diff --git a/README.md b/README.md
index a278a2c..aac32b7 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 Login/Consent 的隔离接入,生产切换仍待验收。
## 职责与边界
@@ -53,6 +53,7 @@ npm --prefix frontend run build
./gradlew bootRun
```
+应用启动需要配置 PostgreSQL 数据源,连接与开发容器说明见[第二因素](docs/webauthn.md)。
测试需要可用的 Docker,生成器配置了 Grafana LGTM Testcontainer。
测试覆盖 AD 第一因素、浏览器流程和监控集成。人类登录统一从 `/signin` 进入。
使用 GraalVM 25 验证原生测试与编译:
@@ -68,11 +69,11 @@ 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 接入方式与生产主体映射边界见 [Login/Consent](docs/hydra-login.md)。
## 领域与代码组织
-当前限界上下文为 `authentication`,使用 DDD 分层,依赖向领域内部收敛:
+当前限界上下文为 `authentication`、`authorization` 与 `clients`,使用 DDD 分层,依赖向领域内部收敛:
```text
interfaces/web → infrastructure/security(principal)+ domain
@@ -87,8 +88,12 @@ configuration → 装配上述实现
- `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。
使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。
- `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。
+- `authentication/infrastructure/persistence`:封装注册并发锁及事务,不把表结构带入 Security 策略。
+- `authentication/infrastructure/webauthn`:凭据归属与注册策略、challenge 仓储扩展;密码学校验和 JDBC 存储交给 Spring Security。
- `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。
- `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。
+- `authorization`:领域请求、应用授权用例与策略、Hydra Admin 基础设施、浏览器请求绑定分层维护;客户端注册与签发仍归 Hydra。
+- `clients/domain/ClientRepository`:客户端仓储契约,由 Hydra HTTP 实现;受 MFA 与直接管理组保护的客户端 CRUD;持久化与密钥由 Hydra 持有,见[管理接口](docs/client-management.md)。
- `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。
测试覆盖应用用例、AD 仓储和完整 Spring Security 过滤器链,LDAP 夹具集中在测试 `support` 包。
diff --git a/build.gradle b/build.gradle
index 2eace16..d6cde2d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -21,11 +21,16 @@ 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'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
+ implementation 'org.springframework.boot:spring-boot-starter-restclient'
implementation 'org.springframework.security:spring-security-webauthn'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
@@ -42,6 +47,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 +89,18 @@ 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'
+}
+
+// Adds a loopback-only Hydra issuer and fixture client to the same test-only browser application.
+tasks.register('hydraBrowserFixture', JavaExec) {
+ dependsOn tasks.named('testClasses')
+ classpath = sourceSets.test.runtimeClasspath
+ mainClass = 'top.ddupan.iam.login.WebAuthnBrowserFixture'
+ systemProperty 'iam.fixture.hydra', 'true'
+}
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/client-management.md b/docs/client-management.md
new file mode 100644
index 0000000..ef17af1
--- /dev/null
+++ b/docs/client-management.md
@@ -0,0 +1,57 @@
+# OAuth2/OIDC 客户端管理
+
+本服务通过受限 API 管理 Hydra 中的第一方 confidential authorization-code 客户端。
+领域层定义 `ClientRepository`,基础设施的 `HydraClientRepository` 实现远端持久化。
+API 没有管理 UI,沿用浏览器 Spring session;调用者必须完成有效的密码 + WebAuthn MFA,
+且直接属于配置的管理组。默认组列表为空,拒绝全部管理操作。
+
+```yaml
+iam:
+ clients:
+ admin-group-dns:
+ - CN=IAM Administrators,CN=Users,DC=example,DC=test
+```
+
+按完整 DN 匹配,保持与 AD 仓储一致的直接组语义,不展开嵌套组。可配置一个统一粗粒度
+管理组,不要求每个应用建立独立 admins 组。组成员来自本次目录登录快照,变更后需重新认证。
+
+| 方法与路径 | 行为 |
+| --- | --- |
+| GET `/api/iam/session` | 获取当前 session 的 CSRF headerName/token |
+| GET `/api/iam/clients?page=0&size=20` | 返回 Hydra 分页内支持的客户端,size 1–100 |
+| POST `/api/iam/clients` | 创建,201 返回 `{client, secret}`,密钥仅此次返回 |
+| GET `/api/iam/clients/{id}` | 查询,不返回密钥 |
+| PUT `/api/iam/clients/{id}` | 替换可管理字段,保留现有密钥 |
+| DELETE `/api/iam/clients/{id}` | 删除,204 |
+
+写操作必须携带当前 session cookie 与 GET session 返回的 CSRF 请求头。匿名返回 401,
+因素不足、缺少管理组或 CSRF 不符返回 403。CSRF 默认由 Spring Security 处理,没有绕过路径。
+成功变更记录 action、client ID 和操作者稳定目录标识,不记录密钥。没有 bearer 管理接口。
+
+POST/PUT 请求示例(PUT 的 id 必须等于路径):
+
+```json
+{
+ "id": "example-app",
+ "name": "示例应用",
+ "redirectUris": ["https://app.example.test/oidc/callback"],
+ "scopes": ["openid", "profile", "email", "groups"],
+ "postLogoutRedirectUris": ["https://app.example.test/logged-out"],
+ "backchannelLogoutUri": "https://app.example.test/oidc/backchannel-logout",
+ "frontchannelLogoutUri": "",
+ "loginEnabled": true
+}
+```
+
+回调必须 HTTPS(隔离开发允许 loopback HTTP),不允许通配符、fragment 或 URL 用户信息。
+本轮固定 `authorization_code`、`code`、`client_secret_basic`、public subject;scope 限于上述
+四项且要求 openid。不接收任意 Hydra 字段、密钥、签名配置或授权类型,也没有通用 Admin API
+代理。不要为并不支持注销协议的应用登记虚构的端点。
+
+客户端与生成的 secret 由 Hydra 持久化;本服务不复制或缓存它们。管理员应在创建时安全保存
+secret。暂不提供密钥轮换接口。Hydra 版本固定,更新时省略 secret 以保留原值;集成测试验证
+创建、重启后读取、更新后原密钥仍能认证、删除。测试 PostgreSQL 完全独立于真实开发 MFA 库。
+
+列表按 Hydra 原始分页过滤不支持的授权类型,空页不表示之后必无客户端;本接口不是已有
+全部 Hydra 客户端类型的迁移工具。禁用写入 Hydra metadata,后续授权即时重读并拒绝,
+已签发 token 的生命周期另由 issuer 和应用控制。
diff --git a/docs/hydra-login.md b/docs/hydra-login.md
new file mode 100644
index 0000000..7f26a1b
--- /dev/null
+++ b/docs/hydra-login.md
@@ -0,0 +1,137 @@
+# Hydra Login/Consent 接入
+
+本实现使用 Hydra 的私有 Admin API,沿用 Spring Security 的 AD + WebAuthn 双因素认证。
+OAuth2/OIDC 签发、客户端注册表和客户端密钥由 Hydra 持有。应用只负责身份、授权确认与
+Login/Consent 接受,不自行签发 token,也不实现另一套认证状态机。
+
+## 浏览器路径
+
+1. 客户端发起 Hydra authorization code 请求;Hydra 将浏览器带到 `/oauth2/start?login_challenge=...`。
+2. 服务端通过 Admin API 核对 client、scope、audience 与 issuer 请求地址,绑定浏览器会话,
+ 再进入 `/oauth2/login`。该页面由 Spring Security 要求两种因素,未满足时转到密码或 MFA 页。
+3. MFA 完成后显示应用和申请范围;用户通过带 CSRF 的原生表单 POST 确认。
+4. 服务端重新核对 Hydra 请求,使用显式 subject 绑定接受 login,浏览器返回 Hydra。
+5. Hydra 回到 `/oauth2/consent`;服务端核对会话随机值、原始 challenge 摘要、主体、client、
+ 原始请求 URL 和 scope 后一次性接受 consent;Hydra 将授权码交给客户端。
+
+只为管理员启用的第一方 client 接受 openid/profile/email/groups,不授予 access-token audience、
+不申请 offline_access/refresh token。Claims 按请求 scope 释放,组保持直接 AD memberOf 的名称;
+AD mail 没有邮箱所有权验证依据,`email_verified=false`。
+
+单个浏览器会话只保存一个未完成请求,10 分钟失效,新请求替换旧请求。认证因素与会话
+依然由 Spring Security 管理;`HydraBrowserRequests` 只保存待接受的 issuer 请求及回程关联,
+不记录密码、私钥或 token。退出/重启后未完成请求需从应用重新发起。
+
+Hydra v26 的 consent `login_challenge` 是内部标识,不能与浏览器收到的 opaque challenge
+逐字比较。本服务在受信任的 login accept `context` 中写入原始 challenge 的 SHA-256 摘要
+与会话随机值,在 consent 读回并核对;不解析 Hydra 内部格式。
+
+确认页的 CSP 仅增加当前 Hydra 与已校验回调的 origin,允许原生表单返回 issuer 后跳转;
+身份页允许向固定 Hydra origin 完成注销跳转。Hydra 返回的 redirect 必须是已配置公共 origin 下的
+`/oauth2/auth`,Admin HTTP client 不跟随重定向,不向浏览器传播上游错误正文。
+
+本轮仅覆盖交互式授权码流,不支持静默 `prompt=none`。
+接受 login 时保留 Hydra 登录会话,供统一注销关联应用;会话寿命沿用 Hydra 配置,
+不延长已有会话。Hydra 的 `skip` 仅表示 issuer 记得登录,不能绕过 Spring 的有效双因素、
+原生表单确认与主体匹配。`prompt=login/consent/select_account` 或 `max_age` 存在时重新验证身份,
+不把之前的 MFA 时间改写为新登录时间。不请求上述参数时可复用仍有效的本地双因素会话。
+
+## 配置与主体连续性
+
+先启用 [AD](ad-login.md) 与 [WebAuthn](webauthn.md),再提供以下配置:
+
+```yaml
+iam:
+ hydra:
+ enabled: true
+ admin-url: http://hydra-admin.hydra.svc.cluster.local:4445
+ public-url: https://hydra.ad.ddupan.top
+ http:
+ connect-timeout: 3s
+ read-timeout: 5s
+ clients: [gitea] # 仅供尚未写入管理标记的旧客户端过渡
+ subjects:
+ - authority: ad.example.test
+ directory-id: 00112233-4455-6677-8899-aabbccddeeff
+ subject: human:EXISTING_REVIEWED_SUBJECT
+```
+
+以上主体是格式示例,不能用于真实账号。配置中的绑定按 AD authority + objectGUID 匹配,
+必须唯一;未绑定用户拒绝授权。不使用用户名、邮箱、自动创建 Gitea 账号或 AD GUID 的新哈希
+来替代现有主体。现役 Go 适配器使用 `human:` + SHA-256(Authelia issuer + NUL + sub),
+上线前需要取得并核对该旧主体,建立到 AD 稳定键的显式映射,确认 Gitea 的外部账号关联。
+普通改名不改变绑定;删除或修改绑定属于迁移操作,需要单独审查。
+
+Hydra 环境配置需将 login URL 指向 `/oauth2/start`,consent URL 指向 `/oauth2/consent`,
+logout URL 指向 `/oauth2/logout`,默认 post-logout URL 指向本服务 `/signin`。
+本仓库的实现与测试不等于这些生产设置已变更。Admin URL 可以使用现役受 NetworkPolicy
+约束的集群内 HTTP,也可通过 loopback port-forward;不能公开管理端口。
+HTTP client 在配置层使用 Boot 提供的 `RestClient.Builder` 装配并注入,保留框架定制与观测能力。
+上述连接/读取超时有默认值且必须为正,可按部署环境覆盖;禁止跟随重定向不提供关闭开关。
+Login、Consent 和 Logout 共用 `HydraGateway` 与 Admin client。
+公共 origin 必须 HTTPS,HTTP 只接受隔离测试的 loopback。客户端请求必须显式带 redirect_uri。
+
+## 客户端注册与管理边界
+
+`clients` 领域模块提供受 Spring Security 保护的 CRUD,调用私有 Hydra Admin API。
+Hydra PostgreSQL 是客户端与密钥的唯一持久化来源,不读其内部表、不建第二份注册表。
+使用方式和边界见 [客户端管理接口](client-management.md)。
+
+每次 login/consent 都重新读取 client 的 `metadata.iam_login_enabled`;显式 false 拒绝新授权。
+仅当标记不存在时使用旧 `iam.hydra.clients` allowlist。通过 API 新增启用的客户端不需要修改
+服务配置。禁用不能撤回已签发 token 或已建立的应用会话。公共动态注册入口不在本轮范围,
+不能让未信任调用者写入该管理标记。
+
+## 统一注销
+
+RP 使用 Hydra discovery 的 `end_session_endpoint`,携带 ID token hint 与已登记回调。
+Hydra 查询其登录会话后回到 `/oauth2/logout`;用户提交有 CSRF 与浏览器请求绑定的确认表单,
+服务端重新核对 challenge、主体和登记回调,接受 Hydra logout,并通过 Spring 的
+`SecurityContextLogoutHandler` 清除当前本地会话。Hydra 负责通知登记的 front/back-channel
+端点并返回应用;不自行遍历客户端发 HTTP 请求。身份页也提供原生表单发起统一退出。
+
+Hydra 会话不存在或已过期时可能直接返回应用,不经过确认页;这不证明 Spring 会话也被清除。
+本地退出按钮仍能清除当前 Spring 会话。下游必须实现登记的注销协议,通知失败也不能宣称
+应用已退出。统一注销不等于撤销所有已签发 token,不覆盖其他浏览器设备。
+
+## 正式域名规划
+
+正式入口目标为 `https://auth.ddupan.top`,替换现有 Authelia 入口,Hydra 与本服务按路径同源:
+
+- Hydra:discovery/JWKS、`/oauth2/auth`、`/oauth2/token`、`/oauth2/revoke`、
+ `/oauth2/sessions/logout`、`/userinfo` 等经核对的 public 端点。
+- iam-login:`/signin`、`/signin/**`、`/webauthn/**`、`/login/webauthn`、
+ `/oauth2/start`、`/oauth2/login`、`/oauth2/consent`、`/oauth2/logout`、`/api/iam/**` 和静态资源。
+- 不把整个 `/oauth2/**` 路径交给 Hydra;不发布 Hydra `/admin/**` 或管理端口。
+
+这是部署计划,不是现网配置。上线前需核对 cookie 名称、受信任代理与 TLS 转发、issuer
+变更和应用配置、旧 sub 关联,以及新 WebAuthn RP ID 的凭据注册。不能仅改 DNS 后假定现有
+passkey 和 OIDC 会话仍可复用;回退路径也需在基础设施 PR 中明确。
+
+## 隔离验证与生产切换
+
+首轮验收以现有 AD + MFA → Hydra → Gitea 原账号及仓库权限为目标,不以完整 self-service、
+目录管理或自动恢复为前置条件。AD 管理与人工 MFA 恢复边界见 [第二因素](webauthn.md)。
+
+```sh
+scripts/gradle-in-docker test bootJar
+scripts/gradle-in-docker hydraBrowserFixture
+# 另一终端,仅连接固定的 loopback 测试夹具:
+IAM_HYDRA_FIXTURE=1 npm --prefix frontend run test:browser -- hydra.spec.ts
+```
+
+夹具包含模拟 AD、临时 PostgreSQL、固定 digest 的 Hydra v26.2.0,以及测试专用 OAuth client。
+Hydra 只监听 127.0.0.1:14444/14445,应用使用测试证书监听 HTTPS localhost:18083;
+客户端回调由测试专用 loopback HTTP 服务接收,不在生产 JAR 中加入测试回调或 token 查看入口。
+虚拟认证器产生真实 WebAuthn 签名,随后交换授权码,检查 ID token 的 JWKS 签名、issuer、
+audience、nonce、有效期、预配置旧 sub、组与邮箱语义,并拒绝授权码重放。
+同时验证 remembered login 仍显示授权确认、RP 发起注销、back-channel token 签名与 sid
+关联,以及 Spring 会话失效。JVM 集成测试另验证真实 PostgreSQL 上客户端 CRUD、Hydra
+重启后记录仍在、更新保留旧密钥与管理接口的 MFA/组/CSRF 拒绝。
+这些验证不等于生产 Gitea 账号关联、真实新链路人类操作或 Native 验收。
+
+下一步生产切换仍需完成真实 subject 映射审查、AD/WebAuthn/Hydra Native 验收、部署网络规则,
+以及从 Gitea 返回原账号与原仓库权限的实际验收。现役 Go/Authelia 登录入口继续作为已验收路径。
+
+上游接口依据:[Hydra Login/Consent](https://www.ory.com/docs/oauth2-oidc/custom-login-consent/flow)、
+[客户端管理能力](https://www.ory.com/hydra)。
diff --git a/docs/native-validation.md b/docs/native-validation.md
index f65a049..0288b01 100644
--- a/docs/native-validation.md
+++ b/docs/native-validation.md
@@ -29,6 +29,10 @@ 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 结论。
+- Hydra 的 RestClient/JDK HTTP 与 JSON DTO 新增反射绑定声明;仍需在启用 `iam.hydra.enabled`
+ 的 Native 产物上实际运行授权码流程,JVM 接入结果不构成该项验收。
- 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。
- LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。
- 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后
@@ -40,3 +44,10 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需
通过上述测试后才准备生产切换;保留现役 OIDC 上游适配器作为回退路径。应用镜像以
不可变 digest 交给 homelab-infra,部署状态与真实人类 MFA 验收分别记录。
+
+### 测试 AOT 的待排查项(2026-09-29)
+
+本轮重构的 `processTestAot` 在处理测试上下文后未退出;线程栈显示 `DestroyJavaVM`
+等待测试夹具的非 daemon LDAP listener。该轮日常验证使用
+`test bootJar -x processTestAot -x compileAotTestJava -x processAotTestResources`
+执行 JVM 测试,不把该结果视为测试 AOT 或 Native 验收。阶段性原生验证前需解决夹具生命周期。
diff --git a/docs/webauthn.md b/docs/webauthn.md
new file mode 100644
index 0000000..6cc37c4
--- /dev/null
+++ b/docs/webauthn.md
@@ -0,0 +1,83 @@
+# 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 请求时继续 [Login/Consent](hydra-login.md)。
+
+首次注册信任近期 AD 密码验证。已有凭据后的新增注册同时要求密码与 WebAuthn 因素;
+本轮 UI 只提供首次注册与验证,不提供新增管理、删除或自助恢复入口。按维护者确认的
+自用范围,遗失全部 passkey 由管理员人工操作数据库恢复,不以开发恢复 UI 作为上线条件。
+人工恢复只处理核实后的目标主体凭据,并按首次注册规则重新绑定;不能清空整个凭据库。
+AD 用户、密码和组继续通过 RSAT 或目录命令行管理,不在本应用增加目录管理页面。
+注册和验证均要求认证器 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。
+维护者已确认开发入口的 AD + passkey 人类路径能够工作。更多认证器兼容性和 Native 路径
+仍需独立验收,不能套用虚拟认证器结果。生产共享 PostgreSQL 的接入留在部署阶段。
+
+## 数据源与注册事务
+
+PostgreSQL 是应用运行依赖,通过 `spring.datasource.*` 配置,连接池参数使用
+`spring.datasource.hikari.*`。DataSource、JdbcTemplate 与事务管理器由 Boot 自动配置,
+WebAuthn 配置仅装配官方凭据仓储及认证策略;不在应用配置中排除 DataSource 自动配置。
+仅不涉及持久化的独立测试显式排除它。
+
+首次注册的数据库锁由 `authentication/infrastructure/persistence/JdbcCredentialRegistration`
+封装;获得用户行锁后,在同一事务中重新检查注册策略并调用官方凭据保存逻辑。
+WebAuthn 策略集成不直接引用 SQL 或表结构。失败回滚与跨连接串行化由 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..d96602b 100644
--- a/frontend/src/page-context.ts
+++ b/frontend/src/page-context.ts
@@ -1,16 +1,20 @@
-export type CsrfToken = { name: string; value: string };
+export type CsrfToken = { name: string; value: string; headerName: string };
type PageBase = {
name: string;
error: string;
action: string;
csrf: CsrfToken;
+ logoutAction?: string;
};
export type SignInContext = PageBase & (
| { step: "password" }
+ | { step: "logout"; binding: string }
+ | { step: "authorize"; binding: string; client: string; scopes: string[] }
| {
- step: "mfa-pending";
+ step: "mfa-pending" | "mfa-complete";
+ passkey: "unavailable" | "register" | "authenticate";
identity: {
username: string;
subjectId: string;
@@ -25,7 +29,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 !== "logout" && context.step !== "authorize" && 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..e39ffc4 100644
--- a/frontend/src/pages/SignInPage.tsx
+++ b/frontend/src/pages/SignInPage.tsx
@@ -1,22 +1,44 @@
+import { Passkey } from "../components/Passkey";
import { SubmitForm } from "../components/SubmitForm";
import type { SignInContext } from "../page-context";
export function SignInPage({ context }: { context: SignInContext }) {
+ if (context.step === "logout") return
+ 退出统一登录
+ 将结束本次登录,并通知支持统一注销的应用。
+
+
+
+ ;
+ if (context.step === "authorize") return
+ 继续登录 {context.client}
+ {context.name},密码与 passkey 已验证。
+ 本次向应用提供以下范围的信息:
+ {context.scopes.map(scope => - {scope}
)}
+
+
+
+
+ ;
return (
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 +56,8 @@ export function SignInPage({ context }: { context: SignInContext }) {
当前仅读取 memberOf,不展开嵌套组,也不包含主组。
)}
+ {context.step === "mfa-pending" && context.passkey !== "unavailable" &&
+ }
{context.step === "password" && <>
@@ -47,6 +71,8 @@ export function SignInPage({ context }: { context: SignInContext }) {
>}
+ {context.step === "mfa-complete" && context.logoutAction &&
+ }
diff --git a/frontend/tests/hydra.spec.ts b/frontend/tests/hydra.spec.ts
new file mode 100644
index 0000000..fb8d993
--- /dev/null
+++ b/frontend/tests/hydra.spec.ts
@@ -0,0 +1,119 @@
+import { test, expect } from "@playwright/test";
+import { createServer, type Server } from "node:http";
+import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
+
+test.use({ ignoreHTTPSErrors: true });
+let callbackServer: Server | undefined;
+const logoutTokens: string[] = [];
+test.beforeAll(async () => {
+ if (process.env.IAM_HYDRA_FIXTURE !== "1") return;
+ callbackServer = createServer((request, response) => {
+ if (request.url === "/backchannel" && request.method === "POST") {
+ let body = "";
+ request.on("data", chunk => { body += chunk.toString(); });
+ request.on("end", () => {
+ logoutTokens.push(new URLSearchParams(body).get("logout_token") ?? "");
+ response.writeHead(200); response.end();
+ });
+ return;
+ }
+ response.writeHead(request.url?.startsWith("/callback?") || request.url === "/logged-out" ? 200 : 404, { "Content-Type": "text/html" });
+ response.end("Fixture callback");
+ });
+ await new Promise(resolve => callbackServer!.listen(14446, "127.0.0.1", resolve));
+});
+test.afterAll(async () => {
+ if (callbackServer) await new Promise((resolve, reject) => callbackServer!.close(error => error ? reject(error) : resolve()));
+});
+
+test("AD + passkey -> Hydra authorization code -> signed OIDC token and coordinated logout", async ({ page, context }) => {
+ test.skip(process.env.IAM_HYDRA_FIXTURE !== "1", "Start hydraBrowserFixture; never runs against production");
+ 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,
+ } });
+ const verifier = randomBytes(32).toString("base64url");
+ const state = randomBytes(24).toString("base64url");
+ const nonce = randomBytes(24).toString("base64url");
+ const authorize = new URL("http://localhost:14444/oauth2/auth");
+ authorize.search = new URLSearchParams({ client_id: "gitea-fixture", response_type: "code",
+ redirect_uri: "http://localhost:14446/callback", scope: "openid profile email groups", state, nonce,
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256",
+ }).toString();
+ await page.goto(authorize.toString());
+ await expect(page.getByRole("heading", { name: "登录你的账号" })).toBeVisible();
+ await page.getByLabel("用户名", { exact: true }).fill("alice");
+ await page.getByLabel("密码", { exact: true }).fill("fixture-password");
+ await page.getByRole("button", { name: "继续", exact: true }).click();
+ await page.getByRole("button", { name: "注册 Passkey", exact: true }).click();
+ await expect(page.getByRole("status")).toContainText("Passkey 已保存");
+ await page.getByRole("button", { name: "验证 Passkey", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "继续登录 gitea-fixture" })).toBeVisible();
+ await page.getByRole("button", { name: "继续至应用", exact: true }).click();
+ await expect.poll(() => new URL(page.url()).origin + new URL(page.url()).pathname)
+ .toBe("http://localhost:14446/callback");
+ const callback = new URL(page.url());
+ expect(callback.searchParams.get("state")).toBe(state);
+ expect(callback.searchParams.has("error")).toBe(false);
+ const code = callback.searchParams.get("code")!;
+ expect(code).toBeTruthy();
+ const exchange = await context.request.post("http://localhost:14444/oauth2/token", {
+ headers: { Authorization: "Basic " + Buffer.from("gitea-fixture:fixture-client-secret").toString("base64") },
+ form: { grant_type: "authorization_code", code, redirect_uri: "http://localhost:14446/callback", code_verifier: verifier },
+ });
+ expect(exchange.status()).toBe(200);
+ const tokens = await exchange.json();
+ expect(tokens.refresh_token).toBeUndefined();
+ const [headerPart, payloadPart, signature] = tokens.id_token.split(".");
+ const header = JSON.parse(Buffer.from(headerPart, "base64url").toString());
+ expect(header.alg).toBe("RS256");
+ const discovery = await context.request.get("http://localhost:14444/.well-known/openid-configuration").then(r => r.json());
+ expect(discovery.issuer).toBe("http://localhost:14444/");
+ const keys = await context.request.get(discovery.jwks_uri).then(r => r.json());
+ const jwk = keys.keys.find((key: { kid: string }) => key.kid === header.kid);
+ expect(verify("RSA-SHA256", Buffer.from(headerPart + "." + payloadPart),
+ createPublicKey({ key: jwk, format: "jwk" }), Buffer.from(signature, "base64url"))).toBe(true);
+ const claims = JSON.parse(Buffer.from(payloadPart, "base64url").toString());
+ expect(claims).toMatchObject({ iss: discovery.issuer, sub: "human:fixture-existing-oidc-subject",
+ nonce, preferred_username: "alice", email: "alice@example.test", email_verified: false });
+ expect([claims.aud].flat()).toContain("gitea-fixture");
+ expect(claims.exp).toBeGreaterThan(Date.now() / 1000);
+ expect(claims.groups).toEqual(["MixedCase", "gitea-admins"]);
+ expect(claims.amr).toEqual(expect.arrayContaining(["pwd", "mfa"]));
+ const replay = await context.request.post("http://localhost:14444/oauth2/token", {
+ headers: { Authorization: "Basic " + Buffer.from("gitea-fixture:fixture-client-secret").toString("base64") },
+ form: { grant_type: "authorization_code", code, redirect_uri: "http://localhost:14446/callback", code_verifier: verifier },
+ });
+ expect(replay.status()).toBe(400);
+ // Hydra remembers its session, but Spring still presents explicit authorization confirmation.
+ await page.goto(authorize.toString());
+ await expect(page.getByRole("heading", { name: "继续登录 gitea-fixture" })).toBeVisible();
+ await page.getByRole("button", { name: "继续至应用", exact: true }).click();
+ await expect.poll(() => new URL(page.url()).origin + new URL(page.url()).pathname).toBe("http://localhost:14446/callback");
+ expect(new URL(page.url()).searchParams.has("error")).toBe(false);
+ const logout = new URL("http://localhost:14444/oauth2/sessions/logout");
+ logout.search = new URLSearchParams({ id_token_hint: tokens.id_token, post_logout_redirect_uri: "http://localhost:14446/logged-out" }).toString();
+ await page.goto(logout.toString());
+ await expect(page.getByRole("heading", { name: "退出统一登录" })).toBeVisible();
+ await page.getByRole("button", { name: "确认退出", exact: true }).click();
+ await expect.poll(() => new URL(page.url()).origin + new URL(page.url()).pathname).toBe("http://localhost:14446/logged-out");
+ await expect.poll(() => logoutTokens.length).toBe(1);
+ const [lh, lp, ls] = logoutTokens[0].split(".");
+ const logoutHeader = JSON.parse(Buffer.from(lh, "base64url").toString());
+ expect(logoutHeader.alg).toBe("RS256");
+ const logoutKey = keys.keys.find((key: { kid: string }) => key.kid === logoutHeader.kid);
+ expect(verify("RSA-SHA256", Buffer.from(lh + "." + lp), createPublicKey({ key: logoutKey, format: "jwk" }), Buffer.from(ls, "base64url"))).toBe(true);
+ const notification = JSON.parse(Buffer.from(lp, "base64url").toString());
+ expect(notification.iss).toBe(discovery.issuer);
+ expect([notification.aud].flat()).toContain("gitea-fixture");
+ expect(claims.sid).toBeTruthy();
+ expect(notification.sid).toBe(claims.sid);
+ expect(notification.jti).toBeTruthy();
+ expect(notification.nonce).toBeUndefined();
+ expect(Math.abs(notification.iat - Date.now() / 1000)).toBeLessThan(60);
+ expect(notification.events).toEqual({ "http://schemas.openid.net/event/backchannel-logout": {} });
+ const session = await context.request.get("https://localhost:18083/api/iam/session");
+ expect(session.status()).toBe(401);
+});
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/persistence/JdbcCredentialRegistration.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/persistence/JdbcCredentialRegistration.java
new file mode 100644
index 0000000..d473d15
--- /dev/null
+++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/persistence/JdbcCredentialRegistration.java
@@ -0,0 +1,23 @@
+package top.ddupan.iam.login.authentication.infrastructure.persistence;
+
+import java.util.function.Supplier;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.transaction.support.TransactionTemplate;
+
+/** Serializes registration per user across sessions/processes in the same database transaction. */
+public final class JdbcCredentialRegistration {
+ private final JdbcOperations jdbc;
+ private final TransactionTemplate transactions;
+
+ public JdbcCredentialRegistration(JdbcOperations jdbc, TransactionTemplate transactions) {
+ this.jdbc = jdbc;
+ this.transactions = transactions;
+ }
+
+ public T register(String userId, Supplier registration) {
+ return transactions.execute(status -> {
+ jdbc.queryForObject("select id from user_entities where id = ? for update", String.class, userId);
+ return registration.get();
+ });
+ }
+}
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..8150dd6
--- /dev/null
+++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/DirectoryRelyingPartyOperations.java
@@ -0,0 +1,65 @@
+package top.ddupan.iam.login.authentication.infrastructure.webauthn;
+
+import top.ddupan.iam.login.authentication.infrastructure.persistence.JdbcCredentialRegistration;
+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 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 JdbcCredentialRegistration registrations;
+
+ public DirectoryRelyingPartyOperations(WebAuthnRelyingPartyOperations delegate, MfaPolicy policy,
+ PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials,
+ JdbcCredentialRegistration registrations) {
+ this.delegate = delegate;
+ this.policy = policy;
+ this.users = users;
+ this.credentials = credentials;
+ this.registrations = registrations;
+ }
+
+ @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 registrations.register(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