/** * Mandu Auth Guards - 인증/인가 헬퍼 🔐 * * beforeHandle에서 사용할 수 있는 타입-안전 인증 헬퍼 * 인증 실패 시 적절한 에러를 throw하여 체인 중단 */ import type { ManduContext } from "./context"; /** * 인증 실패 에러 (401 Unauthorized) */ export class AuthenticationError extends Error { readonly statusCode = 401; constructor(message: string = "Authentication required") { super(message); this.name = "AuthenticationError"; } } /** * 인가 실패 에러 (403 Forbidden) */ export class AuthorizationError extends Error { readonly statusCode = 403; readonly requiredRoles?: string[]; constructor(message: string = "Access denied", requiredRoles?: string[]) { super(message); this.name = "AuthorizationError"; this.requiredRoles = requiredRoles; } } /** * 기본 User 인터페이스 * 프로젝트에서 확장하여 사용 */ export interface BaseUser { id: string; [key: string]: unknown; } /** * Role을 가진 User 인터페이스 */ export interface UserWithRole extends BaseUser { role: string; } /** * Roles 배열을 가진 User 인터페이스 */ export interface UserWithRoles extends BaseUser { roles: string[]; } // ============================================ // 🔐 Auth Helpers // ============================================ /** * 인증된 사용자 필수 * beforeHandle에서 user가 없으면 AuthenticationError throw * * @param ctx ManduContext * @param key store에서 user를 찾을 키 (기본: 'user') * @returns 인증된 User (타입 확정) * @throws AuthenticationError * * @example * typescript * import { requireUser } from '@mandujs/core' * * export default Mandu.filling() * .beforeHandle(async (ctx) => { * // JWT 토큰 검증 후 user 저장 * const user = await verifyToken(ctx.headers.get('Authorization')); * ctx.set('user', user); * // void 반환 시 계속 진행 * }) * .get((ctx) => { * const user = requireUser(ctx); // User 타입 확정, 없으면 401 * return ctx.ok({ message: "Hello, " + user.id + "!" }); * }) * */ export function requireUser( ctx: ManduContext, key: string = "user" ): T { const user = ctx.get(key); if (!user) { throw new AuthenticationError("User context is required"); } if (typeof user !== "object" || !("id" in user)) { throw new AuthenticationError("Invalid user context"); } return user; } /** * 특정 역할 필수 (단일 role 필드) * * @param ctx ManduContext * @param roles 허용된 역할 목록 * @param key store에서 user를 찾을 키 (기본: 'user') * @returns 인증된 User (타입 확정) * @throws AuthenticationError (user 없음) * @throws AuthorizationError (역할 불일치) * * @example * typescript * .beforeHandle((ctx) => { * requireRole(ctx, 'admin', 'moderator'); // admin 또는 moderator만 허용 * // void 반환 시 계속 진행 * }) * */ export function requireRole( ctx: ManduContext, ...roles: string[] ): T { const user = requireUser(ctx); if (!("role" in user) || typeof user.role !== "string") { throw new AuthorizationError("User has no role defined"); } if (!roles.includes(user.role)) { throw new AuthorizationError( "Required role: " + roles.join(" or "), roles ); } return user; } /** * 특정 역할 중 하나 필수 (roles 배열 필드) * * @param ctx ManduContext * @param roles 허용된 역할 목록 (하나라도 있으면 통과) * @param key store에서 user를 찾을 키 (기본: 'user') * @returns 인증된 User (타입 확정) * @throws AuthenticationError (user 없음) * @throws AuthorizationError (역할 불일치) * * @example * typescript * .beforeHandle((ctx) => { * requireAnyRole(ctx, 'editor', 'admin'); // editor 또는 admin 역할 필요 * // void 반환 시 계속 진행 * }) * */ export function requireAnyRole( ctx: ManduContext, ...roles: string[] ): T { const user = requireUser(ctx); if (!("roles" in user) || !Array.isArray(user.roles)) { throw new AuthorizationError("User has no roles defined"); } const hasRole = roles.some((role) => user.roles.includes(role)); if (!hasRole) { throw new AuthorizationError( "Required one of roles: " + roles.join(", "), roles ); } return user; } /** * 모든 역할 필수 (roles 배열 필드) * * @param ctx ManduContext * @param roles 필요한 역할 목록 (모두 있어야 통과) * @returns 인증된 User (타입 확정) * @throws AuthenticationError (user 없음) * @throws AuthorizationError (역할 불일치) * * @example * typescript * .beforeHandle((ctx) => { * requireAllRoles(ctx, 'verified', 'premium'); // verified AND premium 필요 * // void 반환 시 계속 진행 * }) * */ export function requireAllRoles( ctx: ManduContext, ...roles: string[] ): T { const user = requireUser(ctx); if (!("roles" in user) || !Array.isArray(user.roles)) { throw new AuthorizationError("User has no roles defined"); } const missingRoles = roles.filter((role) => !user.roles.includes(role)); if (missingRoles.length > 0) { throw new AuthorizationError( "Missing required roles: " + missingRoles.join(", "), roles ); } return user; } // ============================================ // 🔐 Auth Handler Factory // ============================================ /** * 인증 beforeHandle 생성 팩토리 * 반복되는 인증 로직을 beforeHandle로 변환 * * @example * typescript * const authHandler = createAuthGuard(async (ctx) => { * const token = ctx.headers.get('Authorization')?.replace('Bearer ', ''); * if (!token) return null; * return await verifyJwt(token); * }); * * export default Mandu.filling() * .beforeHandle(authHandler) * .get((ctx) => { * const user = requireUser(ctx); * return ctx.ok({ user }); * }) * */ export function createAuthGuard( authenticator: (ctx: ManduContext) => T | null | Promise, options: { key?: string; onUnauthenticated?: (ctx: ManduContext) => Response; } = {} ) { const { key = "user", onUnauthenticated } = options; return async (ctx: ManduContext): Promise => { try { const user = await authenticator(ctx); if (user) { ctx.set(key, user); return; // void 반환 시 계속 진행 } if (onUnauthenticated) { return onUnauthenticated(ctx); } return ctx.unauthorized("Authentication required"); } catch (error) { if (error instanceof AuthenticationError) { return ctx.unauthorized(error.message); } throw error; } }; } /** * 역할 기반 beforeHandle 생성 팩토리 * * @example * typescript * const adminOnly = createRoleGuard('admin'); * const editorOrAdmin = createRoleGuard('editor', 'admin'); * * export default Mandu.filling() * .beforeHandle(authHandler) * .beforeHandle(adminOnly) // admin만 접근 가능 * .delete((ctx) => ctx.noContent()) * */ export function createRoleGuard(...allowedRoles: string[]) { return (ctx: ManduContext): Response | void => { try { requireRole(ctx, ...allowedRoles); return; // void 반환 시 계속 진행 } catch (error) { if (error instanceof AuthenticationError) { return ctx.unauthorized(error.message); } if (error instanceof AuthorizationError) { return ctx.forbidden(error.message); } throw error; } }; }