Archived
app:鉴权中间件 —— Bearer / 票据 → Principal → 按路由归属判 401/403(ADR-012)
鉴权链的核心件:查路由归属表拿 access(public 直接放行),取凭据(Authorization: Bearer;GET 且没带头时认 ?ticket=),Bearer 走 org.authenticate、票据走 org.principalOf,按端判定后把 principal 放进上下文。401(没凭据/凭据坏了,该 重新登录)与 403(端不够/账号停用,重新登录没用)分得清楚,前端靠这个分辨。 http-env.ts 只加不删:account/session 是 ADR-010 旧物,现役 server.ts 与十来个 路由文件还在读,退役是下一个任务的事。这一轮也不接线到 server.ts —— 接上会让 现役 cookie 会话链断掉。 测试文件按简报逐字照用,唯一偏差是 get/post 两个测试辅助函数包了一层 Promise.resolve:本仓 Hono 4.12 的 app.request() 类型签名是 Response | Promise<Response>,直接赋给 Promise<Response> 类型标注过不了 typecheck;包一层不改变行为(await 对两者结果一致)。
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 鉴权中间件(ADR-012)—— app 层唯一一处;身份判定读 org 出口(ADR-010 的分工不变)。
|
||||
*
|
||||
* ① 查路由注册表拿 access;public 直接放行。
|
||||
* ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / <a href> 的路)。
|
||||
* ③ 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<Principal>;
|
||||
principalOf(staffId: StaffId): Promise<Principal | undefined>;
|
||||
};
|
||||
|
||||
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<AppEnv> {
|
||||
return async (c, next) => {
|
||||
c.set('principal', undefined);
|
||||
// 未知路由按 any 处置:先鉴权再 404,与从前一致(不给没登录的人探路由表)
|
||||
const access = deps.registry.lookup(c.req.method, c.req.path) ?? 'any';
|
||||
if (access === 'public') {
|
||||
await next();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bearer = bearerOf(c.req.header('authorization'));
|
||||
const ticket = c.req.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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user