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,243 @@
|
||||
import {
|
||||
AdminSetupRequest,
|
||||
CSRF_COOKIE,
|
||||
LoginRequest,
|
||||
LoginResult,
|
||||
PasswordChangeRequest,
|
||||
PasswordChangeResult,
|
||||
SESSION_COOKIE,
|
||||
SessionInfo,
|
||||
TotpAction,
|
||||
TotpActionResult,
|
||||
TotpStatus,
|
||||
} from "@umt/contracts";
|
||||
import type { Context } from "hono";
|
||||
import { deleteCookie, setCookie } from "hono/cookie";
|
||||
import { z } from "zod";
|
||||
import { type AuthEnv, deriveCsrfToken, requireAdminSession } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import {
|
||||
adminActor,
|
||||
type CreatedSession,
|
||||
changePassword,
|
||||
createSession,
|
||||
loadAdmin,
|
||||
revokeSession,
|
||||
sessionInfo,
|
||||
setupAdmin,
|
||||
totpBegin,
|
||||
totpDisable,
|
||||
totpEnable,
|
||||
totpStatus,
|
||||
verifyLogin,
|
||||
} from "./auth-service.ts";
|
||||
import { audit, type Def, endpoint } from "./endpoint.ts";
|
||||
|
||||
// 登录门禁端点:/auth/setup、/auth/login、/auth/session 免鉴权(见 http/auth.ts PUBLIC_AUTH_PATHS),
|
||||
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
|
||||
|
||||
const tags = ["auth"];
|
||||
const OkResult = z.object({ ok: z.literal(true) });
|
||||
|
||||
const D = {
|
||||
setup: {
|
||||
method: "post",
|
||||
path: "/auth/setup",
|
||||
scope: "read",
|
||||
summary: "首次初始化管理员",
|
||||
body: AdminSetupRequest,
|
||||
response: SessionInfo,
|
||||
tags,
|
||||
},
|
||||
login: {
|
||||
method: "post",
|
||||
path: "/auth/login",
|
||||
scope: "read",
|
||||
summary: "管理员登录",
|
||||
body: LoginRequest,
|
||||
response: LoginResult,
|
||||
tags,
|
||||
},
|
||||
logout: { method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult, tags },
|
||||
session: {
|
||||
method: "get",
|
||||
path: "/auth/session",
|
||||
scope: "read",
|
||||
summary: "当前登录态(免鉴权)",
|
||||
response: SessionInfo,
|
||||
tags,
|
||||
},
|
||||
password: {
|
||||
method: "post",
|
||||
path: "/auth/password",
|
||||
scope: "admin",
|
||||
summary: "修改密码(吊销其它会话)",
|
||||
body: PasswordChangeRequest,
|
||||
response: PasswordChangeResult,
|
||||
tags,
|
||||
},
|
||||
totpGet: { method: "get", path: "/auth/totp", scope: "admin", summary: "TOTP 状态", response: TotpStatus, tags },
|
||||
totpPost: {
|
||||
method: "post",
|
||||
path: "/auth/totp",
|
||||
scope: "admin",
|
||||
summary: "TOTP 绑定/启用/停用",
|
||||
body: TotpAction,
|
||||
response: TotpActionResult,
|
||||
tags,
|
||||
},
|
||||
} as const satisfies Record<string, Def>;
|
||||
|
||||
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
|
||||
|
||||
/**
|
||||
* 客户端 IP:只有直连对端是回环/私网(即前面有自己的反代)时才信 X-Real-IP / X-Forwarded-For 最后一跳,
|
||||
* 否则用对端地址——防止客户端伪造头绕过 IP 限速(对应 Caddy trusted_proxies_strict)。
|
||||
*/
|
||||
export function clientIp(c: Context<AuthEnv>): string {
|
||||
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
||||
if (remote && !PRIVATE_IP.test(remote)) return remote;
|
||||
const xri = c.req.header("x-real-ip")?.trim();
|
||||
const xff = c.req
|
||||
.header("x-forwarded-for")
|
||||
?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.at(-1);
|
||||
return xri || xff || remote || "unknown";
|
||||
}
|
||||
|
||||
function isHttps(c: Context<AuthEnv>): boolean {
|
||||
if (new URL(c.req.url).protocol === "https:") return true;
|
||||
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
||||
if (remote && !PRIVATE_IP.test(remote)) return false;
|
||||
return c.req.header("x-forwarded-proto")?.split(",")[0]?.trim() === "https";
|
||||
}
|
||||
|
||||
function issueCookies(c: Context<AuthEnv>, s: { token: string; expiresAt: Date }) {
|
||||
const secure = isHttps(c);
|
||||
setCookie(c, SESSION_COOKIE, s.token, { httpOnly: true, sameSite: "Lax", path: "/", secure, expires: s.expiresAt });
|
||||
setCookie(c, CSRF_COOKIE, deriveCsrfToken(s.token), {
|
||||
httpOnly: false,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
secure,
|
||||
expires: s.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
function clearCookies(c: Context<AuthEnv>) {
|
||||
deleteCookie(c, SESSION_COOKIE, { path: "/" });
|
||||
deleteCookie(c, CSRF_COOKIE, { path: "/" });
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
const deps = () => ({ db: ctx.db, logger: ctx.logger, secrets: ctx.secrets });
|
||||
const meta = (c: Context<AuthEnv>) => ({ ip: clientIp(c), userAgent: c.req.header("user-agent") ?? null });
|
||||
|
||||
const startSession = async (c: Context<AuthEnv>, adminId: string): Promise<CreatedSession> => {
|
||||
const s = await createSession(ctx.db, adminId, meta(c));
|
||||
issueCookies(c, s);
|
||||
return s;
|
||||
};
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.setup,
|
||||
async ({ body, c }) => {
|
||||
const admin = await setupAdmin(deps(), body, clientIp(c));
|
||||
const s = await startSession(c, admin.id);
|
||||
const actor = adminActor(admin);
|
||||
await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username });
|
||||
ctx.logger.info({ username: admin.username }, "管理员已初始化");
|
||||
return await sessionInfo(ctx.db, actor, s.expiresAt);
|
||||
},
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.login,
|
||||
async ({ body, c }) => {
|
||||
const r = await verifyLogin(deps(), body, clientIp(c));
|
||||
if (r.status === "totp_required") return { status: "totp_required" as const };
|
||||
const s = await startSession(c, r.admin.id);
|
||||
const actor = adminActor(r.admin);
|
||||
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
|
||||
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
||||
},
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.session,
|
||||
async ({ c }) => {
|
||||
const session = c.get("session");
|
||||
// CSRF cookie 丢了(被清)时顺手补发
|
||||
if (session) {
|
||||
setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), {
|
||||
httpOnly: false,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
secure: isHttps(c),
|
||||
expires: session.expiresAt,
|
||||
});
|
||||
}
|
||||
return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null);
|
||||
},
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
endpoint(app, D.logout, async ({ c, actor }) => {
|
||||
const session = c.get("session");
|
||||
if (session) {
|
||||
await revokeSession(ctx.db, session.id, "logout");
|
||||
await audit(ctx, actor, "admin.logout", { type: "admin_session", id: session.id });
|
||||
}
|
||||
clearCookies(c);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.password,
|
||||
async ({ body, c, actor }) => {
|
||||
const session = c.get("session");
|
||||
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
|
||||
const revoked = await changePassword(deps(), body, {
|
||||
adminUserId: session.adminUserId,
|
||||
sessionId: session.id,
|
||||
ip: clientIp(c),
|
||||
});
|
||||
await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked });
|
||||
return { revokedSessions: revoked };
|
||||
},
|
||||
{ middleware: [requireAdminSession()] },
|
||||
);
|
||||
|
||||
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)), { middleware: [requireAdminSession()] });
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.totpPost,
|
||||
async ({ body, actor }) => {
|
||||
const admin = await loadAdmin(ctx.db);
|
||||
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
|
||||
if (body.action === "begin") {
|
||||
const r = await totpBegin(deps(), admin);
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
|
||||
}
|
||||
if (body.action === "enable") {
|
||||
await totpEnable(deps(), admin, body.code);
|
||||
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
|
||||
} else {
|
||||
await totpDisable(deps(), admin, body);
|
||||
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
|
||||
}
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)) };
|
||||
},
|
||||
{ middleware: [requireAdminSession()] },
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user