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

Open
panxiao81 wants to merge 1 commits from feat/webauthn-mfa into main
25 changed files with 874 additions and 23 deletions
Showing only changes of commit 45994e3919 - Show all commits
+3 -2
View File
@@ -1,7 +1,7 @@
# iam-login # iam-login
独立 IAM 的登录与认证服务,以 Java、Spring Security 和 GraalVM Native 实现,作为 独立 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-本地验证结果)。 [本地验证结果](docs/bootstrap.md#2026-09-25-本地验证结果)。
新增 AD 路径本轮按维护者要求只进行 JVM 验证,不沿用基线的 Native 验收结论。 新增 AD 路径本轮按维护者要求只进行 JVM 验证,不沿用基线的 Native 验收结论。
重构前的真实目录密码与属性/直接所属组读取已通过维护者浏览器验收;Spring Data LDAP 重构前的真实目录密码与属性/直接所属组读取已通过维护者浏览器验收;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 实体与领域映射。 - `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。
使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。 使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。
- `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。 - `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。
- `authentication/infrastructure/webauthn`:凭据归属与注册策略、challenge 仓储扩展;密码学校验和 JDBC 存储交给 Spring Security。
- `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。 - `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。
- `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。 - `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。
- `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。 - `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。
+12
View File
@@ -21,6 +21,10 @@ repositories {
dependencies { dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator' 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-data-ldap'
implementation 'org.springframework.boot:spring-boot-starter-opentelemetry' implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'
implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-security'
@@ -42,6 +46,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'com.unboundid:unboundid-ldapsdk' testImplementation 'com.unboundid:unboundid-ldapsdk'
testImplementation 'org.testcontainers:testcontainers-grafana' testImplementation 'org.testcontainers:testcontainers-grafana'
testImplementation 'org.testcontainers:testcontainers-postgresql'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter' testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testCompileOnly 'org.projectlombok:lombok' testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
@@ -83,3 +88,10 @@ tasks.named('processResources') {
} }
from('frontend/dist') { into 'ui' } 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'
}
+21
View File
@@ -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:
+3 -2
View File
@@ -10,8 +10,9 @@ try-with-resources 管理已认证用户仓储会话;基础设施的 `AdUserRe
`AdUserEntry` 的 LDAP 注解不会进入领域对象。 `AdUserEntry` 的 LDAP 注解不会进入领域对象。
成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。 成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。
**这是密码因素验收页面,MFA 尚未接入,不是完整登录成功。** Spring Security 保存 **密码成功本身不是完整登录成功。** Spring Security 先保存仅含 `FACTOR_PASSWORD`
仅含 `FACTOR_PASSWORD` 的认证结果;其余应用请求使用 `denyAll`,不调用 Hydra, 的认证结果;启用 [WebAuthn](webauthn.md) 后在此继续第二因素,否则停留在等待页面。
未实现的应用请求使用 `denyAll`,不调用 Hydra,
不替换现役 Go/Authelia/Gitea 登录链路。 不替换现役 Go/Authelia/Gitea 登录链路。
## 目录和组语义 ## 目录和组语义
+2
View File
@@ -29,6 +29,8 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需
- 登录 Controller 与安全链使用 `@ConditionalOnProperty(iam.ad.enabled)`;AOT 在构建时 - 登录 Controller 与安全链使用 `@ConditionalOnProperty(iam.ad.enabled)`;AOT 在构建时
决定 bean 是否存在。AD Native 产物必须在 AOT 阶段启用此属性,验证实际入口与兜底链, 决定 bean 是否存在。AD Native 产物必须在 AOT 阶段启用此属性,验证实际入口与兜底链,
不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。 不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。
- WebAuthn 新增的 JDBC/Flyway/PostgreSQL、WebAuthn4J 校验与 JSON 路径本轮仅做 JVM 验证;
Native AOT 时还需启用 `iam.webauthn.enabled`,不得套用基础骨架的 Native 结论。
- 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。 - 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。
- LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。 - LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。
- 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后 - 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后
+70
View File
@@ -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 的接入留在部署阶段。
+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 = { type PageBase = {
name: string; name: string;
@@ -10,7 +10,8 @@ type PageBase = {
export type SignInContext = PageBase & ( export type SignInContext = PageBase & (
| { step: "password" } | { step: "password" }
| { | {
step: "mfa-pending"; step: "mfa-pending" | "mfa-complete";
passkey: "unavailable" | "register" | "authenticate";
identity: { identity: {
username: string; username: string;
subjectId: string; subjectId: string;
@@ -25,7 +26,7 @@ export function readPageContext(): SignInContext {
const data = document.getElementById("login-context")?.textContent; const data = document.getElementById("login-context")?.textContent;
if (!data) throw new Error("Missing login page context"); if (!data) throw new Error("Missing login page context");
const context: SignInContext = JSON.parse(data); 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"); throw new Error("Unknown login step");
} }
return context; return context;
+10 -3
View File
@@ -1,3 +1,4 @@
import { Passkey } from "../components/Passkey";
import { SubmitForm } from "../components/SubmitForm"; import { SubmitForm } from "../components/SubmitForm";
import type { SignInContext } from "../page-context"; import type { SignInContext } from "../page-context";
@@ -8,15 +9,19 @@ export function SignInPage({ context }: { context: SignInContext }) {
<section className="card" aria-labelledby="title"> <section className="card" aria-labelledby="title">
<div className="eyebrow">AD 登录验证</div> <div className="eyebrow">AD 登录验证</div>
<h1 id="title"> <h1 id="title">
{context.step === "password" ? "登录你的账号" : "密码已验证,等待 MFA"} {context.step === "password" ? "登录你的账号" : context.step === "mfa-complete" ? "MFA 已验证" : "密码已验证,等待 MFA"}
</h1> </h1>
<p className="intro"> <p className="intro">
{context.step === "password" {context.step === "password"
? "使用 AD 用户名或完整 UPN 登录。" ? "使用 AD 用户名或完整 UPN 登录。"
: `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`} : context.step === "mfa-complete"
? `${context.name},密码与 passkey 已验证。尚未向应用授权。`
: context.passkey === "unavailable"
? `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`
: `${context.name},请使用 passkey 完成第二因素验证。`}
</p> </p>
{context.error && <p className="error" role="alert">{context.error}</p>} {context.error && <p className="error" role="alert">{context.error}</p>}
{context.step === "mfa-pending" && ( {context.step !== "password" && (
<div className="directory-result"> <div className="directory-result">
<dl> <dl>
<dt>账号</dt><dd>{context.identity.username}</dd> <dt>账号</dt><dd>{context.identity.username}</dd>
@@ -34,6 +39,8 @@ export function SignInPage({ context }: { context: SignInContext }) {
<p>当前仅读取 memberOf,不展开嵌套组,也不包含主组。</p> <p>当前仅读取 memberOf,不展开嵌套组,也不包含主组。</p>
</div> </div>
)} )}
{context.step === "mfa-pending" && context.passkey !== "unavailable" &&
<Passkey csrf={context.csrf} initial={context.passkey} />}
<SubmitForm action={context.action} csrf={context.csrf} <SubmitForm action={context.action} csrf={context.csrf}
label={context.step === "password" ? "继续" : "退出并重新验证"}> label={context.step === "password" ? "继续" : "退出并重新验证"}>
{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);
});
@@ -1,10 +1,16 @@
package top.ddupan.iam.login.authentication.infrastructure.security; package top.ddupan.iam.login.authentication.infrastructure.security;
import org.springframework.security.core.AuthenticatedPrincipal; 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; import top.ddupan.iam.login.authentication.domain.User;
/** An immutable directory snapshot; never contains credentials or connections. */ /** 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 @Override
public String getName() { public String getName() {
return user.id().authority() + ":" + user.id().value(); return user.id().authority() + ":" + user.id().value();
@@ -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());
}
}
@@ -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<Object> password = AuthorizationManagerFactories.<Object>multiFactor()
.requireFactor(f -> f.passwordAuthority().validDuration(Duration.ofMinutes(10))).build().authenticated();
public final AuthorizationManager<Object> complete = AuthorizationManagerFactories.<Object>multiFactor()
.requireFactor(f -> f.passwordAuthority().validDuration(Duration.ofMinutes(10)))
.requireFactor(f -> f.webauthnAuthority().validDuration(Duration.ofMinutes(10))).build().authenticated();
public MfaPolicy(PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials) {
this.users = users;
this.credentials = credentials;
}
public DirectoryPrincipal current() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
password.verify(() -> authentication, null);
if (!(authentication.getPrincipal() instanceof DirectoryPrincipal principal)) {
throw new AccessDeniedException("Directory identity required");
}
return principal;
}
public boolean enrolled(String name) {
var user = users.findByUsername(name);
return user != null && !credentials.findByUserId(user.getId()).isEmpty();
}
public void requireEnrollment(Authentication authentication) {
password.verify(() -> authentication, null);
if (enrolled(authentication.getName())) complete.verify(() -> authentication, null);
}
}
@@ -0,0 +1,74 @@
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsRepository;
import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsRepository;
/** Framework repository extension: server-side TTL, owner binding and atomic single consumption. */
public final class SessionChallenges {
private final Clock clock;
private final MfaPolicy policy;
private static final Duration LIFETIME = Duration.ofMinutes(5);
private record Challenge(Object options, String owner, Instant expiresAt) { }
public SessionChallenges(Clock clock, MfaPolicy policy) {
this.clock = clock;
this.policy = policy;
}
private void save(HttpServletRequest request, String key, Object options) {
// Upstream clears after load; load already consumes atomically. Do not clear a newer challenge.
if (options == null) return;
var owner = policy.current().getName();
var session = request.getSession();
synchronized (session) {
session.setAttribute(key, new Challenge(options, owner, clock.instant().plus(LIFETIME)));
}
}
private Object consume(HttpServletRequest request, String key) {
var session = request.getSession(false);
if (session == null) return null;
synchronized (session) {
var challenge = (Challenge) session.getAttribute(key);
session.removeAttribute(key);
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (challenge == null || !clock.instant().isBefore(challenge.expiresAt())
|| authentication == null || !challenge.owner().equals(authentication.getName())) return null;
var result = policy.password.authorize(() -> authentication, null);
if (result == null || !result.isGranted()) return null;
return challenge.options();
}
}
public PublicKeyCredentialCreationOptionsRepository registration() {
return new PublicKeyCredentialCreationOptionsRepository() {
private static final String KEY = "iam.webauthn.registration";
@Override public void save(HttpServletRequest r, HttpServletResponse s, PublicKeyCredentialCreationOptions o) {
SessionChallenges.this.save(r, KEY, o);
}
@Override public PublicKeyCredentialCreationOptions load(HttpServletRequest r) {
return (PublicKeyCredentialCreationOptions) consume(r, KEY);
}
};
}
public PublicKeyCredentialRequestOptionsRepository authentication() {
return new PublicKeyCredentialRequestOptionsRepository() {
private static final String KEY = "iam.webauthn.authentication";
@Override public void save(HttpServletRequest r, HttpServletResponse s, PublicKeyCredentialRequestOptions o) {
SessionChallenges.this.save(r, KEY, o);
}
@Override public PublicKeyCredentialRequestOptions load(HttpServletRequest r) {
return (PublicKeyCredentialRequestOptions) consume(r, KEY);
}
};
}
}
@@ -0,0 +1,20 @@
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
import java.net.URI;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("iam.webauthn")
public record WebAuthnProperties(boolean enabled, String rpId, String origin) {
public WebAuthnProperties {
if (enabled) {
if (origin == null) throw new IllegalArgumentException("WebAuthn HTTPS origin is required");
var uri = URI.create(origin);
if (rpId == null || rpId.isBlank() || !"https".equals(uri.getScheme())
|| !rpId.equals(uri.getHost()) || uri.getUserInfo() != null
|| uri.getQuery() != null || uri.getFragment() != null
|| (uri.getPath() != null && !uri.getPath().isEmpty())) {
throw new IllegalArgumentException("WebAuthn requires an exact HTTPS origin and matching RP host");
}
}
}
}
@@ -1,6 +1,8 @@
package top.ddupan.iam.login.authentication.interfaces.web; package top.ddupan.iam.login.authentication.interfaces.web;
import java.util.Map; import java.util.Map;
import org.springframework.beans.factory.ObjectProvider;
import top.ddupan.iam.login.authentication.infrastructure.webauthn.MfaPolicy;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -17,9 +19,11 @@ import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrin
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true") @ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
public class SignInController { public class SignInController {
private final PageRenderer renderer; private final PageRenderer renderer;
private final ObjectProvider<MfaPolicy> policies;
public SignInController(PageRenderer renderer) { public SignInController(PageRenderer renderer, ObjectProvider<MfaPolicy> policies) {
this.renderer = renderer; this.renderer = renderer;
this.policies = policies;
} }
@GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE) @GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE)
@@ -31,8 +35,19 @@ public class SignInController {
@GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE) @GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> pending(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) { ResponseEntity<String> pending(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
var policy = policies.getIfAvailable();
return identity(principal, csrf, "mfa-pending", policy == null ? "unavailable"
: policy.enrolled(principal.getName()) ? "authenticate" : "register");
}
@GetMapping(value = "/signin/complete", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> complete(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
return identity(principal, csrf, "mfa-complete", "authenticate");
}
private ResponseEntity<String> identity(DirectoryPrincipal principal, CsrfToken csrf, String step, String passkey) {
var user = principal.user(); var user = principal.user();
return renderer.render(Map.of("step", "mfa-pending", "name", user.displayName(), return renderer.render(Map.of("step", step, "passkey", passkey, "name", user.displayName(),
"error", "", "action", "/signin/restart", "csrf", csrf(csrf), "error", "", "action", "/signin/restart", "csrf", csrf(csrf),
"identity", Map.of("username", user.username(), "subjectId", user.id().value(), "identity", Map.of("username", user.username(), "subjectId", user.id().value(),
"email", user.email(), "email", user.email(),
@@ -41,6 +56,6 @@ public class SignInController {
} }
private static Map<String, String> csrf(CsrfToken token) { private static Map<String, String> csrf(CsrfToken token) {
return Map.of("name", token.getParameterName(), "value", token.getToken()); return Map.of("name", token.getParameterName(), "value", token.getToken(), "headerName", token.getHeaderName());
} }
} }
@@ -1,6 +1,7 @@
package top.ddupan.iam.login.configuration; package top.ddupan.iam.login.configuration;
import java.time.Duration; import java.time.Duration;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -41,18 +42,26 @@ class SecurityConfiguration {
@Bean @Bean
@Order(2) @Order(2)
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true") @ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords) throws Exception { SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords,
ObjectProvider<WebAuthnBrowserConfigurer> webAuthn) throws Exception {
var passwordFactor = AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor() var passwordFactor = AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10))) .requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10)))
.build(); .build();
return http.securityMatcher("/signin", "/signin/**", "/assets/**") var mfa = webAuthn.getIfAvailable();
http.securityMatcher("/signin", "/signin/**", "/assets/**", "/webauthn/**", "/login/webauthn")
.redirectToHttps(Customizer.withDefaults()) .redirectToHttps(Customizer.withDefaults())
.authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords))) .authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords)))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> {
.requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll() if (mfa != null) {
auth.requestMatchers("/signin/complete").access(mfa.policy.complete);
auth.requestMatchers(org.springframework.http.HttpMethod.POST, "/webauthn/register")
.access(mfa.policy.password);
}
auth.requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll()
.requestMatchers("/signin/mfa").access(passwordFactor.authenticated()) .requestMatchers("/signin/mfa").access(passwordFactor.authenticated())
// No complete MFA or Hydra acceptance exists yet. Fail closed until those are implemented. // Credential deletion and all unimplemented routes remain closed.
.anyRequest().denyAll()) .anyRequest().denyAll();
})
.formLogin(form -> form.loginPage("/signin").loginProcessingUrl("/signin/password") .formLogin(form -> form.loginPage("/signin").loginProcessingUrl("/signin/password")
.defaultSuccessUrl("/signin/mfa", true).failureUrl("/signin?error")) .defaultSuccessUrl("/signin/mfa", true).failureUrl("/signin?error"))
.logout(logout -> logout.logoutUrl("/signin/restart").logoutSuccessUrl("/signin")) .logout(logout -> logout.logoutUrl("/signin/restart").logoutSuccessUrl("/signin"))
@@ -64,8 +73,10 @@ class SecurityConfiguration {
.requestCache(cache -> cache.disable()) .requestCache(cache -> cache.disable())
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives( .headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"))) + "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")));
.build(); if (mfa != null) mfa.configure(http);
var chain = http.build();
return mfa == null ? chain : mfa.finish(http, chain);
} }
@Bean @Bean
@@ -0,0 +1,55 @@
package top.ddupan.iam.login.configuration;
import java.util.List;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.FactorGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter;
import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;
import top.ddupan.iam.login.authentication.infrastructure.webauthn.*;
/** Wires official filters through their public extension points; no custom authentication filter. */
final class WebAuthnBrowserConfigurer {
private final WebAuthnProperties properties;
final MfaPolicy policy;
private final SessionChallenges challenges;
WebAuthnBrowserConfigurer(WebAuthnProperties properties, MfaPolicy policy, SessionChallenges challenges) {
this.properties = properties;
this.policy = policy;
this.challenges = challenges;
}
void configure(HttpSecurity http) throws Exception {
http.setSharedObject(UserDetailsService.class, name -> {
if (!policy.current().getName().equals(name)) throw new UsernameNotFoundException("Directory identity mismatch");
// Spring Security merges the existing password factor, retaining its original issue time.
return new User(name, "", List.of());
});
http.webAuthn(web -> web.rpId(properties.rpId()).rpName("IAM Login")
.allowedOrigins(properties.origin()).disableDefaultRegistrationPage(true)
.creationOptionsRepository(challenges.registration()));
http.exceptionHandling(exceptions -> exceptions.defaultDeniedHandlerForMissingAuthority(
new LoginUrlAuthenticationEntryPoint("/signin/mfa"), FactorGrantedAuthority.WEBAUTHN_AUTHORITY));
}
SecurityFilterChain finish(HttpSecurity http, SecurityFilterChain chain) {
var repository = challenges.authentication();
// Security 7.1 exposes these setters but does not expose the assertion repository in its DSL.
for (var filter : chain.getFilters()) {
if (filter instanceof PublicKeyCredentialRequestOptionsFilter options) {
options.setRequestOptionsRepository(repository);
}
if (filter instanceof WebAuthnAuthenticationFilter authentication) {
authentication.setRequestOptionsRepository(repository);
authentication.setSessionAuthenticationStrategy(http.getSharedObject(
org.springframework.security.web.authentication.session.SessionAuthenticationStrategy.class));
}
}
return chain;
}
}
@@ -0,0 +1,74 @@
package top.ddupan.iam.login.configuration;
import java.time.Clock;
import java.time.Duration;
import java.util.Set;
import javax.sql.DataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
import org.springframework.security.web.webauthn.management.*;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import top.ddupan.iam.login.authentication.infrastructure.webauthn.*;
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "iam.webauthn", name = "enabled", havingValue = "true")
@EnableConfigurationProperties({WebAuthnProperties.class, DataSourceProperties.class})
class WebAuthnConfiguration {
@Bean
DataSource webAuthnDataSource(DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean
PublicKeyCredentialUserEntityRepository credentialUsers(JdbcOperations jdbc) {
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
}
@Bean
UserCredentialRepository credentials(JdbcOperations jdbc) {
return new JdbcUserCredentialRepository(jdbc);
}
@Bean
MfaPolicy mfaPolicy(PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials) {
return new MfaPolicy(users, credentials);
}
@Bean
SessionChallenges challenges(MfaPolicy policy) {
return new SessionChallenges(Clock.systemUTC(), policy);
}
@Bean
WebAuthnRelyingPartyOperations relyingParty(WebAuthnProperties properties, MfaPolicy policy,
PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials,
JdbcOperations jdbc, PlatformTransactionManager transactions) {
var delegate = new Webauthn4JRelyingPartyOperations(users, credentials,
PublicKeyCredentialRpEntity.builder().id(properties.rpId()).name("IAM Login").build(),
Set.of(properties.origin()));
delegate.setCustomizeCreationOptions(options -> options.timeout(Duration.ofMinutes(5))
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.residentKey(ResidentKeyRequirement.REQUIRED)
.userVerification(UserVerificationRequirement.REQUIRED).build()));
delegate.setCustomizeRequestOptions(options -> options.timeout(Duration.ofMinutes(5))
.userVerification(UserVerificationRequirement.REQUIRED));
return new DirectoryRelyingPartyOperations(delegate, policy, users, credentials, jdbc,
new TransactionTemplate(transactions));
}
@Bean
WebAuthnBrowserConfigurer webAuthnBrowser(WebAuthnProperties properties, MfaPolicy policy,
SessionChallenges challenges) {
return new WebAuthnBrowserConfigurer(properties, policy, challenges);
}
}
+2
View File
@@ -1,4 +1,6 @@
spring: spring:
autoconfigure:
exclude: org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
application: application:
name: iam-login name: iam-login
management: management:
@@ -0,0 +1,30 @@
-- Based on Spring Security 7.1.1 WebAuthn JDBC schemas (Apache-2.0).
create table user_entities
(
id varchar(1000) not null,
name varchar(100) not null,
display_name varchar(200),
primary key (id)
);
create table user_credentials
(
credential_id varchar(1000) not null,
user_entity_user_id varchar(1000) not null,
public_key bytea not null,
signature_count bigint,
uv_initialized boolean,
backup_eligible boolean not null,
authenticator_transports varchar(1000),
public_key_credential_type varchar(100),
backup_state boolean not null,
attestation_object bytea,
attestation_client_data_json bytea,
created timestamp,
last_used timestamp,
label varchar(1000) not null,
primary key (credential_id)
);
create unique index user_entities_name on user_entities(name);
create index user_credentials_owner on user_credentials(user_entity_user_id);
@@ -0,0 +1,36 @@
package top.ddupan.iam.login;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
import top.ddupan.iam.login.support.AdDirectoryFixture;
/** Test-only HTTPS application with simulated AD and disposable PostgreSQL. Never connects to real AD. */
public final class WebAuthnBrowserFixture {
public static void main(String[] args) {
var directory = new AdDirectoryFixture();
var database = new PostgreSQLContainer(DockerImageName.parse(
"postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873")
.asCompatibleSubstituteFor("postgres"));
database.start();
var application = new SpringApplication(IamLoginApplication.class);
application.setDefaultProperties(Map.ofEntries(
Map.entry("server.address", "127.0.0.1"), Map.entry("server.port", "18083"),
Map.entry("server.ssl.enabled", "true"), Map.entry("server.ssl.key-store", "classpath:ldap/fixture.p12"),
Map.entry("server.ssl.key-store-password", "fixture-only"),
Map.entry("iam.ad.enabled", "true"), Map.entry("iam.ad.domain", "example.test"),
Map.entry("iam.ad.base-dn", "dc=example,dc=test"), Map.entry("iam.ad.url", directory.url()),
Map.entry("iam.webauthn.enabled", "true"), Map.entry("iam.webauthn.rp-id", "localhost"),
Map.entry("iam.webauthn.origin", "https://localhost:18083"),
Map.entry("spring.datasource.url", database.getJdbcUrl()),
Map.entry("spring.datasource.username", database.getUsername()),
Map.entry("spring.datasource.password", database.getPassword()),
Map.entry("spring.security.user.password", "fixture-monitor-password"),
Map.entry("management.otlp.metrics.export.enabled", "false")));
var context = application.run(args);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
context.close(); directory.close(); database.stop();
}));
}
}
@@ -0,0 +1,70 @@
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.FactorGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.webauthn.api.Bytes;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
import top.ddupan.iam.login.authentication.domain.User;
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
import static org.assertj.core.api.Assertions.*;
class SessionChallengesTests {
private final MfaPolicy policy = new MfaPolicy(new MapPublicKeyCredentialUserEntityRepository(), new MapUserCredentialRepository());
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
@Test
void challengesExpireAndAreBoundToCurrentIdentity() {
var issued = Instant.now();
var request = new MockHttpServletRequest();
var response = new MockHttpServletResponse();
identify("alice", issued);
var repository = new SessionChallenges(Clock.fixed(issued, ZoneOffset.UTC), policy).authentication();
var options = PublicKeyCredentialRequestOptions.builder().rpId("localhost").challenge(Bytes.random()).build();
repository.save(request, response, options);
var later = new SessionChallenges(Clock.fixed(issued.plusSeconds(301), ZoneOffset.UTC), policy).authentication();
assertThat(later.load(request)).isNull();
repository.save(request, response, options);
identify("bob", issued);
assertThat(repository.load(request)).isNull();
identify("alice", issued);
assertThat(repository.load(request)).isNull();
}
@Test
void challengeIsConsumedOnceAndUpstreamCleanupCannotEraseNewChallenge() {
var issued = Instant.now();
identify("alice", issued);
var request = new MockHttpServletRequest();
var response = new MockHttpServletResponse();
var repository = new SessionChallenges(Clock.systemUTC(), policy).authentication();
var first = PublicKeyCredentialRequestOptions.builder().rpId("localhost").challenge(Bytes.random()).build();
var second = PublicKeyCredentialRequestOptions.builder().rpId("localhost").challenge(Bytes.random()).build();
repository.save(request, response, first);
assertThat(repository.load(request)).isSameAs(first);
assertThat(repository.load(request)).isNull();
repository.save(request, response, second);
repository.save(request, response, null);
assertThat(repository.load(request)).isSameAs(second);
repository.save(request, response, first);
identify("alice", issued.minusSeconds(601));
assertThat(repository.load(request)).isNull();
}
private static void identify(String id, Instant issued) {
var user = new User(new User.UserId("example.test", id), id, id, "", List.of());
SecurityContextHolder.getContext().setAuthentication(UsernamePasswordAuthenticationToken.authenticated(
new DirectoryPrincipal(user), null, List.of(FactorGrantedAuthority.withAuthority("FACTOR_PASSWORD")
.issuedAt(issued).build())));
}
}
@@ -0,0 +1,90 @@
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.FactorGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import org.springframework.jdbc.core.JdbcOperations;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
import top.ddupan.iam.login.support.AdDirectoryFixture;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties = {"iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test",
"iam.webauthn.enabled=true", "iam.webauthn.rp-id=localhost", "iam.webauthn.origin=https://localhost",
"management.otlp.metrics.export.enabled=false", "spring.security.user.password=fixture-monitor-password"})
@AutoConfigureMockMvc
class WebAuthnIntegrationTests {
static class Fixtures {
static final AdDirectoryFixture DIRECTORY = new AdDirectoryFixture();
static final PostgreSQLContainer DATABASE = new PostgreSQLContainer(DockerImageName.parse(
"postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873")
.asCompatibleSubstituteFor("postgres"));
static { DATABASE.start(); }
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry r) {
r.add("iam.ad.url", () -> Fixtures.DIRECTORY.url());
r.add("spring.datasource.url", () -> Fixtures.DATABASE.getJdbcUrl());
r.add("spring.datasource.username", () -> Fixtures.DATABASE.getUsername());
r.add("spring.datasource.password", () -> Fixtures.DATABASE.getPassword());
}
@AfterAll static void close() { Fixtures.DIRECTORY.close(); Fixtures.DATABASE.stop(); }
@Autowired MockMvc mvc;
@Autowired JdbcOperations jdbc;
@Test
void passwordOnlyCanEnrollButCannotCompleteOrDelete() throws Exception {
var session = login();
mvc.perform(post("/webauthn/register/options").session(session).with(https()))
.andExpect(status().isForbidden());
mvc.perform(post("/webauthn/register/options").session(session).with(https()).with(csrf()))
.andExpect(status().isOk()).andExpect(jsonPath("rp.id").value("localhost"))
.andExpect(jsonPath("authenticatorSelection.userVerification").value("required"));
assertThat(jdbc.queryForObject("select count(*) from user_entities", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("select count(*) from user_credentials", Integer.class)).isZero();
mvc.perform(get("/signin/complete").session(session).with(https()))
.andExpect(redirectedUrlPattern("/signin/mfa?*"));
mvc.perform(delete("/webauthn/register/unused").session(session).with(https()).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
void optionsRequireFreshPasswordEvenThoughFrameworkProcessesThemBeforeAuthorization() throws Exception {
mvc.perform(post("/webauthn/authenticate/options").with(https()).with(csrf()))
.andExpect(status().isUnauthorized());
var session = login();
var context = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT");
var auth = context.getAuthentication();
context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(auth.getPrincipal(), null,
List.of(FactorGrantedAuthority.withAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)
.issuedAt(Instant.now().minusSeconds(601)).build())));
mvc.perform(post("/webauthn/register/options").session(session).with(https()).with(csrf()))
.andExpect(status().is3xxRedirection());
}
private MockHttpSession login() throws Exception {
var session = new MockHttpSession();
mvc.perform(post("/signin/password").session(session).with(https()).with(csrf())
.param("username", "alice").param("password", "fixture-password"))
.andExpect(redirectedUrl("/signin/mfa"));
return session;
}
private static RequestPostProcessor https() {
return request -> { request.setScheme("https"); request.setSecure(true); request.setServerPort(443); return request; };
}
}
@@ -71,7 +71,9 @@ public final class AdDirectoryFixture implements AutoCloseable {
"80090308: LdapErr: DSID-0C090334, comment: AcceptSecurityContext error, data " + subcode + ", v1db1"); "80090308: LdapErr: DSID-0C090334, comment: AcceptSecurityContext error, data " + subcode + ", v1db1");
if (name.equalsIgnoreCase("[email protected]")) { if (name.equalsIgnoreCase("[email protected]")) {
request.setRequest(new SimpleBindRequest(USER_DN, request.getRequest().getPassword().getValue())); request.setRequest(new SimpleBindRequest(USER_DN, request.getRequest().getPassword().getValue()));
} else if (!name.equals(USER_DN)) { } else if (name.equalsIgnoreCase("[email protected]")) {
request.setRequest(new SimpleBindRequest("cn=Bob," + BASE, request.getRequest().getPassword().getValue()));
} else if (!name.equals(USER_DN) && !name.equals("cn=Bob," + BASE)) {
throw new LDAPException(ResultCode.INVALID_CREDENTIALS, "Invalid credentials"); throw new LDAPException(ResultCode.INVALID_CREDENTIALS, "Invalid credentials");
} }
} }
@@ -90,6 +92,14 @@ public final class AdDirectoryFixture implements AutoCloseable {
new com.unboundid.ldap.sdk.Attribute("mail", "[email protected]"), new com.unboundid.ldap.sdk.Attribute("mail", "[email protected]"),
new com.unboundid.ldap.sdk.Attribute("objectGUID", GUID), new com.unboundid.ldap.sdk.Attribute("objectGUID", GUID),
new com.unboundid.ldap.sdk.Attribute("memberOf", "CN=gitea-admins," + BASE, "CN=MixedCase," + BASE))); new com.unboundid.ldap.sdk.Attribute("memberOf", "CN=gitea-admins," + BASE, "CN=MixedCase," + BASE)));
ldap.add(new Entry("cn=Bob," + BASE,
new com.unboundid.ldap.sdk.Attribute("objectClass", "user"),
new com.unboundid.ldap.sdk.Attribute("cn", "Bob"),
new com.unboundid.ldap.sdk.Attribute("sAMAccountName", "bob"),
new com.unboundid.ldap.sdk.Attribute("userPrincipalName", "[email protected]"),
new com.unboundid.ldap.sdk.Attribute("userPassword", "fixture-password"),
new com.unboundid.ldap.sdk.Attribute("displayName", "Bob"),
new com.unboundid.ldap.sdk.Attribute("objectGUID", new byte[16])));
} catch (Exception ex) { throw new ExceptionInInitializerError(ex); } } catch (Exception ex) { throw new ExceptionInInitializerError(ex); }
} }