接入 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>;
}
+4 -3
View File
@@ -1,4 +1,4 @@
export type CsrfToken = { name: string; value: string };
export type CsrfToken = { name: string; value: string; headerName: string };
type PageBase = {
name: string;
@@ -10,7 +10,8 @@ type PageBase = {
export type SignInContext = PageBase & (
| { step: "password" }
| {
step: "mfa-pending";
step: "mfa-pending" | "mfa-complete";
passkey: "unavailable" | "register" | "authenticate";
identity: {
username: string;
subjectId: string;
@@ -25,7 +26,7 @@ export function readPageContext(): SignInContext {
const data = document.getElementById("login-context")?.textContent;
if (!data) throw new Error("Missing login page context");
const context: SignInContext = JSON.parse(data);
if (context.step !== "password" && context.step !== "mfa-pending") {
if (context.step !== "password" && context.step !== "mfa-pending" && context.step !== "mfa-complete") {
throw new Error("Unknown login step");
}
return context;
+10 -3
View File
@@ -1,3 +1,4 @@
import { Passkey } from "../components/Passkey";
import { SubmitForm } from "../components/SubmitForm";
import type { SignInContext } from "../page-context";
@@ -8,15 +9,19 @@ export function SignInPage({ context }: { context: SignInContext }) {
<section className="card" aria-labelledby="title">
<div className="eyebrow">AD 登录验证</div>
<h1 id="title">
{context.step === "password" ? "登录你的账号" : "密码已验证,等待 MFA"}
{context.step === "password" ? "登录你的账号" : context.step === "mfa-complete" ? "MFA 已验证" : "密码已验证,等待 MFA"}
</h1>
<p className="intro">
{context.step === "password"
? "使用 AD 用户名或完整 UPN 登录。"
: `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`}
: context.step === "mfa-complete"
? `${context.name},密码与 passkey 已验证。尚未向应用授权。`
: context.passkey === "unavailable"
? `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`
: `${context.name},请使用 passkey 完成第二因素验证。`}
</p>
{context.error && <p className="error" role="alert">{context.error}</p>}
{context.step === "mfa-pending" && (
{context.step !== "password" && (
<div className="directory-result">
<dl>
<dt>账号</dt><dd>{context.identity.username}</dd>
@@ -34,6 +39,8 @@ export function SignInPage({ context }: { context: SignInContext }) {
<p>当前仅读取 memberOf,不展开嵌套组,也不包含主组。</p>
</div>
)}
{context.step === "mfa-pending" && context.passkey !== "unavailable" &&
<Passkey csrf={context.csrf} initial={context.passkey} />}
<SubmitForm action={context.action} csrf={context.csrf}
label={context.step === "password" ? "继续" : "退出并重新验证"}>
{context.step === "password" && <>
+63
View File
@@ -0,0 +1,63 @@
import { test, expect } from "@playwright/test";
test.use({ ignoreHTTPSErrors: true });
// Dedicated localhost fixture only. Never registers a synthetic credential against real AD.
test("AD + PostgreSQL + real WebAuthn ceremony, factor gating and persisted re-login", async ({ page, context }) => {
test.skip(process.env.IAM_WEBAUTHN_FIXTURE !== "1", "Start the test-only webauthnBrowserFixture first");
const cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable");
await cdp.send("WebAuthn.addVirtualAuthenticator", { options: {
protocol: "ctap2", transport: "internal", hasResidentKey: true,
hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true,
} });
async function login(username = "alice") {
await page.goto("https://localhost:18083/signin");
await page.getByLabel("用户名", { exact: true }).fill(username);
await page.getByLabel("密码", { exact: true }).fill("fixture-password");
await page.getByRole("button", { name: "继续", exact: true }).click();
await expect(page.getByRole("heading", { name: "密码已验证,等待 MFA" })).toBeVisible();
}
await login();
await page.getByRole("button", { name: "注册 Passkey", exact: true }).click();
await expect(page.getByRole("status")).toContainText("Passkey 已保存");
await page.goto("https://localhost:18083/signin/complete");
await expect(page).toHaveURL(/^https:\/\/localhost:18083\/signin\/mfa(?:\?.*)?$/);
const enrollmentToken = await page.evaluate(() => JSON.parse(document.getElementById("login-context")!.textContent!).csrf);
const enrollAgain = await context.request.post("https://localhost:18083/webauthn/register/options", {
headers: { [enrollmentToken.headerName]: enrollmentToken.value }, maxRedirects: 0,
});
expect(enrollAgain.status()).toBe(302);
expect(enrollAgain.headers().location).toContain("factor.type=webauthn");
const beforeMfa = (await context.cookies()).find(c => c.name === "JSESSIONID")!.value;
await page.getByRole("button", { name: "验证 Passkey", exact: true }).click();
await expect(page.getByRole("heading", { name: "MFA 已验证", exact: true })).toBeVisible();
await expect(page.getByText("gitea-admins", { exact: true })).toBeVisible();
expect((await context.cookies()).find(c => c.name === "JSESSIONID")!.value).not.toBe(beforeMfa);
await page.getByRole("button", { name: "退出并重新验证", exact: true }).click();
await login();
await expect(page.getByRole("button", { name: "注册 Passkey", exact: true })).toHaveCount(0);
const assertionRequest = page.waitForRequest(request => new URL(request.url()).pathname === "/login/webauthn");
await page.getByRole("button", { name: "验证 Passkey", exact: true }).click();
const assertion = (await assertionRequest).postDataJSON();
await expect(page.getByRole("heading", { name: "MFA 已验证", exact: true })).toBeVisible();
const token = await page.evaluate(() => JSON.parse(document.getElementById("login-context")!.textContent!).csrf);
const replay = await context.request.post("https://localhost:18083/login/webauthn", {
headers: { [token.headerName]: token.value }, data: assertion,
});
expect(replay.status()).toBe(401);
await page.getByRole("button", { name: "退出并重新验证", exact: true }).click();
await login("bob");
// Bob has no credential. A discoverable Alice credential must not become Bob's second factor.
const foreignStatus = await page.evaluate(async () => {
const csrf = JSON.parse(document.getElementById("login-context")!.textContent!).csrf;
const headers = { "Content-Type": "application/json", [csrf.headerName]: csrf.value };
const options = await fetch("/webauthn/authenticate/options", { method: "POST", headers }).then(r => r.json());
const credential = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
}) as PublicKeyCredential;
return fetch("/login/webauthn", { method: "POST", headers, body: JSON.stringify(credential.toJSON()) })
.then(r => r.status);
});
expect(foreignStatus).toBe(401);
});