feat(server): W2-A login gate, secrets, settings, instances, audit, engine panel API and PG-backed SSE bus
- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
(migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
{type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
downloader/media-server instances (+test), path-mappings (+verify),
PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
admin APIs against mock qB/Emby, SSE across two Deno subprocesses.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
07db0dac5b
commit
ff230e5da7
34 files changed
+4363
-78
No files matched your search
+149
-22
@@ -1,11 +1,16 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { ApiScope } from "@umt/contracts";
|
||||
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";
|
||||
kind: "api_key" | "dev_admin" | "admin_session";
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
@@ -18,13 +23,32 @@ export interface ApiKeyRecord {
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/** API Key 查询接口,便于测试替换 */
|
||||
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 {
|
||||
@@ -39,6 +63,26 @@ export function kyselyApiKeyStore(db: Kysely<DB>): ApiKeyStore {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +92,28 @@ export interface AuthOptions {
|
||||
devAdminKey?: string | undefined;
|
||||
}
|
||||
|
||||
export type AuthEnv = { Variables: { principal: Principal } };
|
||||
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);
|
||||
@@ -56,25 +121,77 @@ function safeEqual(a: string, b: string) {
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
/** Bearer API Key 鉴权:成功后在 c.var.principal 写入调用者 */
|
||||
export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler<AuthEnv> {
|
||||
return async (c, next) => {
|
||||
const header = c.req.header("authorization") ?? "";
|
||||
const m = /^Bearer\s+(\S+)$/i.exec(header);
|
||||
if (!m?.[1]) throw new ApiException("unauthorized", "缺少 Bearer API Key");
|
||||
const key = m[1];
|
||||
const toScopes = (s: readonly string[]): ApiScope[] =>
|
||||
s.filter((x): x is ApiScope => ApiScopeSchema.safeParse(x).success);
|
||||
|
||||
if (opts.devAdminKey && safeEqual(key, opts.devAdminKey)) {
|
||||
c.set("principal", { kind: "dev_admin", id: "dev-admin", name: "dev-admin", scopes: ["admin"] });
|
||||
/**
|
||||
* /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();
|
||||
}
|
||||
if (!opts.store) throw new ApiException("unauthorized", "API Key 无效");
|
||||
const rec = await opts.store.findByHash(hashApiKey(key));
|
||||
if (!rec || rec.revokedAt) throw new ApiException("unauthorized", "API Key 无效或已撤销");
|
||||
c.set("principal", { kind: "api_key", id: rec.id, name: rec.name, scopes: rec.scopes });
|
||||
// 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");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,7 +203,7 @@ export function hasScope(granted: readonly string[], required: ApiScope): boolea
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 要求调用者至少具备其中一个 scope */
|
||||
/** 要求调用者至少具备其中一个 scope(cookie 会话与 API Key 同样适用) */
|
||||
export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler<AuthEnv> {
|
||||
return async (c, next) => {
|
||||
const p = c.get("principal");
|
||||
@@ -97,3 +214,13 @@ export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler<AuthEnv> {
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
/** 只允许控制台管理员会话(或开发模式 DEV_ADMIN_KEY),拒绝外部 API Key —— 用于改密、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();
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user