first
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import * as errors from '../custom-errors';
|
||||
|
||||
export const bodyParser: () => MiddlewareHandler = () => async (ctx, next) => {
|
||||
if (!['POST', 'PUT', 'PATCH'].includes(ctx.req.method.toUpperCase())) {
|
||||
ctx.set('body', {});
|
||||
return next();
|
||||
}
|
||||
|
||||
const contentType = ctx.req.header('Content-Type') || '';
|
||||
|
||||
try {
|
||||
if (contentType.includes('application/json')) {
|
||||
const text = await ctx.req.text();
|
||||
const body = text ? JSON.parse(text) : {};
|
||||
ctx.set('body', body);
|
||||
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const body = await ctx.req.parseBody();
|
||||
ctx.set('body', body);
|
||||
} else if (contentType.includes('multipart/form-data')) {
|
||||
const body = await ctx.req.parseBody({ all: true });
|
||||
ctx.set('body', body);
|
||||
} else {
|
||||
// No recognized content type, set empty body
|
||||
ctx.set('body', {});
|
||||
}
|
||||
} catch (ex) {
|
||||
// throw errors.BAD_REQUEST('Invalid request body');
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './body-parser';
|
||||
export * from './user-middleware';
|
||||
export * from './origin-middleware';
|
||||
export * from './origin-validation';
|
||||
export * from './rate-limiter';
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
export const originMiddleware: MiddlewareHandler = function (ctx, next) {
|
||||
let origin = ctx.req.header('origin');
|
||||
|
||||
if (!origin) {
|
||||
const referer = ctx.req.header('referer');
|
||||
if (referer) {
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
origin = url.origin;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.set('origin', origin);
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import * as errors from '../custom-errors';
|
||||
|
||||
const { PUBLIC_BUILD_ENV } = process.env;
|
||||
|
||||
const ALLOWED_ORIGINS: Record<string, string[]> = {
|
||||
staging: ['https://staging.officer.dev'],
|
||||
production: ['https://app.officer.dev', 'https://edge.officer.dev'],
|
||||
};
|
||||
|
||||
export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
|
||||
// Dev environment: allow any origin
|
||||
if (!PUBLIC_BUILD_ENV || PUBLIC_BUILD_ENV === 'dev' || PUBLIC_BUILD_ENV === 'development') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If origin is present, validate it
|
||||
if (origin) {
|
||||
// Chrome extension check
|
||||
if (origin.startsWith('chrome-extension://')) {
|
||||
if (PUBLIC_BUILD_ENV === 'staging') {
|
||||
return true; // Allow any extension in staging
|
||||
}
|
||||
if (PUBLIC_BUILD_ENV === 'production') {
|
||||
return origin === 'chrome-extension://fjooappefigppfjolhadliepialebodg';
|
||||
}
|
||||
}
|
||||
|
||||
// Web origin check
|
||||
const allowed = ALLOWED_ORIGINS[PUBLIC_BUILD_ENV];
|
||||
return !!allowed?.includes(origin);
|
||||
}
|
||||
|
||||
// No origin header: allow same-origin requests by checking Host header
|
||||
// This handles cases where browsers don't send Origin for same-origin requests
|
||||
if (host) {
|
||||
const allowed = ALLOWED_ORIGINS[PUBLIC_BUILD_ENV];
|
||||
return !!allowed?.some((o) => o.endsWith(host));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const originValidationMiddleware: MiddlewareHandler = function (ctx, next) {
|
||||
const origin = ctx.get('origin') as string | undefined;
|
||||
const host = ctx.req.header('host');
|
||||
if (!isOriginAllowed(origin, host)) {
|
||||
throw errors.FORBIDDEN('Invalid origin');
|
||||
}
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Context, MiddlewareHandler } from 'hono';
|
||||
import * as errors from '../custom-errors';
|
||||
|
||||
type RateLimitEntry = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
type RateLimiterOptions = {
|
||||
windowMs: number;
|
||||
max: number;
|
||||
keyGenerator?: (ctx: Context) => string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const stores = new Map<string, Map<string, RateLimitEntry>>();
|
||||
|
||||
function cleanupStore(store: Map<string, RateLimitEntry>) {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt <= now) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { PUBLIC_BUILD_ENV } = process.env;
|
||||
const isProduction = PUBLIC_BUILD_ENV === 'production' || PUBLIC_BUILD_ENV === 'staging';
|
||||
|
||||
export function rateLimiter(options: RateLimiterOptions): MiddlewareHandler {
|
||||
const {
|
||||
windowMs,
|
||||
max,
|
||||
keyGenerator = (ctx) => ctx.req.header('x-forwarded-for') || 'unknown',
|
||||
message = 'Too many requests, please try again later',
|
||||
} = options;
|
||||
|
||||
const storeId = `${windowMs}-${max}-${Math.random()}`;
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
stores.set(storeId, store);
|
||||
|
||||
// Cleanup every minute
|
||||
setInterval(() => cleanupStore(store), 60_000);
|
||||
|
||||
return async (ctx, next) => {
|
||||
if (!isProduction) return next();
|
||||
|
||||
const key = keyGenerator(ctx);
|
||||
const now = Date.now();
|
||||
const entry = store.get(key);
|
||||
|
||||
if (entry && entry.resetAt > now) {
|
||||
if (entry.count >= max) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
throw errors.TOO_MANY_REQUESTS(message, retryAfter);
|
||||
}
|
||||
entry.count++;
|
||||
} else {
|
||||
store.set(key, {
|
||||
count: 1,
|
||||
resetAt: now + windowMs,
|
||||
});
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-configured rate limiters for common use cases
|
||||
export const signinRateLimiter = rateLimiter({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5,
|
||||
keyGenerator: (ctx) => {
|
||||
const body = ctx.get('body');
|
||||
return body?.email || ctx.req.header('x-forwarded-for') || 'unknown';
|
||||
},
|
||||
message: 'Too many sign in attempts, please try again in 15 minutes',
|
||||
});
|
||||
|
||||
export const forgotPasswordRateLimiter = rateLimiter({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 3,
|
||||
keyGenerator: (ctx) => {
|
||||
const body = ctx.get('body');
|
||||
return body?.email || ctx.req.header('x-forwarded-for') || 'unknown';
|
||||
},
|
||||
message: 'Too many password reset requests, please try again in 1 hour',
|
||||
});
|
||||
|
||||
export const signupRateLimiter = rateLimiter({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 5,
|
||||
keyGenerator: (ctx) => ctx.req.header('x-forwarded-for') || 'unknown',
|
||||
message: 'Too many sign up attempts, please try again in 1 hour',
|
||||
});
|
||||
|
||||
export const passkeyRateLimiter = rateLimiter({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 10,
|
||||
keyGenerator: (ctx) => {
|
||||
const email = ctx.req.param('email');
|
||||
return email || ctx.req.header('x-forwarded-for') || 'unknown';
|
||||
},
|
||||
message: 'Too many passkey requests, please try again in 1 minute',
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { verify } from '@@/jwt';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isOriginAllowed } from './origin-validation';
|
||||
import { officerdb, eq, Users, TokenBlacklist } from 'officerdb';
|
||||
|
||||
// Role permissions: which HTTP methods each role can use
|
||||
// Roles not listed here are denied by default (fail-safe)
|
||||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
Member: ['GET'],
|
||||
Admin: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
Owner: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
'Super Admin': ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
};
|
||||
|
||||
function isMethodAllowed(role: string | null, method: string): boolean {
|
||||
if (!role) return false;
|
||||
const allowedMethods = ROLE_PERMISSIONS[role];
|
||||
if (!allowedMethods) return false; // Unknown role = no access
|
||||
return allowedMethods.includes(method);
|
||||
}
|
||||
|
||||
export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||
const { authorization } = ctx.req.header();
|
||||
|
||||
// Support token in query param for media elements (<audio>, <video>, <img>)
|
||||
// that cannot send Authorization headers
|
||||
let token: string | undefined;
|
||||
if (authorization) {
|
||||
[, token] = authorization.split(' ');
|
||||
} else {
|
||||
token = ctx.req.query('token') || undefined;
|
||||
}
|
||||
if (!token) throw errors.UNAUTHORIZED();
|
||||
|
||||
// Validate origin for authenticated routes
|
||||
const origin = ctx.get('origin') as string | undefined;
|
||||
const host = ctx.req.header('host');
|
||||
if (!isOriginAllowed(origin, host)) {
|
||||
throw errors.FORBIDDEN('Invalid origin');
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await verify(token);
|
||||
if (!user) throw errors.UNAUTHORIZED();
|
||||
|
||||
// Check if token is blacklisted (explicit signout)
|
||||
if (user.jti) {
|
||||
const blacklisted = await officerdb.query.TokenBlacklist.findFirst({
|
||||
where: eq(TokenBlacklist.jti, user.jti),
|
||||
});
|
||||
if (blacklisted) throw errors.UNAUTHORIZED();
|
||||
}
|
||||
|
||||
// Check if token was issued before password change
|
||||
if (user.iat && user.id) {
|
||||
const dbUser = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.id, user.id),
|
||||
columns: { passwordChangedAt: true },
|
||||
});
|
||||
if (dbUser?.passwordChangedAt) {
|
||||
// iat is in seconds, passwordChangedAt is in milliseconds
|
||||
const tokenIssuedAt = user.iat * 1000;
|
||||
if (tokenIssuedAt < dbUser.passwordChangedAt) {
|
||||
throw errors.UNAUTHORIZED();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMethodAllowed(user.role, ctx.req.method)) {
|
||||
throw errors.FORBIDDEN('Insufficient permissions');
|
||||
}
|
||||
ctx.set('user', user);
|
||||
return next();
|
||||
} catch (ex) {
|
||||
if (ex instanceof errors.CustomError) throw ex;
|
||||
throw errors.UNAUTHORIZED();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user