接入 WebAuthn 第二因素与 PostgreSQL 凭据仓储
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# iam-login
|
||||
|
||||
独立 IAM 的登录与认证服务,以 Java、Spring Security 和 GraalVM Native 实现,作为
|
||||
Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及等待 MFA 的浏览器页面;MFA 与 Hydra 登录链路仍待实现。
|
||||
Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及 WebAuthn 第二因素浏览器流程;Hydra 登录链路仍待实现。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
@@ -68,7 +68,7 @@ JVM、AOT、Native 测试及原生应用 HTTP 检查已通过,实测范围与
|
||||
[本地验证结果](docs/bootstrap.md#2026-09-25-本地验证结果)。
|
||||
新增 AD 路径本轮按维护者要求只进行 JVM 验证,不沿用基线的 Native 验收结论。
|
||||
重构前的真实目录密码与属性/直接所属组读取已通过维护者浏览器验收;Spring Data LDAP
|
||||
版本已通过 JVM 和浏览器回归,真实人类复验待反馈。MFA 与 Hydra 链路仍待实现。
|
||||
版本已通过 JVM 和浏览器回归,真实人类复验待反馈。WebAuthn 实现与验证边界见 [第二因素](docs/webauthn.md),Hydra 链路仍待实现。
|
||||
|
||||
## 领域与代码组织
|
||||
|
||||
@@ -87,6 +87,7 @@ configuration → 装配上述实现
|
||||
- `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。
|
||||
使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。
|
||||
- `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。
|
||||
- `authentication/infrastructure/webauthn`:凭据归属与注册策略、challenge 仓储扩展;密码学校验和 JDBC 存储交给 Spring Security。
|
||||
- `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。
|
||||
- `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。
|
||||
- `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。
|
||||
|
||||
@@ -21,6 +21,10 @@ repositories {
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-flyway'
|
||||
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-ldap'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
@@ -42,6 +46,7 @@ dependencies {
|
||||
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
|
||||
testImplementation 'com.unboundid:unboundid-ldapsdk'
|
||||
testImplementation 'org.testcontainers:testcontainers-grafana'
|
||||
testImplementation 'org.testcontainers:testcontainers-postgresql'
|
||||
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||
testCompileOnly 'org.projectlombok:lombok'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
@@ -83,3 +88,10 @@ tasks.named('processResources') {
|
||||
}
|
||||
from('frontend/dist') { into 'ui' }
|
||||
}
|
||||
|
||||
// Explicit test-only entry point for browser WebAuthn ceremonies; no fixture endpoints in production.
|
||||
tasks.register('webauthnBrowserFixture', JavaExec) {
|
||||
dependsOn tasks.named('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'top.ddupan.iam.login.WebAuthnBrowserFixture'
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 登录链路。
|
||||
|
||||
## 目录和组语义
|
||||
|
||||
@@ -29,6 +29,8 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需
|
||||
- 登录 Controller 与安全链使用 `@ConditionalOnProperty(iam.ad.enabled)`;AOT 在构建时
|
||||
决定 bean 是否存在。AD Native 产物必须在 AOT 阶段启用此属性,验证实际入口与兜底链,
|
||||
不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。
|
||||
- WebAuthn 新增的 JDBC/Flyway/PostgreSQL、WebAuthn4J 校验与 JSON 路径本轮仅做 JVM 验证;
|
||||
Native AOT 时还需启用 `iam.webauthn.enabled`,不得套用基础骨架的 Native 结论。
|
||||
- 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。
|
||||
- LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。
|
||||
- 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后
|
||||
|
||||
@@ -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 的接入留在部署阶段。
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type CsrfToken = { name: string; value: string };
|
||||
export type CsrfToken = { name: string; value: string; headerName: string };
|
||||
|
||||
type PageBase = {
|
||||
name: string;
|
||||
@@ -10,7 +10,8 @@ type PageBase = {
|
||||
export type SignInContext = PageBase & (
|
||||
| { step: "password" }
|
||||
| {
|
||||
step: "mfa-pending";
|
||||
step: "mfa-pending" | "mfa-complete";
|
||||
passkey: "unavailable" | "register" | "authenticate";
|
||||
identity: {
|
||||
username: string;
|
||||
subjectId: string;
|
||||
@@ -25,7 +26,7 @@ export function readPageContext(): SignInContext {
|
||||
const data = document.getElementById("login-context")?.textContent;
|
||||
if (!data) throw new Error("Missing login page context");
|
||||
const context: SignInContext = JSON.parse(data);
|
||||
if (context.step !== "password" && context.step !== "mfa-pending") {
|
||||
if (context.step !== "password" && context.step !== "mfa-pending" && context.step !== "mfa-complete") {
|
||||
throw new Error("Unknown login step");
|
||||
}
|
||||
return context;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Passkey } from "../components/Passkey";
|
||||
import { SubmitForm } from "../components/SubmitForm";
|
||||
import type { SignInContext } from "../page-context";
|
||||
|
||||
@@ -8,15 +9,19 @@ export function SignInPage({ context }: { context: SignInContext }) {
|
||||
<section className="card" aria-labelledby="title">
|
||||
<div className="eyebrow">AD 登录验证</div>
|
||||
<h1 id="title">
|
||||
{context.step === "password" ? "登录你的账号" : "密码已验证,等待 MFA"}
|
||||
{context.step === "password" ? "登录你的账号" : context.step === "mfa-complete" ? "MFA 已验证" : "密码已验证,等待 MFA"}
|
||||
</h1>
|
||||
<p className="intro">
|
||||
{context.step === "password"
|
||||
? "使用 AD 用户名或完整 UPN 登录。"
|
||||
: `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`}
|
||||
: context.step === "mfa-complete"
|
||||
? `${context.name},密码与 passkey 已验证。尚未向应用授权。`
|
||||
: context.passkey === "unavailable"
|
||||
? `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`
|
||||
: `${context.name},请使用 passkey 完成第二因素验证。`}
|
||||
</p>
|
||||
{context.error && <p className="error" role="alert">{context.error}</p>}
|
||||
{context.step === "mfa-pending" && (
|
||||
{context.step !== "password" && (
|
||||
<div className="directory-result">
|
||||
<dl>
|
||||
<dt>账号</dt><dd>{context.identity.username}</dd>
|
||||
@@ -34,6 +39,8 @@ export function SignInPage({ context }: { context: SignInContext }) {
|
||||
<p>当前仅读取 memberOf,不展开嵌套组,也不包含主组。</p>
|
||||
</div>
|
||||
)}
|
||||
{context.step === "mfa-pending" && context.passkey !== "unavailable" &&
|
||||
<Passkey csrf={context.csrf} initial={context.passkey} />}
|
||||
<SubmitForm action={context.action} csrf={context.csrf}
|
||||
label={context.step === "password" ? "继续" : "退出并重新验证"}>
|
||||
{context.step === "password" && <>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+7
-1
@@ -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();
|
||||
|
||||
+71
@@ -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());
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+74
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -1,6 +1,8 @@
|
||||
package top.ddupan.iam.login.authentication.interfaces.web;
|
||||
|
||||
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.http.MediaType;
|
||||
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")
|
||||
public class SignInController {
|
||||
private final PageRenderer renderer;
|
||||
private final ObjectProvider<MfaPolicy> policies;
|
||||
|
||||
public SignInController(PageRenderer renderer) {
|
||||
public SignInController(PageRenderer renderer, ObjectProvider<MfaPolicy> policies) {
|
||||
this.renderer = renderer;
|
||||
this.policies = policies;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE)
|
||||
@@ -31,8 +35,19 @@ public class SignInController {
|
||||
|
||||
@GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE)
|
||||
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();
|
||||
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),
|
||||
"identity", Map.of("username", user.username(), "subjectId", user.id().value(),
|
||||
"email", user.email(),
|
||||
@@ -41,6 +56,6 @@ public class SignInController {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -41,18 +42,26 @@ class SecurityConfiguration {
|
||||
@Bean
|
||||
@Order(2)
|
||||
@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()
|
||||
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10)))
|
||||
.build();
|
||||
return http.securityMatcher("/signin", "/signin/**", "/assets/**")
|
||||
var mfa = webAuthn.getIfAvailable();
|
||||
http.securityMatcher("/signin", "/signin/**", "/assets/**", "/webauthn/**", "/login/webauthn")
|
||||
.redirectToHttps(Customizer.withDefaults())
|
||||
.authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords)))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll()
|
||||
.authorizeHttpRequests(auth -> {
|
||||
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())
|
||||
// No complete MFA or Hydra acceptance exists yet. Fail closed until those are implemented.
|
||||
.anyRequest().denyAll())
|
||||
// Credential deletion and all unimplemented routes remain closed.
|
||||
.anyRequest().denyAll();
|
||||
})
|
||||
.formLogin(form -> form.loginPage("/signin").loginProcessingUrl("/signin/password")
|
||||
.defaultSuccessUrl("/signin/mfa", true).failureUrl("/signin?error"))
|
||||
.logout(logout -> logout.logoutUrl("/signin/restart").logoutSuccessUrl("/signin"))
|
||||
@@ -64,8 +73,10 @@ class SecurityConfiguration {
|
||||
.requestCache(cache -> cache.disable())
|
||||
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||
"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'")))
|
||||
.build();
|
||||
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")));
|
||||
if (mfa != null) mfa.configure(http);
|
||||
var chain = http.build();
|
||||
return mfa == null ? chain : mfa.finish(http, chain);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
spring:
|
||||
autoconfigure:
|
||||
exclude: org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
|
||||
application:
|
||||
name: iam-login
|
||||
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();
|
||||
}));
|
||||
}
|
||||
}
|
||||
+70
@@ -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())));
|
||||
}
|
||||
}
|
||||
+90
@@ -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");
|
||||
if (name.equalsIgnoreCase("[email protected]")) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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("objectGUID", GUID),
|
||||
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); }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user