接入 WebAuthn 第二因素与 PostgreSQL 凭据仓储

This commit is contained in:
2026-09-27 18:29:52 +00:00
parent 6d040658c8
commit 45994e3919
25 changed files with 874 additions and 23 deletions
+63
View File
@@ -0,0 +1,63 @@
import { useState } from "react";
import type { CsrfToken } from "../page-context";
export function Passkey({ csrf, initial }: { csrf: CsrfToken; initial: "register" | "authenticate" }) {
const [step, setStep] = useState(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [registered, setRegistered] = useState(false);
async function post(path: string, body?: unknown) {
const response = await fetch(path, {
method: "POST", credentials: "same-origin", redirect: "error",
headers: { "Content-Type": "application/json", [csrf.headerName]: csrf.value },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) {
throw new Error("验证未完成,请重试;若登录已过期,请退出并重新验证密码。");
}
return response.json();
}
async function perform() {
setBusy(true);
setError("");
try {
if (!PublicKeyCredential.parseCreationOptionsFromJSON || !PublicKeyCredential.parseRequestOptionsFromJSON) {
throw new Error("请使用支持 passkey 的新版浏览器。");
}
if (step === "register") {
const options = await post("/webauthn/register/options");
const credential = await navigator.credentials.create({
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
}) as PublicKeyCredential | null;
if (!credential) throw new Error("注册已取消。");
await post("/webauthn/register", { publicKey: { credential: credential.toJSON(), label: "Passkey" } });
setRegistered(true);
setStep("authenticate");
} else {
const options = await post("/webauthn/authenticate/options");
const credential = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
}) as PublicKeyCredential | null;
if (!credential) throw new Error("验证已取消。");
await post("/login/webauthn", credential.toJSON());
window.location.assign("/signin/complete");
}
} catch (cause) {
setError(cause instanceof DOMException ? "操作已取消或认证器不可用,可以重试。"
: cause instanceof Error ? cause.message : "验证未完成,请重试。");
} finally {
setBusy(false);
}
}
return <div className="passkey">
{registered && <p role="status">Passkey 已保存,请验证一次以完成第二因素。</p>}
{step === "register" && <p>首次使用,请注册 passkey。后续登录仍需 AD 密码和 passkey。</p>}
{error && <p className="error" role="alert">{error}</p>}
<button type="button" disabled={busy} onClick={perform}>
{busy ? "等待认证器…" : step === "register" ? "注册 Passkey" : "验证 Passkey"}
</button>
</div>;
}