From 81a185cd612d633fd81e7a4223a8e36cd9658fa4 Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 17:14:30 +0800 Subject: [PATCH] =?UTF-8?q?app:=E9=89=B4=E6=9D=83=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E4=BB=B6=20=E2=80=94=E2=80=94=20Bearer=20/=20=E7=A5=A8?= =?UTF-8?q?=E6=8D=AE=20=E2=86=92=20Principal=20=E2=86=92=20=E6=8C=89?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=BD=92=E5=B1=9E=E5=88=A4=20401/403(ADR-012?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 鉴权链的核心件:查路由归属表拿 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,直接赋给 Promise 类型标注过不了 typecheck;包一层不改变行为(await 对两者结果一致)。 --- .../src/app/__tests__/auth-middleware.test.ts | 148 ++++++++++++++++++ apps/api/src/app/auth-middleware.ts | 103 ++++++++++++ apps/api/src/app/http-env.ts | 11 +- 3 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/app/__tests__/auth-middleware.test.ts create mode 100644 apps/api/src/app/auth-middleware.ts diff --git a/apps/api/src/app/__tests__/auth-middleware.test.ts b/apps/api/src/app/__tests__/auth-middleware.test.ts new file mode 100644 index 0000000..a4dd56c --- /dev/null +++ b/apps/api/src/app/__tests__/auth-middleware.test.ts @@ -0,0 +1,148 @@ +/** 小 Hono + 假的 org 出口:中间件的四个出口(放行 / 401 / 403 / 票据)逐一走一遍 */ +import { describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import type { Position, Principal, Surface } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { AccountDisabledError, TokenRejectedError } from '@modules/org'; +import { bearerOf, createAuthMiddleware, forbiddenBody, requiredEnds } from '../auth-middleware.ts'; +import type { AppEnv } from '../http-env.ts'; +import { buildRouteRegistry } from '../route-registry.ts'; +import { createTicketIssuer } from '../ticket.ts'; + +const position: Position = { id: positionIdSchema.parse('none'), code: 'NONE', name: '未分配' }; +const principal = (ends: Surface[], roles: string[]): Principal => ({ + staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles }, + position, + roles, + ends, +}); +const ADMIN = principal(['design', 'ops', 'workbench'], ['admin']); +const USER = principal(['workbench'], ['user']); +const NOBODY = principal([], ['auditor']); + +const tickets = createTicketIssuer(Buffer.alloc(32, 3)); +const now = new Date('2026-08-25T09:00:00Z'); + +function makeApp(): Hono { + const app = new Hono(); + app.use( + '*', + createAuthMiddleware({ + registry: buildRouteRegistry([ + { method: 'GET', path: '/api/health', access: 'public' }, + { method: 'GET', path: '/api/me', access: 'any' }, + { method: 'GET', path: '/api/desk/tasks', access: 'workbench' }, + { method: 'GET', path: '/api/gov/tasks', access: 'ops' }, + { method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' }, + { method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] }, + ]), + org: { + authenticate: async (token) => { + if (token === 'admin') return ADMIN; + if (token === 'user') return USER; + if (token === 'nobody') return NOBODY; + if (token === 'disabled') throw new AccountDisabledError('alice'); + throw new TokenRejectedError('签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'); + }, + principalOf: async (id) => (id === 'sub-1' ? USER : undefined), + }, + tickets, + clock: { now: () => now }, + }), + ); + app.get('/api/health', (c) => c.json({ ok: true })); + app.get('/api/me', (c) => c.json({ account: c.get('principal')?.staff.account ?? null })); + app.get('/api/desk/tasks', (c) => c.json({ ok: true })); + app.get('/api/gov/tasks', (c) => c.json({ ok: true })); + app.post('/api/gov/tasks/:taskId/control', (c) => c.json({ ok: true })); + app.get('/api/gov/staff', (c) => c.json({ ok: true })); + return app; +} + +const app = makeApp(); +// app.request() 的类型签名是 Response | Promise(Hono 4.12);包一层 Promise.resolve +// 只是把类型收窄成调用方好用的 Promise,不改变行为(await 对两者结果一致)。 +const get = (path: string, headers: Record = {}): Promise => + Promise.resolve(app.request(path, { headers })); +const post = (path: string, headers: Record = {}): Promise => + Promise.resolve(app.request(path, { method: 'POST', headers })); +type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } }; + +describe('鉴权中间件', () => { + it('public 不要凭据', async () => { + expect((await get('/api/health')).status).toBe(200); + }); + it('没凭据 → 401', async () => { + const res = await get('/api/me'); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ code: 'unauthenticated', message: '缺少 Bearer token' }); + }); + it('坏 token → 401 带人话', async () => { + const res = await get('/api/me', { authorization: 'Bearer garbage' }); + expect(res.status).toBe(401); + expect(((await res.json()) as Body).message).toContain('签名校验失败'); + }); + it('any:登录即可,连无端可进的人也行', async () => { + const res = await get('/api/me', { authorization: 'Bearer nobody' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ account: 'alice' }); + }); + it('端不够 → 403,说清需要哪个端、你能进哪几个', async () => { + const res = await get('/api/gov/tasks', { authorization: 'Bearer user' }); + expect(res.status).toBe(403); + const body = (await res.json()) as Body; + expect(body.code).toBe('forbidden'); + expect(body.message).toContain('运营中心'); + expect(body.message).toContain('工作台'); + expect(body.detail).toEqual({ need: ['ops'], ends: ['workbench'] }); + }); + it('端够了 → 放行;数组归属任一命中即可', async () => { + expect((await get('/api/gov/tasks', { authorization: 'Bearer admin' })).status).toBe(200); + expect((await get('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200); + expect((await get('/api/gov/staff', { authorization: 'Bearer admin' })).status).toBe(200); + expect((await get('/api/gov/staff', { authorization: 'Bearer user' })).status).toBe(403); + }); + it('账号停用 → 403(重新登录没用)', async () => { + const res = await get('/api/me', { authorization: 'Bearer disabled' }); + expect(res.status).toBe(403); + expect(((await res.json()) as Body).message).toContain('停用'); + }); + it('票据:GET 上认,POST 上不认', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200); + expect((await post(`/api/gov/tasks/t-1/control?ticket=${encodeURIComponent(ticket)}`)).status).toBe(401); + }); + it('过期票据 → 401 指路重取', async () => { + const { ticket } = tickets.issue('sub-1', new Date(now.getTime() - 120_000)); + const res = await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`); + expect(res.status).toBe(401); + expect(((await res.json()) as Body).message).toContain('/api/auth/ticket'); + }); + it('Bearer 与票据同时在:Bearer 说了算', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await get(`/api/gov/tasks?ticket=${encodeURIComponent(ticket)}`, { authorization: 'Bearer admin' })).status).toBe(200); + }); + it('未知路由:没凭据先 401,有凭据再 404', async () => { + expect((await get('/api/nope')).status).toBe(401); + expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404); + }); +}); + +describe('小件', () => { + it('bearerOf 认大小写与空白', () => { + expect(bearerOf('bearer abc')).toBe('abc'); + expect(bearerOf('Bearer abc ')).toBe('abc'); + expect(bearerOf('Basic abc')).toBeUndefined(); + expect(bearerOf(undefined)).toBeUndefined(); + expect(bearerOf('Bearer ')).toBeUndefined(); + }); + it('requiredEnds', () => { + expect(requiredEnds('public')).toBeUndefined(); + expect(requiredEnds('any')).toBeUndefined(); + expect(requiredEnds('ops')).toEqual(['ops']); + expect(requiredEnds(['design', 'ops'])).toEqual(['design', 'ops']); + }); + it('forbiddenBody 对无端可进的人如实说', () => { + expect(forbiddenBody(['design'], NOBODY).message).toContain('无端可进'); + }); +}); diff --git a/apps/api/src/app/auth-middleware.ts b/apps/api/src/app/auth-middleware.ts new file mode 100644 index 0000000..ec5b67f --- /dev/null +++ b/apps/api/src/app/auth-middleware.ts @@ -0,0 +1,103 @@ +/** + * 鉴权中间件(ADR-012)—— app 层唯一一处;身份判定读 org 出口(ADR-010 的分工不变)。 + * + * ① 查路由注册表拿 access;public 直接放行。 + * ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / 的路)。 + * ③ 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); + // 未知路由按 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; + }; +} diff --git a/apps/api/src/app/http-env.ts b/apps/api/src/app/http-env.ts index 0da4db4..c8b814a 100644 --- a/apps/api/src/app/http-env.ts +++ b/apps/api/src/app/http-env.ts @@ -1,11 +1,12 @@ /** * Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。 */ -import type { Session } from '@contracts'; +import type { Principal, Session } from '@contracts'; /** - * 请求上下文(ADR-010)。 - * `session` 由鉴权中间件从**服务端会话表**解出 —— 不再有「cookie 里写谁就是谁」的明文身份; - * `account` 保留为便捷读法,值恒取自已核验的会话,免鉴权路由上为空串。 + * 请求上下文。`principal` 由鉴权中间件从 Bearer / 票据解出(ADR-012); + * `account` / `session` 是 ADR-010 的旧物,随 Task 8 一并删除。 */ -export type AppEnv = { Variables: { account: string; session: Session | undefined } }; +export type AppEnv = { + Variables: { account: string; session: Session | undefined; principal: Principal | undefined }; +};