diff --git a/apps/api/src/app/__tests__/auth-middleware.test.ts b/apps/api/src/app/__tests__/auth-middleware.test.ts index a4dd56c..5881c31 100644 --- a/apps/api/src/app/__tests__/auth-middleware.test.ts +++ b/apps/api/src/app/__tests__/auth-middleware.test.ts @@ -66,6 +66,8 @@ const get = (path: string, headers: Record = {}): Promise = {}): Promise => Promise.resolve(app.request(path, { method: 'POST', headers })); +const head = (path: string, headers: Record = {}): Promise => + Promise.resolve(app.request(path, { method: 'HEAD', headers })); type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } }; describe('鉴权中间件', () => { @@ -122,6 +124,20 @@ describe('鉴权中间件', () => { const { ticket } = tickets.issue('sub-1', now); expect((await get(`/api/gov/tasks?ticket=${encodeURIComponent(ticket)}`, { authorization: 'Bearer admin' })).status).toBe(200); }); + it('HEAD 按 GET 判:端不够 → 403,与 GET 同款', async () => { + // Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 c.req.method 仍是 'HEAD' —— + // 不归一的话查表必然 miss、落进「any」兜底,于是「只要登录就能跑任何 GET 处理器」。 + expect((await get('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403); + expect((await head('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403); + }); + it('HEAD 该进的还得进:端够了 → 200', async () => { + expect((await head('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200); + }); + it('HEAD 上的票据与 GET 同款(归一之后两条路一致)', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await head(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200); + expect((await head('/api/gov/tasks', {})).status).toBe(401); + }); it('未知路由:没凭据先 401,有凭据再 404', async () => { expect((await get('/api/nope')).status).toBe(401); expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404); diff --git a/apps/api/src/app/auth-middleware.ts b/apps/api/src/app/auth-middleware.ts index ec5b67f..0501282 100644 --- a/apps/api/src/app/auth-middleware.ts +++ b/apps/api/src/app/auth-middleware.ts @@ -3,6 +3,7 @@ * * ① 查路由注册表拿 access;public 直接放行。 * ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / 的路)。 + * 方法先归一(HEAD 按 GET),再查表、再判票据 —— 两处都按分发时的方法算。 * ③ Bearer → org.authenticate(验签 + 名册即时更新 + 角色→端);票据 → org.principalOf(从库现算)。 * ④ 401 = 没凭据 / 凭据坏了(该重新登录);403 = 人对了但进不了这个端 / 账号停用(重新登录没用)。 * 401 与 403 不混用 —— 前端靠它分辨「该重新登录」和「没权限」(手册 4.5)。 @@ -56,15 +57,22 @@ const unauthenticated = (message: string): ApiError => ({ code: 'unauthenticated 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(c.req.method, c.req.path) ?? 'any'; + 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 = c.req.method === 'GET' ? c.req.query('ticket') : undefined; + const ticket = method === 'GET' ? c.req.query('ticket') : undefined; if (bearer === undefined && (ticket === undefined || ticket === '')) { return c.json(unauthenticated('缺少 Bearer token'), 401); }