From 45994e39199bfc5b94897dd40f088bb6f5b9cff8 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 27 Sep 2026 18:29:52 +0000 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20WebAuthn=20=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E5=9B=A0=E7=B4=A0=E4=B8=8E=20PostgreSQL=20=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E4=BB=93=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- build.gradle | 12 +++ compose.dev.yaml | 21 +++++ docs/ad-login.md | 5 +- docs/native-validation.md | 2 + docs/webauthn.md | 70 +++++++++++++++ frontend/src/components/Passkey.tsx | 63 +++++++++++++ frontend/src/page-context.ts | 7 +- frontend/src/pages/SignInPage.tsx | 13 ++- frontend/tests/webauthn.spec.ts | 63 +++++++++++++ .../security/DirectoryPrincipal.java | 8 +- .../DirectoryRelyingPartyOperations.java | 71 +++++++++++++++ .../infrastructure/webauthn/MfaPolicy.java | 46 ++++++++++ .../webauthn/SessionChallenges.java | 74 +++++++++++++++ .../webauthn/WebAuthnProperties.java | 20 +++++ .../interfaces/web/SignInController.java | 21 ++++- .../configuration/SecurityConfiguration.java | 27 ++++-- .../WebAuthnBrowserConfigurer.java | 55 ++++++++++++ .../configuration/WebAuthnConfiguration.java | 74 +++++++++++++++ src/main/resources/application.yaml | 2 + .../db/migration/V1__webauthn_credentials.sql | 30 +++++++ .../iam/login/WebAuthnBrowserFixture.java | 36 ++++++++ .../webauthn/SessionChallengesTests.java | 70 +++++++++++++++ .../webauthn/WebAuthnIntegrationTests.java | 90 +++++++++++++++++++ .../iam/login/support/AdDirectoryFixture.java | 12 ++- 25 files changed, 874 insertions(+), 23 deletions(-) create mode 100644 compose.dev.yaml create mode 100644 docs/webauthn.md create mode 100644 frontend/src/components/Passkey.tsx create mode 100644 frontend/tests/webauthn.spec.ts create mode 100644 src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/DirectoryRelyingPartyOperations.java create mode 100644 src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/MfaPolicy.java create mode 100644 src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/SessionChallenges.java create mode 100644 src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/WebAuthnProperties.java create mode 100644 src/main/java/top/ddupan/iam/login/configuration/WebAuthnBrowserConfigurer.java create mode 100644 src/main/java/top/ddupan/iam/login/configuration/WebAuthnConfiguration.java create mode 100644 src/main/resources/db/migration/V1__webauthn_credentials.sql create mode 100644 src/test/java/top/ddupan/iam/login/WebAuthnBrowserFixture.java create mode 100644 src/test/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/SessionChallengesTests.java create mode 100644 src/test/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/WebAuthnIntegrationTests.java diff --git a/README.md b/README.md index a278a2c..2d277b3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # iam-login 独立 IAM 的登录与认证服务,以 Java、Spring Security 和 GraalVM Native 实现,作为 -Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及等待 MFA 的浏览器页面;MFA 与 Hydra 登录链路仍待实现。 +Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及 WebAuthn 第二因素浏览器流程;Hydra 登录链路仍待实现。 ## 职责与边界 @@ -68,7 +68,7 @@ JVM、AOT、Native 测试及原生应用 HTTP 检查已通过,实测范围与 [本地验证结果](docs/bootstrap.md#2026-09-25-本地验证结果)。 新增 AD 路径本轮按维护者要求只进行 JVM 验证,不沿用基线的 Native 验收结论。 重构前的真实目录密码与属性/直接所属组读取已通过维护者浏览器验收;Spring Data LDAP -版本已通过 JVM 和浏览器回归,真实人类复验待反馈。MFA 与 Hydra 链路仍待实现。 +版本已通过 JVM 和浏览器回归,真实人类复验待反馈。WebAuthn 实现与验证边界见 [第二因素](docs/webauthn.md),Hydra 链路仍待实现。 ## 领域与代码组织 @@ -87,6 +87,7 @@ configuration → 装配上述实现 - `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。 使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。 - `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。 +- `authentication/infrastructure/webauthn`:凭据归属与注册策略、challenge 仓储扩展;密码学校验和 JDBC 存储交给 Spring Security。 - `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。 - `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。 - `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。 diff --git a/build.gradle b/build.gradle index 2eace16..030a6d7 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,10 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-flyway' + runtimeOnly 'org.flywaydb:flyway-database-postgresql' + runtimeOnly 'org.postgresql:postgresql' implementation 'org.springframework.boot:spring-boot-starter-data-ldap' implementation 'org.springframework.boot:spring-boot-starter-opentelemetry' implementation 'org.springframework.boot:spring-boot-starter-security' @@ -42,6 +46,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'com.unboundid:unboundid-ldapsdk' testImplementation 'org.testcontainers:testcontainers-grafana' + testImplementation 'org.testcontainers:testcontainers-postgresql' testImplementation 'org.testcontainers:testcontainers-junit-jupiter' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' @@ -83,3 +88,10 @@ tasks.named('processResources') { } from('frontend/dist') { into 'ui' } } + +// Explicit test-only entry point for browser WebAuthn ceremonies; no fixture endpoints in production. +tasks.register('webauthnBrowserFixture', JavaExec) { + dependsOn tasks.named('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = 'top.ddupan.iam.login.WebAuthnBrowserFixture' +} diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..368ee69 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,21 @@ +# Only the development credential database. Supply IAM_DEV_DB_PASSWORD outside Git. +services: + postgres: + image: postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873 + environment: + POSTGRES_DB: iam_login + POSTGRES_USER: iam_login + POSTGRES_PASSWORD: ${IAM_DEV_DB_PASSWORD:?Set IAM_DEV_DB_PASSWORD outside Git} + ports: + - "127.0.0.1:${IAM_DEV_DB_PORT:-15432}:5432" + volumes: + - postgres:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U iam_login -d iam_login"] + interval: 5s + timeout: 3s + retries: 10 + cpus: 1 + mem_limit: 512m +volumes: + postgres: diff --git a/docs/ad-login.md b/docs/ad-login.md index 631cfe0..08237bd 100644 --- a/docs/ad-login.md +++ b/docs/ad-login.md @@ -10,8 +10,9 @@ try-with-resources 管理已认证用户仓储会话;基础设施的 `AdUserRe `AdUserEntry` 的 LDAP 注解不会进入领域对象。 成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。 -**这是密码因素验收页面,MFA 尚未接入,不是完整登录成功。** Spring Security 保存 -仅含 `FACTOR_PASSWORD` 的认证结果;其余应用请求使用 `denyAll`,不调用 Hydra, +**密码成功本身不是完整登录成功。** Spring Security 先保存仅含 `FACTOR_PASSWORD` +的认证结果;启用 [WebAuthn](webauthn.md) 后在此继续第二因素,否则停留在等待页面。 +未实现的应用请求使用 `denyAll`,不调用 Hydra, 不替换现役 Go/Authelia/Gitea 登录链路。 ## 目录和组语义 diff --git a/docs/native-validation.md b/docs/native-validation.md index f65a049..14c1f94 100644 --- a/docs/native-validation.md +++ b/docs/native-validation.md @@ -29,6 +29,8 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需 - 登录 Controller 与安全链使用 `@ConditionalOnProperty(iam.ad.enabled)`;AOT 在构建时 决定 bean 是否存在。AD Native 产物必须在 AOT 阶段启用此属性,验证实际入口与兜底链, 不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。 +- WebAuthn 新增的 JDBC/Flyway/PostgreSQL、WebAuthn4J 校验与 JSON 路径本轮仅做 JVM 验证; + Native AOT 时还需启用 `iam.webauthn.enabled`,不得套用基础骨架的 Native 结论。 - 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。 - LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。 - 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后 diff --git a/docs/webauthn.md b/docs/webauthn.md new file mode 100644 index 0000000..eedbd37 --- /dev/null +++ b/docs/webauthn.md @@ -0,0 +1,70 @@ +# WebAuthn 第二因素 + +WebAuthn 使用 Spring Security 官方过滤器、WebAuthn4J 校验和 JDBC 仓储。 +PostgreSQL 保存 user handle、凭据公钥与签名计数等记录,不保存用户密码或认证器私钥。 +AD 仍为用户与组权威;凭据按目录 authority + objectGUID 关联,用户名改名不会换主体。 + +## 登录与注册 + +1. `/signin` 使用原生表单 POST 验证 AD 密码,建立 `FACTOR_PASSWORD`。 +2. `/signin/mfa`:没有凭据时注册 passkey;已有凭据时验证 passkey。 +3. 注册只保存凭据,必须再次实际验证,才取得 `FACTOR_WEBAUTHN`。 +4. `/signin/complete` 要求两种因素均在 10 分钟内有效;目前仅显示验证结果,尚不接受 Hydra challenge。 + +首次注册信任近期 AD 密码验证。已有凭据后的新增注册同时要求密码与 WebAuthn 因素; +本轮 UI 只提供首次注册与验证,不提供新增管理、删除或自助恢复入口。遗失所有 passkey +尚无自助登录途径;生产上线前需要另行确定恢复和初始注册政策,不能把数据库清空作为日常恢复方式。 +注册和验证均要求认证器 user verification(例如 PIN 或生物识别)。 + +Spring Security 负责因素合并、会话轮换、退出和 CSRF。应用仅补目录主体与凭据所有权 +限制、首次注册并发检查,以及 challenge 的 5 分钟服务端有效期和单次消费。 +没有应用自建登录状态机或认证 Filter。上游 options Filter 位于授权 Filter 前,因而 +这些检查在 RelyingPartyOperations 扩展点执行,不能仅靠 URL 授权规则。 + +## 本地数据库 + +```sh +# 密码从 shell 或外部权限为 0600 的 env 文件提供;不要写入版本库。 +docker compose -p iam-login-dev -f compose.dev.yaml up -d +``` + +必须设置 `IAM_DEV_DB_PASSWORD`。PostgreSQL 仅发布在 `127.0.0.1:15432`,可用 +`IAM_DEV_DB_PORT` 调整端口;数据保存在 Compose named volume 中。 +普通 `down` 不删数据,**不要使用 `down -v`**,否则会删除已注册凭据。 + +应用额外配置: + +```yaml +iam: + webauthn: + enabled: true + rp-id: laptop.tail7e769.ts.net + origin: https://laptop.tail7e769.ts.net:18082 +spring: + datasource: + url: jdbc:postgresql://127.0.0.1:15432/iam_login + username: iam_login + password: ${IAM_DEV_DB_PASSWORD} +``` + +同时启用并配置 [AD](ad-login.md)。RP ID 不含协议与端口,origin 必须与浏览器实际 +HTTPS 入口完全一致;改变 RP 域名后旧凭据不能直接在新域名使用。 +凭据 schema 由 Flyway 管理,采用 Spring Security 7.1.1 官方 PostgreSQL 表结构, +增加主体名称唯一约束和凭据所有者索引。未启用 WebAuthn 时不创建 DataSource,AD-only +路径不需要数据库。数据库不可用时 MFA 失败,不降级为仅密码通过。 + +## 验证 + +JVM 回归使用 Testcontainers PostgreSQL 与模拟 AD;不会向真实 AD 提交测试密码。 +浏览器用 Chromium 虚拟认证器产生真实注册/断言签名,再交给后端校验: + +```sh +scripts/gradle-in-docker test +scripts/gradle-in-docker webauthnBrowserFixture +# 另一终端;测试夹具固定监听 localhost:18083,使用测试证书。 +IAM_WEBAUTHN_FIXTURE=1 npm --prefix frontend run test:browser -- webauthn.spec.ts +``` + +测试专用启动类仅在 test classpath,不进入生产 JAR,也不提供生产调试 API。 +真实用户的 passkey 注册、认证器兼容性和 Native 路径仍需独立验收;JVM/虚拟认证器通过 +不能代替真实人类或 Native 验收。生产共享 PostgreSQL 的接入留在部署阶段。 diff --git a/frontend/src/components/Passkey.tsx b/frontend/src/components/Passkey.tsx new file mode 100644 index 0000000..65e1809 --- /dev/null +++ b/frontend/src/components/Passkey.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import type { CsrfToken } from "../page-context"; + +export function Passkey({ csrf, initial }: { csrf: CsrfToken; initial: "register" | "authenticate" }) { + const [step, setStep] = useState(initial); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [registered, setRegistered] = useState(false); + + async function post(path: string, body?: unknown) { + const response = await fetch(path, { + method: "POST", credentials: "same-origin", redirect: "error", + headers: { "Content-Type": "application/json", [csrf.headerName]: csrf.value }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) { + throw new Error("验证未完成,请重试;若登录已过期,请退出并重新验证密码。"); + } + return response.json(); + } + + async function perform() { + setBusy(true); + setError(""); + try { + if (!PublicKeyCredential.parseCreationOptionsFromJSON || !PublicKeyCredential.parseRequestOptionsFromJSON) { + throw new Error("请使用支持 passkey 的新版浏览器。"); + } + if (step === "register") { + const options = await post("/webauthn/register/options"); + const credential = await navigator.credentials.create({ + publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options), + }) as PublicKeyCredential | null; + if (!credential) throw new Error("注册已取消。"); + await post("/webauthn/register", { publicKey: { credential: credential.toJSON(), label: "Passkey" } }); + setRegistered(true); + setStep("authenticate"); + } else { + const options = await post("/webauthn/authenticate/options"); + const credential = await navigator.credentials.get({ + publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options), + }) as PublicKeyCredential | null; + if (!credential) throw new Error("验证已取消。"); + await post("/login/webauthn", credential.toJSON()); + window.location.assign("/signin/complete"); + } + } catch (cause) { + setError(cause instanceof DOMException ? "操作已取消或认证器不可用,可以重试。" + : cause instanceof Error ? cause.message : "验证未完成,请重试。"); + } finally { + setBusy(false); + } + } + + return
+ {registered &&

Passkey 已保存,请验证一次以完成第二因素。

} + {step === "register" &&

首次使用,请注册 passkey。后续登录仍需 AD 密码和 passkey。

} + {error &&

{error}

} + +
; +} diff --git a/frontend/src/page-context.ts b/frontend/src/page-context.ts index 70a0614..d605524 100644 --- a/frontend/src/page-context.ts +++ b/frontend/src/page-context.ts @@ -1,4 +1,4 @@ -export type CsrfToken = { name: string; value: string }; +export type CsrfToken = { name: string; value: string; headerName: string }; type PageBase = { name: string; @@ -10,7 +10,8 @@ type PageBase = { export type SignInContext = PageBase & ( | { step: "password" } | { - step: "mfa-pending"; + step: "mfa-pending" | "mfa-complete"; + passkey: "unavailable" | "register" | "authenticate"; identity: { username: string; subjectId: string; @@ -25,7 +26,7 @@ export function readPageContext(): SignInContext { const data = document.getElementById("login-context")?.textContent; if (!data) throw new Error("Missing login page context"); const context: SignInContext = JSON.parse(data); - if (context.step !== "password" && context.step !== "mfa-pending") { + if (context.step !== "password" && context.step !== "mfa-pending" && context.step !== "mfa-complete") { throw new Error("Unknown login step"); } return context; diff --git a/frontend/src/pages/SignInPage.tsx b/frontend/src/pages/SignInPage.tsx index f565cdd..c508bd1 100644 --- a/frontend/src/pages/SignInPage.tsx +++ b/frontend/src/pages/SignInPage.tsx @@ -1,3 +1,4 @@ +import { Passkey } from "../components/Passkey"; import { SubmitForm } from "../components/SubmitForm"; import type { SignInContext } from "../page-context"; @@ -8,15 +9,19 @@ export function SignInPage({ context }: { context: SignInContext }) {
AD 登录验证

- {context.step === "password" ? "登录你的账号" : "密码已验证,等待 MFA"} + {context.step === "password" ? "登录你的账号" : context.step === "mfa-complete" ? "MFA 已验证" : "密码已验证,等待 MFA"}

{context.step === "password" ? "使用 AD 用户名或完整 UPN 登录。" - : `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`} + : context.step === "mfa-complete" + ? `${context.name},密码与 passkey 已验证。尚未向应用授权。` + : context.passkey === "unavailable" + ? `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。` + : `${context.name},请使用 passkey 完成第二因素验证。`}

{context.error &&

{context.error}

} - {context.step === "mfa-pending" && ( + {context.step !== "password" && (
账号
{context.identity.username}
@@ -34,6 +39,8 @@ export function SignInPage({ context }: { context: SignInContext }) {

当前仅读取 memberOf,不展开嵌套组,也不包含主组。

)} + {context.step === "mfa-pending" && context.passkey !== "unavailable" && + } {context.step === "password" && <> diff --git a/frontend/tests/webauthn.spec.ts b/frontend/tests/webauthn.spec.ts new file mode 100644 index 0000000..d038b39 --- /dev/null +++ b/frontend/tests/webauthn.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "@playwright/test"; + +test.use({ ignoreHTTPSErrors: true }); + +// Dedicated localhost fixture only. Never registers a synthetic credential against real AD. +test("AD + PostgreSQL + real WebAuthn ceremony, factor gating and persisted re-login", async ({ page, context }) => { + test.skip(process.env.IAM_WEBAUTHN_FIXTURE !== "1", "Start the test-only webauthnBrowserFixture first"); + const cdp = await context.newCDPSession(page); + await cdp.send("WebAuthn.enable"); + await cdp.send("WebAuthn.addVirtualAuthenticator", { options: { + protocol: "ctap2", transport: "internal", hasResidentKey: true, + hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, + } }); + async function login(username = "alice") { + await page.goto("https://localhost:18083/signin"); + await page.getByLabel("用户名", { exact: true }).fill(username); + await page.getByLabel("密码", { exact: true }).fill("fixture-password"); + await page.getByRole("button", { name: "继续", exact: true }).click(); + await expect(page.getByRole("heading", { name: "密码已验证,等待 MFA" })).toBeVisible(); + } + await login(); + await page.getByRole("button", { name: "注册 Passkey", exact: true }).click(); + await expect(page.getByRole("status")).toContainText("Passkey 已保存"); + await page.goto("https://localhost:18083/signin/complete"); + await expect(page).toHaveURL(/^https:\/\/localhost:18083\/signin\/mfa(?:\?.*)?$/); + const enrollmentToken = await page.evaluate(() => JSON.parse(document.getElementById("login-context")!.textContent!).csrf); + const enrollAgain = await context.request.post("https://localhost:18083/webauthn/register/options", { + headers: { [enrollmentToken.headerName]: enrollmentToken.value }, maxRedirects: 0, + }); + expect(enrollAgain.status()).toBe(302); + expect(enrollAgain.headers().location).toContain("factor.type=webauthn"); + const beforeMfa = (await context.cookies()).find(c => c.name === "JSESSIONID")!.value; + await page.getByRole("button", { name: "验证 Passkey", exact: true }).click(); + await expect(page.getByRole("heading", { name: "MFA 已验证", exact: true })).toBeVisible(); + await expect(page.getByText("gitea-admins", { exact: true })).toBeVisible(); + expect((await context.cookies()).find(c => c.name === "JSESSIONID")!.value).not.toBe(beforeMfa); + await page.getByRole("button", { name: "退出并重新验证", exact: true }).click(); + await login(); + await expect(page.getByRole("button", { name: "注册 Passkey", exact: true })).toHaveCount(0); + const assertionRequest = page.waitForRequest(request => new URL(request.url()).pathname === "/login/webauthn"); + await page.getByRole("button", { name: "验证 Passkey", exact: true }).click(); + const assertion = (await assertionRequest).postDataJSON(); + await expect(page.getByRole("heading", { name: "MFA 已验证", exact: true })).toBeVisible(); + const token = await page.evaluate(() => JSON.parse(document.getElementById("login-context")!.textContent!).csrf); + const replay = await context.request.post("https://localhost:18083/login/webauthn", { + headers: { [token.headerName]: token.value }, data: assertion, + }); + expect(replay.status()).toBe(401); + await page.getByRole("button", { name: "退出并重新验证", exact: true }).click(); + await login("bob"); + // Bob has no credential. A discoverable Alice credential must not become Bob's second factor. + const foreignStatus = await page.evaluate(async () => { + const csrf = JSON.parse(document.getElementById("login-context")!.textContent!).csrf; + const headers = { "Content-Type": "application/json", [csrf.headerName]: csrf.value }; + const options = await fetch("/webauthn/authenticate/options", { method: "POST", headers }).then(r => r.json()); + const credential = await navigator.credentials.get({ + publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options), + }) as PublicKeyCredential; + return fetch("/login/webauthn", { method: "POST", headers, body: JSON.stringify(credential.toJSON()) }) + .then(r => r.status); + }); + expect(foreignStatus).toBe(401); +}); diff --git a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/security/DirectoryPrincipal.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/security/DirectoryPrincipal.java index 01e1ee4..276f088 100644 --- a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/security/DirectoryPrincipal.java +++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/security/DirectoryPrincipal.java @@ -1,10 +1,16 @@ package top.ddupan.iam.login.authentication.infrastructure.security; import org.springframework.security.core.AuthenticatedPrincipal; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; import top.ddupan.iam.login.authentication.domain.User; /** An immutable directory snapshot; never contains credentials or connections. */ -public record DirectoryPrincipal(User user) implements AuthenticatedPrincipal { +public record DirectoryPrincipal(User user, Bytes credentialUserId) + implements AuthenticatedPrincipal, PublicKeyCredentialUserEntity { + public DirectoryPrincipal(User user) { this(user, null); } + @Override public Bytes getId() { return credentialUserId; } + @Override public String getDisplayName() { return user.displayName(); } @Override public String getName() { return user.id().authority() + ":" + user.id().value(); diff --git a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/DirectoryRelyingPartyOperations.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/DirectoryRelyingPartyOperations.java new file mode 100644 index 0000000..3b45466 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/DirectoryRelyingPartyOperations.java @@ -0,0 +1,71 @@ +package top.ddupan.iam.login.authentication.infrastructure.webauthn; + +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.webauthn.api.*; +import org.springframework.security.web.webauthn.management.*; +import org.springframework.transaction.support.TransactionTemplate; +import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal; + +/** Adds directory ownership/enrollment policy; verification and storage remain upstream implementations. */ +public final class DirectoryRelyingPartyOperations implements WebAuthnRelyingPartyOperations { + private final WebAuthnRelyingPartyOperations delegate; + private final MfaPolicy policy; + private final PublicKeyCredentialUserEntityRepository users; + private final UserCredentialRepository credentials; + private final JdbcOperations jdbc; + private final TransactionTemplate transactions; + + public DirectoryRelyingPartyOperations(WebAuthnRelyingPartyOperations delegate, MfaPolicy policy, + PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials, + JdbcOperations jdbc, TransactionTemplate transactions) { + this.delegate = delegate; + this.policy = policy; + this.users = users; + this.credentials = credentials; + this.jdbc = jdbc; + this.transactions = transactions; + } + + @Override + public PublicKeyCredentialCreationOptions createPublicKeyCredentialCreationOptions( + PublicKeyCredentialCreationOptionsRequest request) { + policy.current(); + policy.requireEnrollment(request.getAuthentication()); + return delegate.createPublicKeyCredentialCreationOptions(request); + } + + @Override + public CredentialRecord registerCredential(RelyingPartyRegistrationRequest request) { + var principal = policy.current(); + var owner = request.getCreationOptions().getUser(); + if (!principal.getName().equals(owner.getName())) throw new AccessDeniedException("Credential owner mismatch"); + return transactions.execute(status -> { + // Serialize first enrollment across sessions/processes, then recheck existing factors. + jdbc.queryForObject("select id from user_entities where id = ? for update", String.class, + owner.getId().toBase64UrlString()); + policy.requireEnrollment(SecurityContextHolder.getContext().getAuthentication()); + return delegate.registerCredential(request); + }); + } + + @Override + public PublicKeyCredentialRequestOptions createCredentialRequestOptions(PublicKeyCredentialRequestOptionsRequest request) { + policy.current(); + return delegate.createCredentialRequestOptions(request); + } + + @Override + public PublicKeyCredentialUserEntity authenticate(RelyingPartyAuthenticationRequest request) { + var principal = policy.current(); + var owner = users.findByUsername(principal.getName()); + var credential = credentials.findByCredentialId(request.getPublicKey().getRawId()); + if (owner == null || credential == null || !owner.getId().equals(credential.getUserEntityUserId())) { + throw new AccessDeniedException("Credential owner mismatch"); + } + var verified = delegate.authenticate(request); + if (!verified.getName().equals(principal.getName())) throw new AccessDeniedException("Credential owner mismatch"); + return new DirectoryPrincipal(principal.user(), verified.getId()); + } +} diff --git a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/MfaPolicy.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/MfaPolicy.java new file mode 100644 index 0000000..b233dcf --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/MfaPolicy.java @@ -0,0 +1,46 @@ +package top.ddupan.iam.login.authentication.infrastructure.webauthn; + +import java.time.Duration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationManagerFactories; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository; +import org.springframework.security.web.webauthn.management.UserCredentialRepository; +import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal; + +/** Enrollment policy; factor completion and freshness are evaluated by Spring Security. */ +public final class MfaPolicy { + private final PublicKeyCredentialUserEntityRepository users; + private final UserCredentialRepository credentials; + public final AuthorizationManager password = AuthorizationManagerFactories.multiFactor() + .requireFactor(f -> f.passwordAuthority().validDuration(Duration.ofMinutes(10))).build().authenticated(); + public final AuthorizationManager complete = AuthorizationManagerFactories.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); + } +} diff --git a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/SessionChallenges.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/SessionChallenges.java new file mode 100644 index 0000000..063a3d1 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/SessionChallenges.java @@ -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); + } + }; + } +} diff --git a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/WebAuthnProperties.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/WebAuthnProperties.java new file mode 100644 index 0000000..58d8343 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/WebAuthnProperties.java @@ -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"); + } + } + } +} diff --git a/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInController.java b/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInController.java index 4f889ad..ca4ec73 100644 --- a/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInController.java +++ b/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInController.java @@ -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 policies; - public SignInController(PageRenderer renderer) { + public SignInController(PageRenderer renderer, ObjectProvider 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 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 complete(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) { + return identity(principal, csrf, "mfa-complete", "authenticate"); + } + + private ResponseEntity 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 csrf(CsrfToken token) { - return Map.of("name", token.getParameterName(), "value", token.getToken()); + return Map.of("name", token.getParameterName(), "value", token.getToken(), "headerName", token.getHeaderName()); } } diff --git a/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java b/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java index 02a543d..09a04bc 100644 --- a/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java +++ b/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java @@ -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 webAuthn) throws Exception { var passwordFactor = AuthorizationManagerFactories.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 diff --git a/src/main/java/top/ddupan/iam/login/configuration/WebAuthnBrowserConfigurer.java b/src/main/java/top/ddupan/iam/login/configuration/WebAuthnBrowserConfigurer.java new file mode 100644 index 0000000..d8e72f6 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/configuration/WebAuthnBrowserConfigurer.java @@ -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; + } +} diff --git a/src/main/java/top/ddupan/iam/login/configuration/WebAuthnConfiguration.java b/src/main/java/top/ddupan/iam/login/configuration/WebAuthnConfiguration.java new file mode 100644 index 0000000..35b3c0d --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/configuration/WebAuthnConfiguration.java @@ -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); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 25d34e1..3583e46 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,4 +1,6 @@ spring: + autoconfigure: + exclude: org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration application: name: iam-login management: diff --git a/src/main/resources/db/migration/V1__webauthn_credentials.sql b/src/main/resources/db/migration/V1__webauthn_credentials.sql new file mode 100644 index 0000000..eeec0eb --- /dev/null +++ b/src/main/resources/db/migration/V1__webauthn_credentials.sql @@ -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); diff --git a/src/test/java/top/ddupan/iam/login/WebAuthnBrowserFixture.java b/src/test/java/top/ddupan/iam/login/WebAuthnBrowserFixture.java new file mode 100644 index 0000000..2207586 --- /dev/null +++ b/src/test/java/top/ddupan/iam/login/WebAuthnBrowserFixture.java @@ -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(); + })); + } +} diff --git a/src/test/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/SessionChallengesTests.java b/src/test/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/SessionChallengesTests.java new file mode 100644 index 0000000..6463bac --- /dev/null +++ b/src/test/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/SessionChallengesTests.java @@ -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()))); + } +} diff --git a/src/test/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/WebAuthnIntegrationTests.java b/src/test/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/WebAuthnIntegrationTests.java new file mode 100644 index 0000000..8905cd5 --- /dev/null +++ b/src/test/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/WebAuthnIntegrationTests.java @@ -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; }; + } +} diff --git a/src/test/java/top/ddupan/iam/login/support/AdDirectoryFixture.java b/src/test/java/top/ddupan/iam/login/support/AdDirectoryFixture.java index 38168c1..a20aa17 100644 --- a/src/test/java/top/ddupan/iam/login/support/AdDirectoryFixture.java +++ b/src/test/java/top/ddupan/iam/login/support/AdDirectoryFixture.java @@ -71,7 +71,9 @@ public final class AdDirectoryFixture implements AutoCloseable { "80090308: LdapErr: DSID-0C090334, comment: AcceptSecurityContext error, data " + subcode + ", v1db1"); if (name.equalsIgnoreCase("alice@example.test")) { request.setRequest(new SimpleBindRequest(USER_DN, request.getRequest().getPassword().getValue())); - } else if (!name.equals(USER_DN)) { + } else if (name.equalsIgnoreCase("bob@example.test")) { + 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", "alice@example.test"), 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", "bob@example.test"), + 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); } } -- 2.54.0