Auto-fix of 602 string literals across 74 files (half-width punctuation next to Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow the new full-width summaries. check:copy now runs with --strict in ci. Co-Authored-By: Claude Opus 5.5 <[email protected]>
236 lines
8.6 KiB
TypeScript
236 lines
8.6 KiB
TypeScript
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<ApiKeyRecord | null>;
|
|
touch(id: string): Promise<void>;
|
|
/** 按 sha256(token) 查会话(未过滤过期/吊销,由中间件判断) */
|
|
findSession?(hash: string): Promise<SessionRecord | null>;
|
|
/** 节流更新 last_seen_at */
|
|
touchSession?(id: string): Promise<void>;
|
|
}
|
|
|
|
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<DB>): 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",
|
|
"/auth/passkeys/login/options",
|
|
"/auth/passkeys/login/verify",
|
|
] 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 <key>`:API Key(开发模式另认 DEV_ADMIN_KEY);
|
|
* - 否则 cookie `umt_session`:管理员会话(未过期、未吊销),写方法还要校验 `x-csrf-token` == HMAC 派生值;
|
|
* - PUBLIC_AUTH_PATHS 免鉴权(有有效会话时照样挂 actor)。
|
|
*/
|
|
export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler<AuthEnv> {
|
|
const setActor = (
|
|
c: Parameters<MiddlewareHandler<AuthEnv>>[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 <key>");
|
|
}
|
|
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<AuthEnv> {
|
|
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<AuthEnv> {
|
|
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();
|
|
};
|
|
}
|