Files
UHD_Media_Tool/apps/server/src/modules/auth-settings/auth-routes.ts
T
rootandClaude Opus 5.5 f48e1aac96 fix(security): W4-E1 approvals admin-session only, shared redact table, TMDB key redaction, prod mock gate
- POST /approvals and PATCH /approvals/{id} require a console admin session
  (EndpointDef auth:"session"); API keys incl. admin scope get 403, so an
  organize.execute key can no longer approve its own plan (S1)
- tests for single-use approval checks: expired, revision/payload hash
  mismatch, consumed, revoked, pending/rejected, plan mismatch; re-approve
  after plan change succeeds once and cannot be replayed (S4)
- one redact key table in @umt/contracts shared by server logger and agent
  redactForModel: rsskey, torrent_pass, authkey, downhash, c_secure_pass,
  passkey variants, x-api-key, cookie headers, nested objects/arrays (S2)
- TMDB network errors: message and cause redacted via redactUrl (S3)
- web Agent ?mock=1 only in DEV builds or when pi.provider is fake (S7)
- EndpointDef auth annotations for /auth/*, asserted against
  PUBLIC_AUTH_PATHS (S10); doc drift fixes (S11)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 22:24:44 +08:00

168 lines
6.5 KiB
TypeScript

import { Api, CSRF_COOKIE, SESSION_COOKIE } from "@umt/contracts";
import type { Context } from "hono";
import { deleteCookie, setCookie } from "hono/cookie";
import { type AuthEnv, deriveCsrfToken } 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"];
// 端点定义直接取 contracts 注册表(scope 与 auth 标注只维护一份):setup/login/session 为 auth:"public",
// password/totp 为 auth:"session"(endpoint() 据此跳过 requireScope 或追加 requireAdminSession)。
const D = {
setup: { ...Api.auth.setup, tags },
login: { ...Api.auth.login, tags },
logout: { ...Api.auth.logout, tags },
session: { ...Api.auth.session, tags },
password: { ...Api.auth.password, tags },
totpGet: { ...Api.auth.totpStatus, tags },
totpPost: { ...Api.auth.totp, tags },
} 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);
});
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) };
});
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);
});
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 };
});
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)));
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)) };
});
}