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,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<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
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<Response>(Hono 4.12);包一层 Promise.resolve
|
||||
// 只是把类型收窄成调用方好用的 Promise<Response>,不改变行为(await 对两者结果一致)。
|
||||
const get = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||||
Promise.resolve(app.request(path, { headers }));
|
||||
const post = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||||
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('无端可进');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user