/** * 鉴权中间件(ADR-012)—— app 层唯一一处;身份判定读 org 出口(ADR-010 的分工不变)。 * * ① 查路由注册表拿 access;public 直接放行。 * ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / 的路)。 * 方法先归一(HEAD 按 GET),再查表、再判票据 —— 两处都按分发时的方法算。 * ③ Bearer → org.authenticate(验签 + 名册即时更新 + 角色→端);票据 → org.principalOf(从库现算)。 * ④ 401 = 没凭据 / 凭据坏了(该重新登录);403 = 人对了但进不了这个端 / 账号停用(重新登录没用)。 * 401 与 403 不混用 —— 前端靠它分辨「该重新登录」和「没权限」(手册 4.5)。 */ import type { MiddlewareHandler } from 'hono'; import type { ApiError, Principal, RouteAccess, StaffId, Surface } from '@contracts'; import { SURFACE_LABEL, staffIdSchema } from '@contracts'; import { AccountDisabledError, TokenRejectedError } from '@modules/org'; import type { AppEnv } from './http-env.ts'; import type { RouteRegistry } from './route-registry.ts'; import type { TicketIssuer } from './ticket.ts'; /** org 出口里中间件用到的那两口 —— 窄接口,测试好伪造 */ export type Authenticator = { authenticate(token: string): Promise; principalOf(staffId: StaffId): Promise; }; export type AuthMiddlewareDeps = { readonly registry: RouteRegistry; readonly org: Authenticator; readonly tickets: TicketIssuer; readonly clock: { now(): Date }; }; export function bearerOf(header: string | undefined): string | undefined { if (header === undefined) return undefined; const match = /^bearer\s+(\S+)\s*$/i.exec(header.trim()); return match?.[1]; } /** access 要求的端;public / any 不要求 */ export function requiredEnds(access: RouteAccess): readonly Surface[] | undefined { if (access === 'public' || access === 'any') return undefined; return typeof access === 'string' ? [access] : access; } export function forbiddenBody(need: readonly Surface[], principal: Principal): ApiError { const needText = need.map((s) => `「${SURFACE_LABEL[s]}」`).join(' / '); const roles = principal.roles.length === 0 ? '无' : principal.roles.join(', '); const can = principal.ends.length === 0 ? '无端可进' : principal.ends.map((s) => SURFACE_LABEL[s]).join('、'); return { code: 'forbidden', message: `进入${needText}需要授权;你的角色(${roles})可进:${can}`, detail: { need: [...need], ends: [...principal.ends] }, }; } const unauthenticated = (message: string): ApiError => ({ code: 'unauthenticated', message }); export function createAuthMiddleware(deps: AuthMiddlewareDeps): MiddlewareHandler { return async (c, next) => { c.set('principal', undefined); /* * HEAD 按 GET 判。Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 `c.req.method` * 读的是请求行上的原始方法 —— 不归一的话 lookup 必然 miss,落进 `?? 'any'` 兜底, * 于是「只要登录就能跑任何 GET 处理器」。**凭据与归属一律按分发时的方法判, * 不按请求行上的字面方法。** */ const method = c.req.method === 'HEAD' ? 'GET' : c.req.method; // 未知路由按 any 处置:先鉴权再 404,与从前一致(不给没登录的人探路由表) const access = deps.registry.lookup(method, c.req.path) ?? 'any'; if (access === 'public') { await next(); return undefined; } const bearer = bearerOf(c.req.header('authorization')); const ticket = method === 'GET' ? c.req.query('ticket') : undefined; if (bearer === undefined && (ticket === undefined || ticket === '')) { return c.json(unauthenticated('缺少 Bearer token'), 401); } let principal: Principal; try { if (bearer !== undefined) { principal = await deps.org.authenticate(bearer); } else { const opened = deps.tickets.verify(ticket ?? '', deps.clock.now()); if (opened === undefined) { return c.json(unauthenticated('票据无效或已过期 —— 重新取一张 GET /api/auth/ticket'), 401); } const found = await deps.org.principalOf(staffIdSchema.parse(opened.staffId)); if (found === undefined) return c.json(unauthenticated('票据对应的员工不存在'), 401); principal = found; } } catch (error) { if (error instanceof TokenRejectedError) return c.json(unauthenticated(error.message), 401); if (error instanceof AccountDisabledError) { const body: ApiError = { code: 'forbidden', message: error.message }; return c.json(body, 403); } throw error; } const need = requiredEnds(access); if (need !== undefined && !need.some((s) => principal.ends.includes(s))) { return c.json(forbiddenBody(need, principal), 403); } c.set('principal', principal); await next(); return undefined; }; }