import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import { type Actor, type ApiScope, ApiScope as ApiScopeSchema, CSRF_HEADER, SESSION_COOKIE } from "@umt/contracts"; import type { DB, Kysely } from "@umt/db"; import type { MiddlewareHandler } from "hono"; import { getCookie } from "hono/cookie"; import { ApiException } from "./errors.ts"; // 鉴权中间件:控制台 cookie 会话(umt_session)与外部 API Key(Bearer)二选一, // 统一写入 c.var.actor({type:'admin'|'api_key', id, name, scopes});requireScope 对两者一视同仁。 // 旧字段 c.var.principal 保留兼容(kind 区分 api_key / dev_admin / admin_session)。 export interface Principal { kind: "api_key" | "dev_admin" | "admin_session"; id: string; name: string; scopes: string[]; } export interface ApiKeyRecord { id: string; name: string; scopes: string[]; revokedAt: Date | null; } export interface SessionRecord { id: string; adminUserId: string; username: string; expiresAt: Date; revokedAt: Date | null; lastSeenAt: Date; } /** API Key / 会话查询接口,便于测试替换(会话方法可选:测试内存仓库可以只实现 API Key) */ export interface ApiKeyStore { findByHash(hash: string): Promise; touch(id: string): Promise; /** 按 sha256(token) 查会话(未过滤过期/吊销,由中间件判断) */ findSession?(hash: string): Promise; /** 节流更新 last_seen_at */ touchSession?(id: string): Promise; } export const hashApiKey = (key: string) => createHash("sha256").update(key, "utf8").digest("hex"); /** 会话 token 与 API Key 一样只存 sha256(高熵随机串不需要慢哈希) */ export const hashSessionToken = hashApiKey; /** CSRF 双提交 token:HMAC-SHA256(key=会话原始 token, "umt/csrf/v1") */ export const deriveCsrfToken = (sessionToken: string) => createHmac("sha256", sessionToken).update("umt/csrf/v1").digest("base64url"); export function kyselyApiKeyStore(db: Kysely): ApiKeyStore { return { async findByHash(hash) { const row = await db .selectFrom("api_key") .select(["id", "name", "scopes", "revoked_at"]) .where("key_hash", "=", hash) .executeTakeFirst(); return row ? { id: row.id, name: row.name, scopes: row.scopes, revokedAt: row.revoked_at } : null; }, async touch(id) { await db.updateTable("api_key").set({ last_used_at: new Date() }).where("id", "=", id).execute(); }, async findSession(hash) { const row = await db .selectFrom("admin_session as s") .innerJoin("admin_user as u", "u.id", "s.admin_user_id") .select(["s.id", "s.admin_user_id", "u.username", "s.expires_at", "s.revoked_at", "s.last_seen_at"]) .where("s.session_hash", "=", hash) .executeTakeFirst(); if (!row?.username) return null; return { id: row.id, adminUserId: row.admin_user_id, username: row.username, expiresAt: row.expires_at, revokedAt: row.revoked_at, lastSeenAt: row.last_seen_at, }; }, async touchSession(id) { await db.updateTable("admin_session").set({ last_seen_at: new Date() }).where("id", "=", id).execute(); }, }; } export interface AuthOptions { store: ApiKeyStore | null; /** 仅开发模式传入 */ devAdminKey?: string | undefined; } export interface SessionVar { id: string; /** cookie 里的原始 token(用于派生 CSRF、改密时保留当前会话);不要写日志 */ token: string; adminUserId: string; expiresAt: Date; } export type AuthEnv = { Variables: { principal: Principal; actor: Actor; /** 仅 cookie 会话调用时存在 */ session: SessionVar | undefined; }; }; /** 免鉴权路径(相对 /api/v1):登录前就要能访问;带有效 cookie 时仍会挂上 actor */ export const PUBLIC_AUTH_PATHS = ["/auth/setup", "/auth/login", "/auth/session"] as const; const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** last_seen_at 节流写库间隔 */ const TOUCH_INTERVAL_MS = 60_000; function safeEqual(a: string, b: string) { const ab = Buffer.from(a); const bb = Buffer.from(b); return ab.length === bb.length && timingSafeEqual(ab, bb); } const toScopes = (s: readonly string[]): ApiScope[] => s.filter((x): x is ApiScope => ApiScopeSchema.safeParse(x).success); /** * /api/v1 鉴权: * - `Authorization: Bearer `:API Key(开发模式另认 DEV_ADMIN_KEY); * - 否则 cookie `umt_session`:管理员会话(未过期、未吊销),写方法还要校验 `x-csrf-token` == HMAC 派生值; * - PUBLIC_AUTH_PATHS 免鉴权(有有效会话时照样挂 actor)。 */ export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler { const setActor = ( c: Parameters>[0], p: Principal, type: Actor["type"], session?: SessionVar, ) => { c.set("principal", p); c.set("actor", { type, id: p.id, name: p.name, scopes: toScopes(p.scopes) }); c.set("session", session); }; return async (c, next) => { const rel = c.req.path.replace(/^\/api\/v1/, ""); const isPublic = (PUBLIC_AUTH_PATHS as readonly string[]).includes(rel); const header = c.req.header("authorization") ?? ""; if (header) { const m = /^Bearer\s+(\S+)$/i.exec(header); if (!m?.[1]) { if (isPublic) return next(); throw new ApiException("unauthorized", "Authorization 头格式应为 Bearer "); } const key = m[1]; if (opts.devAdminKey && safeEqual(key, opts.devAdminKey)) { setActor(c, { kind: "dev_admin", id: "dev-admin", name: "dev-admin", scopes: ["admin"] }, "admin"); return next(); } const rec = opts.store ? await opts.store.findByHash(hashApiKey(key)) : null; if (!rec || rec.revokedAt) { if (isPublic) return next(); throw new ApiException("unauthorized", "API Key 无效或已撤销"); } setActor(c, { kind: "api_key", id: rec.id, name: rec.name, scopes: rec.scopes }, "api_key"); // last_used_at 尽力更新,失败不影响请求 opts.store?.touch(rec.id).catch(() => {}); return next(); } const token = getCookie(c, SESSION_COOKIE); if (token && opts.store?.findSession) { const s = await opts.store.findSession(hashSessionToken(token)); const now = Date.now(); if (s && !s.revokedAt && s.expiresAt.getTime() > now) { if (!isPublic && WRITE_METHODS.has(c.req.method)) { const provided = c.req.header(CSRF_HEADER); if (!provided || !safeEqual(provided, deriveCsrfToken(token))) { throw new ApiException("forbidden", "CSRF 校验失败,请刷新页面后重试"); } } setActor(c, { kind: "admin_session", id: s.adminUserId, name: s.username, scopes: ["admin"] }, "admin", { id: s.id, token, adminUserId: s.adminUserId, expiresAt: s.expiresAt, }); if (now - s.lastSeenAt.getTime() > TOUCH_INTERVAL_MS) opts.store.touchSession?.(s.id).catch(() => {}); return next(); } } if (isPublic) return next(); throw new ApiException("unauthorized", token ? "会话已过期或已注销,请重新登录" : "需要登录或 Bearer API Key"); }; } /** scope 蕴含关系:admin 覆盖全部;organize.execute 蕴含 organize.read */ export function hasScope(granted: readonly string[], required: ApiScope): boolean { if (granted.includes("admin")) return true; if (granted.includes(required)) return true; if (required === "organize.read" && granted.includes("organize.execute")) return true; return false; } /** 要求调用者至少具备其中一个 scope(cookie 会话与 API Key 同样适用) */ export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler { return async (c, next) => { const p = c.get("principal"); if (!p) throw new ApiException("unauthorized", "未鉴权"); if (!anyOf.some((s) => hasScope(p.scopes, s))) { throw new ApiException("forbidden", `需要 scope: ${anyOf.join(" | ")}`); } return next(); }; } /** * 只允许控制台管理员会话,拒绝外部 API Key 与开发模式 DEV_ADMIN_KEY(DEV key 等同 admin scope 的 Key)—— * 用于注册表标 auth:"session" 的端点(改密、TOTP、审批、文件下载、清空回收站)。 */ export function requireAdminSession(): MiddlewareHandler { return async (c, next) => { const p = c.get("principal"); if (!p) throw new ApiException("unauthorized", "未鉴权"); if (p.kind !== "admin_session") throw new ApiException("forbidden", "该操作只能在控制台登录后进行"); return next(); }; }