diff --git a/README.md b/README.md
index a278a2c..2d277b3 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# iam-login
独立 IAM 的登录与认证服务,以 Java、Spring Security 和 GraalVM Native 实现,作为
-Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及等待 MFA 的浏览器页面;MFA 与 Hydra 登录链路仍待实现。
+Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及 WebAuthn 第二因素浏览器流程;Hydra 登录链路仍待实现。
## 职责与边界
@@ -68,7 +68,7 @@ JVM、AOT、Native 测试及原生应用 HTTP 检查已通过,实测范围与
[本地验证结果](docs/bootstrap.md#2026-09-25-本地验证结果)。
新增 AD 路径本轮按维护者要求只进行 JVM 验证,不沿用基线的 Native 验收结论。
重构前的真实目录密码与属性/直接所属组读取已通过维护者浏览器验收;Spring Data LDAP
-版本已通过 JVM 和浏览器回归,真实人类复验待反馈。MFA 与 Hydra 链路仍待实现。
+版本已通过 JVM 和浏览器回归,真实人类复验待反馈。WebAuthn 实现与验证边界见 [第二因素](docs/webauthn.md),Hydra 链路仍待实现。
## 领域与代码组织
@@ -87,6 +87,7 @@ configuration → 装配上述实现
- `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。
使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。
- `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。
+- `authentication/infrastructure/webauthn`:凭据归属与注册策略、challenge 仓储扩展;密码学校验和 JDBC 存储交给 Spring Security。
- `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。
- `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。
- `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。
diff --git a/build.gradle b/build.gradle
index 2eace16..030a6d7 100644
--- a/build.gradle
+++ b/build.gradle
@@ -21,6 +21,10 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+ implementation 'org.springframework.boot:spring-boot-starter-flyway'
+ runtimeOnly 'org.flywaydb:flyway-database-postgresql'
+ runtimeOnly 'org.postgresql:postgresql'
implementation 'org.springframework.boot:spring-boot-starter-data-ldap'
implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'
implementation 'org.springframework.boot:spring-boot-starter-security'
@@ -42,6 +46,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'com.unboundid:unboundid-ldapsdk'
testImplementation 'org.testcontainers:testcontainers-grafana'
+ testImplementation 'org.testcontainers:testcontainers-postgresql'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
@@ -83,3 +88,10 @@ tasks.named('processResources') {
}
from('frontend/dist') { into 'ui' }
}
+
+// Explicit test-only entry point for browser WebAuthn ceremonies; no fixture endpoints in production.
+tasks.register('webauthnBrowserFixture', JavaExec) {
+ dependsOn tasks.named('testClasses')
+ classpath = sourceSets.test.runtimeClasspath
+ mainClass = 'top.ddupan.iam.login.WebAuthnBrowserFixture'
+}
diff --git a/compose.dev.yaml b/compose.dev.yaml
new file mode 100644
index 0000000..368ee69
--- /dev/null
+++ b/compose.dev.yaml
@@ -0,0 +1,21 @@
+# Only the development credential database. Supply IAM_DEV_DB_PASSWORD outside Git.
+services:
+ postgres:
+ image: postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
+ environment:
+ POSTGRES_DB: iam_login
+ POSTGRES_USER: iam_login
+ POSTGRES_PASSWORD: ${IAM_DEV_DB_PASSWORD:?Set IAM_DEV_DB_PASSWORD outside Git}
+ ports:
+ - "127.0.0.1:${IAM_DEV_DB_PORT:-15432}:5432"
+ volumes:
+ - postgres:/var/lib/postgresql
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U iam_login -d iam_login"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ cpus: 1
+ mem_limit: 512m
+volumes:
+ postgres:
diff --git a/docs/ad-login.md b/docs/ad-login.md
index 631cfe0..08237bd 100644
--- a/docs/ad-login.md
+++ b/docs/ad-login.md
@@ -10,8 +10,9 @@ try-with-resources 管理已认证用户仓储会话;基础设施的 `AdUserRe
`AdUserEntry` 的 LDAP 注解不会进入领域对象。
成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。
-**这是密码因素验收页面,MFA 尚未接入,不是完整登录成功。** Spring Security 保存
-仅含 `FACTOR_PASSWORD` 的认证结果;其余应用请求使用 `denyAll`,不调用 Hydra,
+**密码成功本身不是完整登录成功。** Spring Security 先保存仅含 `FACTOR_PASSWORD`
+的认证结果;启用 [WebAuthn](webauthn.md) 后在此继续第二因素,否则停留在等待页面。
+未实现的应用请求使用 `denyAll`,不调用 Hydra,
不替换现役 Go/Authelia/Gitea 登录链路。
## 目录和组语义
diff --git a/docs/native-validation.md b/docs/native-validation.md
index f65a049..14c1f94 100644
--- a/docs/native-validation.md
+++ b/docs/native-validation.md
@@ -29,6 +29,8 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需
- 登录 Controller 与安全链使用 `@ConditionalOnProperty(iam.ad.enabled)`;AOT 在构建时
决定 bean 是否存在。AD Native 产物必须在 AOT 阶段启用此属性,验证实际入口与兜底链,
不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。
+- WebAuthn 新增的 JDBC/Flyway/PostgreSQL、WebAuthn4J 校验与 JSON 路径本轮仅做 JVM 验证;
+ Native AOT 时还需启用 `iam.webauthn.enabled`,不得套用基础骨架的 Native 结论。
- 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。
- LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。
- 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后
diff --git a/docs/webauthn.md b/docs/webauthn.md
new file mode 100644
index 0000000..eedbd37
--- /dev/null
+++ b/docs/webauthn.md
@@ -0,0 +1,70 @@
+# WebAuthn 第二因素
+
+WebAuthn 使用 Spring Security 官方过滤器、WebAuthn4J 校验和 JDBC 仓储。
+PostgreSQL 保存 user handle、凭据公钥与签名计数等记录,不保存用户密码或认证器私钥。
+AD 仍为用户与组权威;凭据按目录 authority + objectGUID 关联,用户名改名不会换主体。
+
+## 登录与注册
+
+1. `/signin` 使用原生表单 POST 验证 AD 密码,建立 `FACTOR_PASSWORD`。
+2. `/signin/mfa`:没有凭据时注册 passkey;已有凭据时验证 passkey。
+3. 注册只保存凭据,必须再次实际验证,才取得 `FACTOR_WEBAUTHN`。
+4. `/signin/complete` 要求两种因素均在 10 分钟内有效;目前仅显示验证结果,尚不接受 Hydra challenge。
+
+首次注册信任近期 AD 密码验证。已有凭据后的新增注册同时要求密码与 WebAuthn 因素;
+本轮 UI 只提供首次注册与验证,不提供新增管理、删除或自助恢复入口。遗失所有 passkey
+尚无自助登录途径;生产上线前需要另行确定恢复和初始注册政策,不能把数据库清空作为日常恢复方式。
+注册和验证均要求认证器 user verification(例如 PIN 或生物识别)。
+
+Spring Security 负责因素合并、会话轮换、退出和 CSRF。应用仅补目录主体与凭据所有权
+限制、首次注册并发检查,以及 challenge 的 5 分钟服务端有效期和单次消费。
+没有应用自建登录状态机或认证 Filter。上游 options Filter 位于授权 Filter 前,因而
+这些检查在 RelyingPartyOperations 扩展点执行,不能仅靠 URL 授权规则。
+
+## 本地数据库
+
+```sh
+# 密码从 shell 或外部权限为 0600 的 env 文件提供;不要写入版本库。
+docker compose -p iam-login-dev -f compose.dev.yaml up -d
+```
+
+必须设置 `IAM_DEV_DB_PASSWORD`。PostgreSQL 仅发布在 `127.0.0.1:15432`,可用
+`IAM_DEV_DB_PORT` 调整端口;数据保存在 Compose named volume 中。
+普通 `down` 不删数据,**不要使用 `down -v`**,否则会删除已注册凭据。
+
+应用额外配置:
+
+```yaml
+iam:
+ webauthn:
+ enabled: true
+ rp-id: laptop.tail7e769.ts.net
+ origin: https://laptop.tail7e769.ts.net:18082
+spring:
+ datasource:
+ url: jdbc:postgresql://127.0.0.1:15432/iam_login
+ username: iam_login
+ password: ${IAM_DEV_DB_PASSWORD}
+```
+
+同时启用并配置 [AD](ad-login.md)。RP ID 不含协议与端口,origin 必须与浏览器实际
+HTTPS 入口完全一致;改变 RP 域名后旧凭据不能直接在新域名使用。
+凭据 schema 由 Flyway 管理,采用 Spring Security 7.1.1 官方 PostgreSQL 表结构,
+增加主体名称唯一约束和凭据所有者索引。未启用 WebAuthn 时不创建 DataSource,AD-only
+路径不需要数据库。数据库不可用时 MFA 失败,不降级为仅密码通过。
+
+## 验证
+
+JVM 回归使用 Testcontainers PostgreSQL 与模拟 AD;不会向真实 AD 提交测试密码。
+浏览器用 Chromium 虚拟认证器产生真实注册/断言签名,再交给后端校验:
+
+```sh
+scripts/gradle-in-docker test
+scripts/gradle-in-docker webauthnBrowserFixture
+# 另一终端;测试夹具固定监听 localhost:18083,使用测试证书。
+IAM_WEBAUTHN_FIXTURE=1 npm --prefix frontend run test:browser -- webauthn.spec.ts
+```
+
+测试专用启动类仅在 test classpath,不进入生产 JAR,也不提供生产调试 API。
+真实用户的 passkey 注册、认证器兼容性和 Native 路径仍需独立验收;JVM/虚拟认证器通过
+不能代替真实人类或 Native 验收。生产共享 PostgreSQL 的接入留在部署阶段。
diff --git a/frontend/src/components/Passkey.tsx b/frontend/src/components/Passkey.tsx
new file mode 100644
index 0000000..65e1809
--- /dev/null
+++ b/frontend/src/components/Passkey.tsx
@@ -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
+ {registered &&
Passkey 已保存,请验证一次以完成第二因素。
}
+ {step === "register" &&
首次使用,请注册 passkey。后续登录仍需 AD 密码和 passkey。
}
+ {error &&
{error}
}
+
+
;
+}
diff --git a/frontend/src/page-context.ts b/frontend/src/page-context.ts
index 70a0614..d605524 100644
--- a/frontend/src/page-context.ts
+++ b/frontend/src/page-context.ts
@@ -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;
diff --git a/frontend/src/pages/SignInPage.tsx b/frontend/src/pages/SignInPage.tsx
index f565cdd..c508bd1 100644
--- a/frontend/src/pages/SignInPage.tsx
+++ b/frontend/src/pages/SignInPage.tsx
@@ -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 }) {
AD 登录验证
- {context.step === "password" ? "登录你的账号" : "密码已验证,等待 MFA"}
+ {context.step === "password" ? "登录你的账号" : context.step === "mfa-complete" ? "MFA 已验证" : "密码已验证,等待 MFA"}
{context.step === "password"
? "使用 AD 用户名或完整 UPN 登录。"
- : `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`}
+ : context.step === "mfa-complete"
+ ? `${context.name},密码与 passkey 已验证。尚未向应用授权。`
+ : context.passkey === "unavailable"
+ ? `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`
+ : `${context.name},请使用 passkey 完成第二因素验证。`}
{context.error && {context.error}
}
- {context.step === "mfa-pending" && (
+ {context.step !== "password" && (
- 账号
- {context.identity.username}
@@ -34,6 +39,8 @@ export function SignInPage({ context }: { context: SignInContext }) {
当前仅读取 memberOf,不展开嵌套组,也不包含主组。
)}
+ {context.step === "mfa-pending" && context.passkey !== "unavailable" &&
+ }
{context.step === "password" && <>
diff --git a/frontend/tests/webauthn.spec.ts b/frontend/tests/webauthn.spec.ts
new file mode 100644
index 0000000..d038b39
--- /dev/null
+++ b/frontend/tests/webauthn.spec.ts
@@ -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);
+});
diff --git a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/security/DirectoryPrincipal.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/security/DirectoryPrincipal.java
index 01e1ee4..276f088 100644
--- a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/security/DirectoryPrincipal.java
+++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/security/DirectoryPrincipal.java
@@ -1,10 +1,16 @@
package top.ddupan.iam.login.authentication.infrastructure.security;
import org.springframework.security.core.AuthenticatedPrincipal;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
import top.ddupan.iam.login.authentication.domain.User;
/** An immutable directory snapshot; never contains credentials or connections. */
-public record DirectoryPrincipal(User user) implements AuthenticatedPrincipal {
+public record DirectoryPrincipal(User user, Bytes credentialUserId)
+ implements AuthenticatedPrincipal, PublicKeyCredentialUserEntity {
+ public DirectoryPrincipal(User user) { this(user, null); }
+ @Override public Bytes getId() { return credentialUserId; }
+ @Override public String getDisplayName() { return user.displayName(); }
@Override
public String getName() {
return user.id().authority() + ":" + user.id().value();
diff --git a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/DirectoryRelyingPartyOperations.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/DirectoryRelyingPartyOperations.java
new file mode 100644
index 0000000..3b45466
--- /dev/null
+++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/DirectoryRelyingPartyOperations.java
@@ -0,0 +1,71 @@
+package top.ddupan.iam.login.authentication.infrastructure.webauthn;
+
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.webauthn.api.*;
+import org.springframework.security.web.webauthn.management.*;
+import org.springframework.transaction.support.TransactionTemplate;
+import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
+
+/** Adds directory ownership/enrollment policy; verification and storage remain upstream implementations. */
+public final class DirectoryRelyingPartyOperations implements WebAuthnRelyingPartyOperations {
+ private final WebAuthnRelyingPartyOperations delegate;
+ private final MfaPolicy policy;
+ private final PublicKeyCredentialUserEntityRepository users;
+ private final UserCredentialRepository credentials;
+ private final JdbcOperations jdbc;
+ private final TransactionTemplate transactions;
+
+ public DirectoryRelyingPartyOperations(WebAuthnRelyingPartyOperations delegate, MfaPolicy policy,
+ PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials,
+ JdbcOperations jdbc, TransactionTemplate transactions) {
+ this.delegate = delegate;
+ this.policy = policy;
+ this.users = users;
+ this.credentials = credentials;
+ this.jdbc = jdbc;
+ this.transactions = transactions;
+ }
+
+ @Override
+ public PublicKeyCredentialCreationOptions createPublicKeyCredentialCreationOptions(
+ PublicKeyCredentialCreationOptionsRequest request) {
+ policy.current();
+ policy.requireEnrollment(request.getAuthentication());
+ return delegate.createPublicKeyCredentialCreationOptions(request);
+ }
+
+ @Override
+ public CredentialRecord registerCredential(RelyingPartyRegistrationRequest request) {
+ var principal = policy.current();
+ var owner = request.getCreationOptions().getUser();
+ if (!principal.getName().equals(owner.getName())) throw new AccessDeniedException("Credential owner mismatch");
+ return transactions.execute(status -> {
+ // Serialize first enrollment across sessions/processes, then recheck existing factors.
+ jdbc.queryForObject("select id from user_entities where id = ? for update", String.class,
+ owner.getId().toBase64UrlString());
+ policy.requireEnrollment(SecurityContextHolder.getContext().getAuthentication());
+ return delegate.registerCredential(request);
+ });
+ }
+
+ @Override
+ public PublicKeyCredentialRequestOptions createCredentialRequestOptions(PublicKeyCredentialRequestOptionsRequest request) {
+ policy.current();
+ return delegate.createCredentialRequestOptions(request);
+ }
+
+ @Override
+ public PublicKeyCredentialUserEntity authenticate(RelyingPartyAuthenticationRequest request) {
+ var principal = policy.current();
+ var owner = users.findByUsername(principal.getName());
+ var credential = credentials.findByCredentialId(request.getPublicKey().getRawId());
+ if (owner == null || credential == null || !owner.getId().equals(credential.getUserEntityUserId())) {
+ throw new AccessDeniedException("Credential owner mismatch");
+ }
+ var verified = delegate.authenticate(request);
+ if (!verified.getName().equals(principal.getName())) throw new AccessDeniedException("Credential owner mismatch");
+ return new DirectoryPrincipal(principal.user(), verified.getId());
+ }
+}
diff --git a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/MfaPolicy.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/MfaPolicy.java
new file mode 100644
index 0000000..b233dcf
--- /dev/null
+++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/MfaPolicy.java
@@ -0,0 +1,46 @@
+package top.ddupan.iam.login.authentication.infrastructure.webauthn;
+
+import java.time.Duration;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authorization.AuthorizationManager;
+import org.springframework.security.authorization.AuthorizationManagerFactories;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
+import org.springframework.security.web.webauthn.management.UserCredentialRepository;
+import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
+
+/** Enrollment policy; factor completion and freshness are evaluated by Spring Security. */
+public final class MfaPolicy {
+ private final PublicKeyCredentialUserEntityRepository users;
+ private final UserCredentialRepository credentials;
+ public final AuthorizationManager