This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/app/auth-middleware.ts
T
xcsandClaude Opus 5 5964a80b28 鉴权中间件:HEAD 按 GET 判 —— 不归一等于「只要登录就能跑任何 GET 处理器」
Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 `c.req.method` 读的是请求行上的
原始方法,仍是 'HEAD';路由注册表只装 HTTP_METHODS 那五个,于是 lookup('HEAD', …)
**必然 miss** → 落进 `?? 'any'` 兜底 → 一个 `ends: []` 的人(Keycloak 里还没配角色的
新员工)照样能执行三个端的全部 GET 处理器。不绕过认证(无凭据仍 401),够不到
POST/PUT/DELETE,响应体被框架丢掉 —— 但它绕过**端授权**:响应头照抄过去
(desk 那两处 content-disposition 带着资料文件名),而 GET 处理器不是都没副作用
(`GET /api/reports/:period` 一路走到 report-service 的 store.save)。

改:查表之前先归一方法;**票据那一路的 GET 判断用同一个归一后的 method**,
否则 HEAD 拿不到票据那条路,两条路行为不一致。

测(auth-middleware.test.ts +3):HEAD 到 ops 路由持只有 workbench 的 token → 403
(与 GET 同款)· HEAD 到自己端内的路由 → 200 · HEAD 上的票据与 GET 同款。
反向验证:撤掉归一那一行,前两条当场红(200 / 401),装回即绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
2026-08-25 19:41:43 +08:00

112 lines
4.9 KiB
TypeScript

/**
* 鉴权中间件(ADR-012)—— app 层唯一一处;身份判定读 org 出口(ADR-010 的分工不变)。
*
* ① 查路由注册表拿 access;public 直接放行。
* ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / <a href> 的路)。
* 方法先归一(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<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);
/*
* 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;
};
}