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
@@ -0,0 +1,143 @@
|
||||
import { z } from "zod";
|
||||
import { ApiScope, Timestamp } from "./common.ts";
|
||||
|
||||
// 管理员登录门禁(任务书 §3.1-14,做法见 docs/design/auth-reference.md):
|
||||
// 单管理员账号密码 + 服务端会话(cookie umt_session)+ HMAC 派生 CSRF 双提交 + 可选 TOTP;
|
||||
// 外部程序走 API Key(Bearer)。两种调用者在服务端统一为 Actor。
|
||||
|
||||
/** 会话 cookie 名(HttpOnly,SameSite=Lax) */
|
||||
export const SESSION_COOKIE = "umt_session";
|
||||
/** CSRF cookie 名(非 HttpOnly,前端读出后放进 CSRF_HEADER) */
|
||||
export const CSRF_COOKIE = "umt_csrf";
|
||||
/** 写方法(POST/PUT/PATCH/DELETE)用 cookie 会话调用时必须带的请求头 */
|
||||
export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
/** 统一调用者:控制台会话(admin)或外部 API Key */
|
||||
export const ActorKind = z.enum(["admin", "api_key"]);
|
||||
export type ActorKind = z.infer<typeof ActorKind>;
|
||||
export const Actor = z.object({
|
||||
type: ActorKind,
|
||||
/** admin:admin_user.id(开发模式 DEV_ADMIN_KEY 为 "dev-admin");api_key:api_key.id */
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
scopes: z.array(ApiScope),
|
||||
});
|
||||
export type Actor = z.infer<typeof Actor>;
|
||||
|
||||
const Username = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[A-Za-z0-9_.@-]+$/, "用户名只能包含字母、数字与 _ . @ -");
|
||||
/** 密码:长度 10–256(argon2id 存储,不限制字符集) */
|
||||
export const Password = z.string().min(10, "密码至少 10 个字符").max(256);
|
||||
|
||||
/** POST /auth/setup:首次初始化管理员(setup token 在首次启动时打印到服务端日志一次) */
|
||||
export const AdminSetupRequest = z.object({
|
||||
setupToken: z.string().min(16).max(256),
|
||||
username: Username,
|
||||
password: Password,
|
||||
});
|
||||
export type AdminSetupRequest = z.infer<typeof AdminSetupRequest>;
|
||||
|
||||
/** 6 位 TOTP 码 */
|
||||
export const TotpCode = z.string().regex(/^\d{6}$/, "验证码为 6 位数字");
|
||||
|
||||
/** POST /auth/login */
|
||||
export const LoginRequest = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(1).max(256),
|
||||
/** 开启 TOTP 后第二步提交 */
|
||||
totpCode: TotpCode.optional(),
|
||||
});
|
||||
export type LoginRequest = z.infer<typeof LoginRequest>;
|
||||
|
||||
/** GET /auth/session(免鉴权,SPA 探测登录态与是否需要初始化) */
|
||||
export const SessionInfo = z.object({
|
||||
initialized: z.boolean(),
|
||||
authenticated: z.boolean(),
|
||||
actor: Actor.nullable(),
|
||||
/** 仅 cookie 会话有值 */
|
||||
expiresAt: Timestamp.nullable(),
|
||||
totpEnabled: z.boolean(),
|
||||
});
|
||||
export type SessionInfo = z.infer<typeof SessionInfo>;
|
||||
|
||||
/**
|
||||
* POST /auth/login 结果:ok=登录成功(cookie 已下发);totp_required=密码正确但需第二步验证码
|
||||
* (不计入失败次数,前端保留用户名/密码并追加验证码重新提交)
|
||||
*/
|
||||
export const LoginResult = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("ok"), session: SessionInfo }),
|
||||
z.object({ status: z.literal("totp_required") }),
|
||||
]);
|
||||
export type LoginResult = z.infer<typeof LoginResult>;
|
||||
|
||||
/** POST /auth/password:校验旧密码 → 改密 → 吊销其它会话 */
|
||||
export const PasswordChangeRequest = z.object({
|
||||
currentPassword: z.string().min(1).max(256),
|
||||
newPassword: Password,
|
||||
});
|
||||
export type PasswordChangeRequest = z.infer<typeof PasswordChangeRequest>;
|
||||
export const PasswordChangeResult = z.object({ revokedSessions: z.number().int().nonnegative() });
|
||||
export type PasswordChangeResult = z.infer<typeof PasswordChangeResult>;
|
||||
|
||||
/** GET /auth/totp */
|
||||
export const TotpStatus = z.object({ enabled: z.boolean(), pending: z.boolean() });
|
||||
export type TotpStatus = z.infer<typeof TotpStatus>;
|
||||
/**
|
||||
* POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认;
|
||||
* disable 需要当前密码 + 验证码
|
||||
*/
|
||||
export const TotpAction = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("begin") }),
|
||||
z.object({ action: z.literal("enable"), code: TotpCode }),
|
||||
z.object({ action: z.literal("disable"), password: z.string().min(1).max(256), code: TotpCode }),
|
||||
]);
|
||||
export type TotpAction = z.infer<typeof TotpAction>;
|
||||
export const TotpActionResult = z.object({
|
||||
status: TotpStatus,
|
||||
/** 仅 begin 返回:otpauth://totp/... 与 base32 密钥 */
|
||||
otpauthUri: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
});
|
||||
export type TotpActionResult = z.infer<typeof TotpActionResult>;
|
||||
|
||||
/**
|
||||
* 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。
|
||||
* 新增动作只在这里加。
|
||||
*/
|
||||
export const AuditAction = z.enum([
|
||||
"admin.setup",
|
||||
"admin.login_success",
|
||||
"admin.logout",
|
||||
"admin.password_change",
|
||||
"admin.totp_enable",
|
||||
"admin.totp_disable",
|
||||
"api_key.create",
|
||||
"api_key.update",
|
||||
"api_key.revoke",
|
||||
"settings.update",
|
||||
"secret.create",
|
||||
"secret.rotate",
|
||||
"secret.delete",
|
||||
"library_root.create",
|
||||
"library_root.update",
|
||||
"library_root.delete",
|
||||
"library_root.test",
|
||||
"downloader_instance.create",
|
||||
"downloader_instance.update",
|
||||
"downloader_instance.delete",
|
||||
"downloader_instance.test",
|
||||
"media_server_instance.create",
|
||||
"media_server_instance.update",
|
||||
"media_server_instance.delete",
|
||||
"media_server_instance.test",
|
||||
"path_mapping.create",
|
||||
"path_mapping.update",
|
||||
"path_mapping.delete",
|
||||
"path_mapping.verify",
|
||||
"site_account.credentials_set",
|
||||
"engine.run_cancel",
|
||||
]);
|
||||
export type AuditAction = z.infer<typeof AuditAction>;
|
||||
Reference in new issue
Block a user