import { type AuthenticationResponseJSON, type AuthenticatorTransportFuture, generateAuthenticationOptions, generateRegistrationOptions, type RegistrationResponseJSON, verifyAuthenticationResponse, verifyRegistrationResponse, } from "@simplewebauthn/server"; import type { PasskeyChallenge, PasskeyInfo } from "@umt/contracts"; import { type AdminPasskeyRow, type DB, type Kysely, sql } from "@umt/db"; import { ApiException } from "../../http/errors.ts"; import type { AdminRow } from "./auth-service.ts"; // Passkey(WebAuthn,照 uhd_doc src/server/admin-auth/passkey.ts):替代密码的第一因子, // 登录成功直接建会话(不再要求 TOTP)。@simplewebauthn/server 做两个 ceremony; // 挑战存 webauthn_challenge 表(一次性、5 分钟有效、条件更新消费),可以登记多把并命名。 // RP ID 取配置 WEBAUTHN_RP_ID,不设时取请求主机名;来源取配置 WEBAUTHN_ORIGINS,不设时接受主机名属于 RP ID 的来源。 export const CHALLENGE_TTL_MS = 5 * 60_000; export interface RpInfo { rpID: string; rpName: string; /** 配置的允许来源;null = 按 RP ID 校验客户端上报的来源 */ origins: string[] | null; } /** 由配置 + 请求主机名得到 RP 信息(纯函数,便于测试) */ export function resolveRp( cfg: { WEBAUTHN_RP_ID?: string | undefined; WEBAUTHN_ORIGINS?: string | undefined; WEBAUTHN_RP_NAME?: string }, requestHost: string | null | undefined, ): RpInfo { const host = (requestHost ?? "").split(",")[0]?.trim() ?? ""; const hostname = host .replace(/:\d+$/, "") .replace(/^\[|\]$/g, "") .toLowerCase(); const rpID = cfg.WEBAUTHN_RP_ID?.trim().toLowerCase() || hostname || "localhost"; const origins = cfg.WEBAUTHN_ORIGINS ? cfg.WEBAUTHN_ORIGINS.split(",") .map((s) => s.trim().replace(/\/+$/, "")) .filter(Boolean) : null; return { rpID, rpName: cfg.WEBAUTHN_RP_NAME ?? "UHD Media Tool", origins: origins?.length ? origins : null }; } /** 客户端上报的来源(clientDataJSON.origin);没配置允许来源时,主机名必须等于 RP ID 或是它的子域 */ function expectedOrigins(rp: RpInfo, response: { response?: { clientDataJSON?: unknown } }): string[] { if (rp.origins) return rp.origins; let origin = ""; try { const raw = String(response.response?.clientDataJSON ?? ""); origin = String((JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as { origin?: unknown }).origin ?? ""); } catch { throw new ApiException("bad_request", "Passkey 响应格式不正确"); } let hostname = ""; try { hostname = new URL(origin).hostname.replace(/^\[|\]$/g, "").toLowerCase(); } catch { throw new ApiException("unauthorized", "Passkey 来源无效"); } if (hostname !== rp.rpID && !hostname.endsWith(`.${rp.rpID}`)) throw new ApiException("unauthorized", `Passkey 来源 ${origin} 与站点 ${rp.rpID} 不符`); return [origin]; } // ── 挑战 ── async function saveChallenge( db: Kysely, purpose: "register" | "login", challenge: string, adminUserId: string | null, ): Promise { // 顺手清理过期挑战 await db .deleteFrom("webauthn_challenge") .where("expires_at", "<", new Date(Date.now() - CHALLENGE_TTL_MS)) .execute(); const r = await db .insertInto("webauthn_challenge") .values({ purpose, challenge, admin_user_id: adminUserId, expires_at: new Date(Date.now() + CHALLENGE_TTL_MS) }) .returning("id") .executeTakeFirstOrThrow(); return r.id; } /** 取出并作废挑战(一次性);过期 / 用过 / 用途不符返回 null */ async function takeChallenge( db: Kysely, id: string, purpose: "register" | "login", adminUserId: string | null, ): Promise { let q = db .updateTable("webauthn_challenge") .set({ used_at: new Date() }) .where("id", "=", id) .where("purpose", "=", purpose) .where("used_at", "is", null) .where("expires_at", ">", new Date()); if (adminUserId) q = q.where("admin_user_id", "=", adminUserId); const r = await q.returning("challenge").executeTakeFirst(); return r?.challenge ?? null; } // ── 登记的 Passkey ── const transportsOf = (v: unknown): AuthenticatorTransportFuture[] => Array.isArray(v) ? (v.filter((x) => typeof x === "string") as AuthenticatorTransportFuture[]) : []; export function toPasskeyInfo(r: AdminPasskeyRow): PasskeyInfo { return { id: r.id, name: r.name, deviceType: r.device_type, backedUp: r.backed_up, createdAt: r.created_at.toISOString(), lastUsedAt: r.last_used_at?.toISOString() ?? null, }; } export async function listPasskeys(db: Kysely, adminUserId: string): Promise { return await db .selectFrom("admin_passkey") .selectAll() .where("admin_user_id", "=", adminUserId) .orderBy("created_at") .execute(); } export async function renamePasskey(db: Kysely, adminUserId: string, id: string, name: string) { const r = await db .updateTable("admin_passkey") .set({ name: name.trim() }) .where("id", "=", id) .where("admin_user_id", "=", adminUserId) .returningAll() .executeTakeFirst(); if (!r) throw new ApiException("not_found", "Passkey 不存在"); return r; } export async function deletePasskey(db: Kysely, adminUserId: string, id: string) { const r = await db .deleteFrom("admin_passkey") .where("id", "=", id) .where("admin_user_id", "=", adminUserId) .returning(["id", "name"]) .executeTakeFirst(); if (!r) throw new ApiException("not_found", "Passkey 不存在"); return r; } // ── 注册(需要控制台会话) ── export async function registrationOptions(db: Kysely, admin: AdminRow, rp: RpInfo): Promise { const existing = await listPasskeys(db, admin.id); const options = await generateRegistrationOptions({ rpName: rp.rpName, rpID: rp.rpID, userName: admin.username ?? "admin", userID: new TextEncoder().encode(admin.id), attestationType: "none", excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: transportsOf(c.transports) })), authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" }, }); const challengeId = await saveChallenge(db, "register", options.challenge, admin.id); return { challengeId, options: options as unknown as Record }; } export async function verifyRegistration( db: Kysely, admin: AdminRow, rp: RpInfo, input: { challengeId: string; name: string; response: Record }, ): Promise { const response = input.response as unknown as RegistrationResponseJSON; const origins = expectedOrigins(rp, response); const challenge = await takeChallenge(db, input.challengeId, "register", admin.id); if (!challenge) throw new ApiException("precondition_failed", "注册挑战已过期或已用过,重新开始"); let v: Awaited>; try { v = await verifyRegistrationResponse({ response, expectedChallenge: challenge, expectedOrigin: origins, expectedRPID: rp.rpID, requireUserVerification: false, }); } catch (err) { throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`); } if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败"); const { credential, credentialDeviceType, credentialBackedUp } = v.registrationInfo; try { return await db .insertInto("admin_passkey") .values({ admin_user_id: admin.id, credential_id: credential.id, public_key: Buffer.from(credential.publicKey), counter: credential.counter, transports: JSON.stringify(credential.transports ?? response.response?.transports ?? []), device_type: credentialDeviceType, backed_up: credentialBackedUp, name: input.name.trim(), }) .returningAll() .executeTakeFirstOrThrow(); } catch (err) { if ((err as { code?: string }).code === "23505") throw new ApiException("conflict", "这把 Passkey 已经登记过"); throw err; } } // ── 登录(免鉴权) ── export async function authenticationOptions(db: Kysely, admin: AdminRow, rp: RpInfo): Promise { const creds = await listPasskeys(db, admin.id); if (!creds.length) throw new ApiException("precondition_failed", "还没有登记 Passkey,用密码登录"); const options = await generateAuthenticationOptions({ rpID: rp.rpID, userVerification: "preferred", allowCredentials: creds.map((c) => ({ id: c.credential_id, transports: transportsOf(c.transports) })), }); const challengeId = await saveChallenge(db, "login", options.challenge, null); return { challengeId, options: options as unknown as Record }; } /** 校验断言;成功后更新签名计数器与最近使用时间。失败抛 unauthorized / precondition_failed */ export async function verifyAuthentication( db: Kysely, admin: AdminRow, rp: RpInfo, input: { challengeId: string; response: Record }, ): Promise { const response = input.response as unknown as AuthenticationResponseJSON; const origins = expectedOrigins(rp, response); const challenge = await takeChallenge(db, input.challengeId, "login", null); if (!challenge) throw new ApiException("precondition_failed", "登录挑战已过期或已用过,重试"); const cred = await db .selectFrom("admin_passkey") .selectAll() .where("admin_user_id", "=", admin.id) .where("credential_id", "=", String(response.id ?? "")) .executeTakeFirst(); if (!cred) throw new ApiException("unauthorized", "这把 Passkey 没有登记"); let v: Awaited>; try { v = await verifyAuthenticationResponse({ response, expectedChallenge: challenge, expectedOrigin: origins, expectedRPID: rp.rpID, credential: { id: cred.credential_id, publicKey: new Uint8Array(cred.public_key), counter: Number(cred.counter), transports: transportsOf(cred.transports), }, requireUserVerification: false, }); } catch (err) { throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`); } if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败"); // 计数器只增不减(条件更新:并发重放同一个断言只有一个生效;计数器恒为 0 的认证器照常放行) const next = v.authenticationInfo.newCounter; const u = await db .updateTable("admin_passkey") .set({ counter: next, last_used_at: new Date(), device_type: v.authenticationInfo.credentialDeviceType, backed_up: v.authenticationInfo.credentialBackedUp, }) .where("id", "=", cred.id) .where(sql`(counter < ${next} or (counter = 0 and ${next} = 0))`) .returningAll() .executeTakeFirst(); if (!u) throw new ApiException("unauthorized", "Passkey 签名计数器回退(可能被克隆),已拒绝"); return u; }