From 45994e39199bfc5b94897dd40f088bb6f5b9cff8 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 27 Sep 2026 18:29:52 +0000 Subject: [PATCH 1/3] =?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); } } From 62b6e9db408e0b5a08ec5529e685edfd3217f193 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Mon, 28 Sep 2026 12:30:06 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Hydra=20=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E3=80=81=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E7=BB=9F=E4=B8=80=E6=B3=A8=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- build.gradle | 8 ++ docs/client-management.md | 56 ++++++++ docs/hydra-login.md | 131 ++++++++++++++++++ docs/native-validation.md | 2 + docs/webauthn.md | 12 +- frontend/src/page-context.ts | 5 +- frontend/src/pages/SignInPage.tsx | 19 +++ frontend/tests/hydra.spec.ts | 119 ++++++++++++++++ .../interfaces/web/PageRenderer.java | 2 + .../interfaces/web/SignInController.java | 21 ++- .../application/AuthorizationPolicy.java | 54 ++++++++ .../application/AuthorizeApplication.java | 47 +++++++ .../application/port/HydraGateway.java | 13 ++ .../application/port/LogoutGateway.java | 8 ++ .../domain/AuthorizationRequest.java | 24 ++++ .../hydra/HydraAdminClient.java | 122 ++++++++++++++++ .../hydra/HydraLogoutClient.java | 67 +++++++++ .../infrastructure/hydra/HydraProperties.java | 26 ++++ .../interfaces/web/HydraBrowserRequests.java | 62 +++++++++ .../interfaces/web/HydraController.java | 94 +++++++++++++ .../interfaces/web/HydraLogoutController.java | 69 +++++++++ .../clients/application/ClientRegistry.java | 16 +++ .../application/ClientRegistryException.java | 8 ++ .../iam/login/clients/domain/OidcClient.java | 38 +++++ .../infrastructure/HydraClientRegistry.java | 93 +++++++++++++ .../interfaces/web/ClientsController.java | 45 ++++++ .../ClientManagementConfiguration.java | 51 +++++++ .../configuration/HydraConfiguration.java | 34 +++++ .../configuration/SecurityConfiguration.java | 27 ++-- .../iam/login/WebAuthnBrowserFixture.java | 14 +- .../webauthn/WebAuthnIntegrationTests.java | 114 +++++++++++++++ .../AuthorizationPolicyTests.java | 46 ++++++ .../AuthorizationUseCaseTests.java | 55 ++++++++ .../authorization/HydraAdminClientTests.java | 69 +++++++++ .../HydraRegistryIntegrationTests.java | 82 +++++++++++ .../iam/login/support/HydraFixture.java | 62 +++++++++ 37 files changed, 1700 insertions(+), 23 deletions(-) create mode 100644 docs/client-management.md create mode 100644 docs/hydra-login.md create mode 100644 frontend/tests/hydra.spec.ts create mode 100644 src/main/java/top/ddupan/iam/login/authorization/application/AuthorizationPolicy.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/application/AuthorizeApplication.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/application/port/HydraGateway.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/application/port/LogoutGateway.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/domain/AuthorizationRequest.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraAdminClient.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraLogoutClient.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraProperties.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraBrowserRequests.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraController.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraLogoutController.java create mode 100644 src/main/java/top/ddupan/iam/login/clients/application/ClientRegistry.java create mode 100644 src/main/java/top/ddupan/iam/login/clients/application/ClientRegistryException.java create mode 100644 src/main/java/top/ddupan/iam/login/clients/domain/OidcClient.java create mode 100644 src/main/java/top/ddupan/iam/login/clients/infrastructure/HydraClientRegistry.java create mode 100644 src/main/java/top/ddupan/iam/login/clients/interfaces/web/ClientsController.java create mode 100644 src/main/java/top/ddupan/iam/login/configuration/ClientManagementConfiguration.java create mode 100644 src/main/java/top/ddupan/iam/login/configuration/HydraConfiguration.java create mode 100644 src/test/java/top/ddupan/iam/login/authorization/AuthorizationPolicyTests.java create mode 100644 src/test/java/top/ddupan/iam/login/authorization/AuthorizationUseCaseTests.java create mode 100644 src/test/java/top/ddupan/iam/login/authorization/HydraAdminClientTests.java create mode 100644 src/test/java/top/ddupan/iam/login/clients/HydraRegistryIntegrationTests.java create mode 100644 src/test/java/top/ddupan/iam/login/support/HydraFixture.java diff --git a/README.md b/README.md index 2d277b3..915b2b1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # iam-login 独立 IAM 的登录与认证服务,以 Java、Spring Security 和 GraalVM Native 实现,作为 -Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及 WebAuthn 第二因素浏览器流程;Hydra 登录链路仍待实现。 +Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及 WebAuthn 第二因素浏览器流程;已实现 Hydra Login/Consent 的隔离接入,生产切换仍待验收。 ## 职责与边界 @@ -68,11 +68,11 @@ JVM、AOT、Native 测试及原生应用 HTTP 检查已通过,实测范围与 [本地验证结果](docs/bootstrap.md#2026-09-25-本地验证结果)。 新增 AD 路径本轮按维护者要求只进行 JVM 验证,不沿用基线的 Native 验收结论。 重构前的真实目录密码与属性/直接所属组读取已通过维护者浏览器验收;Spring Data LDAP -版本已通过 JVM 和浏览器回归,真实人类复验待反馈。WebAuthn 实现与验证边界见 [第二因素](docs/webauthn.md),Hydra 链路仍待实现。 +版本已通过 JVM 和浏览器回归,真实人类复验待反馈。WebAuthn 实现与验证边界见 [第二因素](docs/webauthn.md),Hydra 接入方式与生产主体映射边界见 [Login/Consent](docs/hydra-login.md)。 ## 领域与代码组织 -当前限界上下文为 `authentication`,使用 DDD 分层,依赖向领域内部收敛: +当前限界上下文为 `authentication`、`authorization` 与 `clients`,使用 DDD 分层,依赖向领域内部收敛: ```text interfaces/web → infrastructure/security(principal)+ domain @@ -90,6 +90,8 @@ configuration → 装配上述实现 - `authentication/infrastructure/webauthn`:凭据归属与注册策略、challenge 仓储扩展;密码学校验和 JDBC 存储交给 Spring Security。 - `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。 - `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。 +- `authorization`:领域请求、应用授权用例与策略、Hydra Admin 基础设施、浏览器请求绑定分层维护;客户端注册与签发仍归 Hydra。 +- `clients`:受 MFA 与直接管理组保护的客户端 CRUD;持久化与密钥由 Hydra 持有,见[管理接口](docs/client-management.md)。 - `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。 测试覆盖应用用例、AD 仓储和完整 Spring Security 过滤器链,LDAP 夹具集中在测试 `support` 包。 diff --git a/build.gradle b/build.gradle index 030a6d7..e35cbc5 100644 --- a/build.gradle +++ b/build.gradle @@ -95,3 +95,11 @@ tasks.register('webauthnBrowserFixture', JavaExec) { classpath = sourceSets.test.runtimeClasspath mainClass = 'top.ddupan.iam.login.WebAuthnBrowserFixture' } + +// Adds a loopback-only Hydra issuer and fixture client to the same test-only browser application. +tasks.register('hydraBrowserFixture', JavaExec) { + dependsOn tasks.named('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = 'top.ddupan.iam.login.WebAuthnBrowserFixture' + systemProperty 'iam.fixture.hydra', 'true' +} diff --git a/docs/client-management.md b/docs/client-management.md new file mode 100644 index 0000000..6da9b9b --- /dev/null +++ b/docs/client-management.md @@ -0,0 +1,56 @@ +# OAuth2/OIDC 客户端管理 + +本服务通过受限 API 管理 Hydra 中的第一方 confidential authorization-code 客户端。 +API 没有管理 UI,沿用浏览器 Spring session;调用者必须完成有效的密码 + WebAuthn MFA, +且直接属于配置的管理组。默认组列表为空,拒绝全部管理操作。 + +```yaml +iam: + clients: + admin-group-dns: + - CN=IAM Administrators,CN=Users,DC=example,DC=test +``` + +按完整 DN 匹配,保持与 AD 仓储一致的直接组语义,不展开嵌套组。可配置一个统一粗粒度 +管理组,不要求每个应用建立独立 admins 组。组成员来自本次目录登录快照,变更后需重新认证。 + +| 方法与路径 | 行为 | +| --- | --- | +| GET `/api/iam/session` | 获取当前 session 的 CSRF headerName/token | +| GET `/api/iam/clients?page=0&size=20` | 返回 Hydra 分页内支持的客户端,size 1–100 | +| POST `/api/iam/clients` | 创建,201 返回 `{client, secret}`,密钥仅此次返回 | +| GET `/api/iam/clients/{id}` | 查询,不返回密钥 | +| PUT `/api/iam/clients/{id}` | 替换可管理字段,保留现有密钥 | +| DELETE `/api/iam/clients/{id}` | 删除,204 | + +写操作必须携带当前 session cookie 与 GET session 返回的 CSRF 请求头。匿名返回 401, +因素不足、缺少管理组或 CSRF 不符返回 403。CSRF 默认由 Spring Security 处理,没有绕过路径。 +成功变更记录 action、client ID 和操作者稳定目录标识,不记录密钥。没有 bearer 管理接口。 + +POST/PUT 请求示例(PUT 的 id 必须等于路径): + +```json +{ + "id": "example-app", + "name": "示例应用", + "redirectUris": ["https://app.example.test/oidc/callback"], + "scopes": ["openid", "profile", "email", "groups"], + "postLogoutRedirectUris": ["https://app.example.test/logged-out"], + "backchannelLogoutUri": "https://app.example.test/oidc/backchannel-logout", + "frontchannelLogoutUri": "", + "loginEnabled": true +} +``` + +回调必须 HTTPS(隔离开发允许 loopback HTTP),不允许通配符、fragment 或 URL 用户信息。 +本轮固定 `authorization_code`、`code`、`client_secret_basic`、public subject;scope 限于上述 +四项且要求 openid。不接收任意 Hydra 字段、密钥、签名配置或授权类型,也没有通用 Admin API +代理。不要为并不支持注销协议的应用登记虚构的端点。 + +客户端与生成的 secret 由 Hydra 持久化;本服务不复制或缓存它们。管理员应在创建时安全保存 +secret。暂不提供密钥轮换接口。Hydra 版本固定,更新时省略 secret 以保留原值;集成测试验证 +创建、重启后读取、更新后原密钥仍能认证、删除。测试 PostgreSQL 完全独立于真实开发 MFA 库。 + +列表按 Hydra 原始分页过滤不支持的授权类型,空页不表示之后必无客户端;本接口不是已有 +全部 Hydra 客户端类型的迁移工具。禁用写入 Hydra metadata,后续授权即时重读并拒绝, +已签发 token 的生命周期另由 issuer 和应用控制。 diff --git a/docs/hydra-login.md b/docs/hydra-login.md new file mode 100644 index 0000000..f0dbcf7 --- /dev/null +++ b/docs/hydra-login.md @@ -0,0 +1,131 @@ +# Hydra Login/Consent 接入 + +本实现使用 Hydra 的私有 Admin API,沿用 Spring Security 的 AD + WebAuthn 双因素认证。 +OAuth2/OIDC 签发、客户端注册表和客户端密钥由 Hydra 持有。应用只负责身份、授权确认与 +Login/Consent 接受,不自行签发 token,也不实现另一套认证状态机。 + +## 浏览器路径 + +1. 客户端发起 Hydra authorization code 请求;Hydra 将浏览器带到 `/oauth2/start?login_challenge=...`。 +2. 服务端通过 Admin API 核对 client、scope、audience 与 issuer 请求地址,绑定浏览器会话, + 再进入 `/oauth2/login`。该页面由 Spring Security 要求两种因素,未满足时转到密码或 MFA 页。 +3. MFA 完成后显示应用和申请范围;用户通过带 CSRF 的原生表单 POST 确认。 +4. 服务端重新核对 Hydra 请求,使用显式 subject 绑定接受 login,浏览器返回 Hydra。 +5. Hydra 回到 `/oauth2/consent`;服务端核对会话随机值、原始 challenge 摘要、主体、client、 + 原始请求 URL 和 scope 后一次性接受 consent;Hydra 将授权码交给客户端。 + +只为管理员启用的第一方 client 接受 openid/profile/email/groups,不授予 access-token audience、 +不申请 offline_access/refresh token。Claims 按请求 scope 释放,组保持直接 AD memberOf 的名称; +AD mail 没有邮箱所有权验证依据,`email_verified=false`。 + +单个浏览器会话只保存一个未完成请求,10 分钟失效,新请求替换旧请求。认证因素与会话 +依然由 Spring Security 管理;`HydraBrowserRequests` 只保存待接受的 issuer 请求及回程关联, +不记录密码、私钥或 token。退出/重启后未完成请求需从应用重新发起。 + +Hydra v26 的 consent `login_challenge` 是内部标识,不能与浏览器收到的 opaque challenge +逐字比较。本服务在受信任的 login accept `context` 中写入原始 challenge 的 SHA-256 摘要 +与会话随机值,在 consent 读回并核对;不解析 Hydra 内部格式。 + +确认页的 CSP 仅增加当前 Hydra 与已校验回调的 origin,允许原生表单返回 issuer 后跳转; +身份页允许向固定 Hydra origin 完成注销跳转。Hydra 返回的 redirect 必须是已配置公共 origin 下的 +`/oauth2/auth`,Admin HTTP client 不跟随重定向,不向浏览器传播上游错误正文。 + +本轮仅覆盖交互式授权码流,不支持静默 `prompt=none`。 +接受 login 时保留 Hydra 登录会话,供统一注销关联应用;会话寿命沿用 Hydra 配置, +不延长已有会话。Hydra 的 `skip` 仅表示 issuer 记得登录,不能绕过 Spring 的有效双因素、 +原生表单确认与主体匹配。`prompt=login/consent/select_account` 或 `max_age` 存在时重新验证身份, +不把之前的 MFA 时间改写为新登录时间。不请求上述参数时可复用仍有效的本地双因素会话。 + +## 配置与主体连续性 + +先启用 [AD](ad-login.md) 与 [WebAuthn](webauthn.md),再提供以下配置: + +```yaml +iam: + hydra: + enabled: true + admin-url: http://hydra-admin.hydra.svc.cluster.local:4445 + public-url: https://hydra.ad.ddupan.top + clients: [gitea] # 仅供尚未写入管理标记的旧客户端过渡 + subjects: + - authority: ad.example.test + directory-id: 00112233-4455-6677-8899-aabbccddeeff + subject: human:EXISTING_REVIEWED_SUBJECT +``` + +以上主体是格式示例,不能用于真实账号。配置中的绑定按 AD authority + objectGUID 匹配, +必须唯一;未绑定用户拒绝授权。不使用用户名、邮箱、自动创建 Gitea 账号或 AD GUID 的新哈希 +来替代现有主体。现役 Go 适配器使用 `human:` + SHA-256(Authelia issuer + NUL + sub), +上线前需要取得并核对该旧主体,建立到 AD 稳定键的显式映射,确认 Gitea 的外部账号关联。 +普通改名不改变绑定;删除或修改绑定属于迁移操作,需要单独审查。 + +Hydra 环境配置需将 login URL 指向 `/oauth2/start`,consent URL 指向 `/oauth2/consent`, +logout URL 指向 `/oauth2/logout`,默认 post-logout URL 指向本服务 `/signin`。 +本仓库的实现与测试不等于这些生产设置已变更。Admin URL 可以使用现役受 NetworkPolicy +约束的集群内 HTTP,也可通过 loopback port-forward;不能公开管理端口。 +公共 origin 必须 HTTPS,HTTP 只接受隔离测试的 loopback。客户端请求必须显式带 redirect_uri。 + +## 客户端注册与管理边界 + +`clients` 领域模块提供受 Spring Security 保护的 CRUD,调用私有 Hydra Admin API。 +Hydra PostgreSQL 是客户端与密钥的唯一持久化来源,不读其内部表、不建第二份注册表。 +使用方式和边界见 [客户端管理接口](client-management.md)。 + +每次 login/consent 都重新读取 client 的 `metadata.iam_login_enabled`;显式 false 拒绝新授权。 +仅当标记不存在时使用旧 `iam.hydra.clients` allowlist。通过 API 新增启用的客户端不需要修改 +服务配置。禁用不能撤回已签发 token 或已建立的应用会话。公共动态注册入口不在本轮范围, +不能让未信任调用者写入该管理标记。 + +## 统一注销 + +RP 使用 Hydra discovery 的 `end_session_endpoint`,携带 ID token hint 与已登记回调。 +Hydra 查询其登录会话后回到 `/oauth2/logout`;用户提交有 CSRF 与浏览器请求绑定的确认表单, +服务端重新核对 challenge、主体和登记回调,接受 Hydra logout,并通过 Spring 的 +`SecurityContextLogoutHandler` 清除当前本地会话。Hydra 负责通知登记的 front/back-channel +端点并返回应用;不自行遍历客户端发 HTTP 请求。身份页也提供原生表单发起统一退出。 + +Hydra 会话不存在或已过期时可能直接返回应用,不经过确认页;这不证明 Spring 会话也被清除。 +本地退出按钮仍能清除当前 Spring 会话。下游必须实现登记的注销协议,通知失败也不能宣称 +应用已退出。统一注销不等于撤销所有已签发 token,不覆盖其他浏览器设备。 + +## 正式域名规划 + +正式入口目标为 `https://auth.ddupan.top`,替换现有 Authelia 入口,Hydra 与本服务按路径同源: + +- Hydra:discovery/JWKS、`/oauth2/auth`、`/oauth2/token`、`/oauth2/revoke`、 + `/oauth2/sessions/logout`、`/userinfo` 等经核对的 public 端点。 +- iam-login:`/signin`、`/signin/**`、`/webauthn/**`、`/login/webauthn`、 + `/oauth2/start`、`/oauth2/login`、`/oauth2/consent`、`/oauth2/logout`、`/api/iam/**` 和静态资源。 +- 不把整个 `/oauth2/**` 路径交给 Hydra;不发布 Hydra `/admin/**` 或管理端口。 + +这是部署计划,不是现网配置。上线前需核对 cookie 名称、受信任代理与 TLS 转发、issuer +变更和应用配置、旧 sub 关联,以及新 WebAuthn RP ID 的凭据注册。不能仅改 DNS 后假定现有 +passkey 和 OIDC 会话仍可复用;回退路径也需在基础设施 PR 中明确。 + +## 隔离验证与生产切换 + +首轮验收以现有 AD + MFA → Hydra → Gitea 原账号及仓库权限为目标,不以完整 self-service、 +目录管理或自动恢复为前置条件。AD 管理与人工 MFA 恢复边界见 [第二因素](webauthn.md)。 + +```sh +scripts/gradle-in-docker test bootJar +scripts/gradle-in-docker hydraBrowserFixture +# 另一终端,仅连接固定的 loopback 测试夹具: +IAM_HYDRA_FIXTURE=1 npm --prefix frontend run test:browser -- hydra.spec.ts +``` + +夹具包含模拟 AD、临时 PostgreSQL、固定 digest 的 Hydra v26.2.0,以及测试专用 OAuth client。 +Hydra 只监听 127.0.0.1:14444/14445,应用使用测试证书监听 HTTPS localhost:18083; +客户端回调由测试专用 loopback HTTP 服务接收,不在生产 JAR 中加入测试回调或 token 查看入口。 +虚拟认证器产生真实 WebAuthn 签名,随后交换授权码,检查 ID token 的 JWKS 签名、issuer、 +audience、nonce、有效期、预配置旧 sub、组与邮箱语义,并拒绝授权码重放。 +同时验证 remembered login 仍显示授权确认、RP 发起注销、back-channel token 签名与 sid +关联,以及 Spring 会话失效。JVM 集成测试另验证真实 PostgreSQL 上客户端 CRUD、Hydra +重启后记录仍在、更新保留旧密钥与管理接口的 MFA/组/CSRF 拒绝。 +这些验证不等于生产 Gitea 账号关联、真实新链路人类操作或 Native 验收。 + +下一步生产切换仍需完成真实 subject 映射审查、AD/WebAuthn/Hydra Native 验收、部署网络规则, +以及从 Gitea 返回原账号与原仓库权限的实际验收。现役 Go/Authelia 登录入口继续作为已验收路径。 + +上游接口依据:[Hydra Login/Consent](https://www.ory.com/docs/oauth2-oidc/custom-login-consent/flow)、 +[客户端管理能力](https://www.ory.com/hydra)。 diff --git a/docs/native-validation.md b/docs/native-validation.md index 14c1f94..fc0be03 100644 --- a/docs/native-validation.md +++ b/docs/native-validation.md @@ -31,6 +31,8 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需 不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。 - WebAuthn 新增的 JDBC/Flyway/PostgreSQL、WebAuthn4J 校验与 JSON 路径本轮仅做 JVM 验证; Native AOT 时还需启用 `iam.webauthn.enabled`,不得套用基础骨架的 Native 结论。 +- Hydra 的 RestClient/JDK HTTP 与 JSON DTO 新增反射绑定声明;仍需在启用 `iam.hydra.enabled` + 的 Native 产物上实际运行授权码流程,JVM 接入结果不构成该项验收。 - 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。 - LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。 - 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后 diff --git a/docs/webauthn.md b/docs/webauthn.md index eedbd37..f39444d 100644 --- a/docs/webauthn.md +++ b/docs/webauthn.md @@ -9,11 +9,13 @@ AD 仍为用户与组权威;凭据按目录 authority + objectGUID 关联, 1. `/signin` 使用原生表单 POST 验证 AD 密码,建立 `FACTOR_PASSWORD`。 2. `/signin/mfa`:没有凭据时注册 passkey;已有凭据时验证 passkey。 3. 注册只保存凭据,必须再次实际验证,才取得 `FACTOR_WEBAUTHN`。 -4. `/signin/complete` 要求两种因素均在 10 分钟内有效;目前仅显示验证结果,尚不接受 Hydra challenge。 +4. `/signin/complete` 要求两种因素均在 10 分钟内有效;单独访问显示验证结果;存在 Hydra 请求时继续 [Login/Consent](hydra-login.md)。 首次注册信任近期 AD 密码验证。已有凭据后的新增注册同时要求密码与 WebAuthn 因素; -本轮 UI 只提供首次注册与验证,不提供新增管理、删除或自助恢复入口。遗失所有 passkey -尚无自助登录途径;生产上线前需要另行确定恢复和初始注册政策,不能把数据库清空作为日常恢复方式。 +本轮 UI 只提供首次注册与验证,不提供新增管理、删除或自助恢复入口。按维护者确认的 +自用范围,遗失全部 passkey 由管理员人工操作数据库恢复,不以开发恢复 UI 作为上线条件。 +人工恢复只处理核实后的目标主体凭据,并按首次注册规则重新绑定;不能清空整个凭据库。 +AD 用户、密码和组继续通过 RSAT 或目录命令行管理,不在本应用增加目录管理页面。 注册和验证均要求认证器 user verification(例如 PIN 或生物识别)。 Spring Security 负责因素合并、会话轮换、退出和 CSRF。应用仅补目录主体与凭据所有权 @@ -66,5 +68,5 @@ IAM_WEBAUTHN_FIXTURE=1 npm --prefix frontend run test:browser -- webauthn.spec.t ``` 测试专用启动类仅在 test classpath,不进入生产 JAR,也不提供生产调试 API。 -真实用户的 passkey 注册、认证器兼容性和 Native 路径仍需独立验收;JVM/虚拟认证器通过 -不能代替真实人类或 Native 验收。生产共享 PostgreSQL 的接入留在部署阶段。 +维护者已确认开发入口的 AD + passkey 人类路径能够工作。更多认证器兼容性和 Native 路径 +仍需独立验收,不能套用虚拟认证器结果。生产共享 PostgreSQL 的接入留在部署阶段。 diff --git a/frontend/src/page-context.ts b/frontend/src/page-context.ts index d605524..d96602b 100644 --- a/frontend/src/page-context.ts +++ b/frontend/src/page-context.ts @@ -5,10 +5,13 @@ type PageBase = { error: string; action: string; csrf: CsrfToken; + logoutAction?: string; }; export type SignInContext = PageBase & ( | { step: "password" } + | { step: "logout"; binding: string } + | { step: "authorize"; binding: string; client: string; scopes: string[] } | { step: "mfa-pending" | "mfa-complete"; passkey: "unavailable" | "register" | "authenticate"; @@ -26,7 +29,7 @@ export function readPageContext(): SignInContext { const data = document.getElementById("login-context")?.textContent; if (!data) throw new Error("Missing login page context"); const context: SignInContext = JSON.parse(data); - if (context.step !== "password" && context.step !== "mfa-pending" && context.step !== "mfa-complete") { + if (context.step !== "logout" && context.step !== "authorize" && context.step !== "password" && context.step !== "mfa-pending" && context.step !== "mfa-complete") { throw new Error("Unknown login step"); } return context; diff --git a/frontend/src/pages/SignInPage.tsx b/frontend/src/pages/SignInPage.tsx index c508bd1..e39ffc4 100644 --- a/frontend/src/pages/SignInPage.tsx +++ b/frontend/src/pages/SignInPage.tsx @@ -3,6 +3,23 @@ import { SubmitForm } from "../components/SubmitForm"; import type { SignInContext } from "../page-context"; export function SignInPage({ context }: { context: SignInContext }) { + if (context.step === "logout") return
+

退出统一登录

+

将结束本次登录,并通知支持统一注销的应用。

+ + + +
; + if (context.step === "authorize") return
+

继续登录 {context.client}

+

{context.name},密码与 passkey 已验证。

+

本次向应用提供以下范围的信息:

+
    {context.scopes.map(scope =>
  • {scope}
  • )}
+ + + + +
; return (
iam
@@ -54,6 +71,8 @@ export function SignInPage({ context }: { context: SignInContext }) { } + {context.step === "mfa-complete" && context.logoutAction && + }
独立 IAM · AD 接入验证
diff --git a/frontend/tests/hydra.spec.ts b/frontend/tests/hydra.spec.ts new file mode 100644 index 0000000..fb8d993 --- /dev/null +++ b/frontend/tests/hydra.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from "@playwright/test"; +import { createServer, type Server } from "node:http"; +import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; + +test.use({ ignoreHTTPSErrors: true }); +let callbackServer: Server | undefined; +const logoutTokens: string[] = []; +test.beforeAll(async () => { + if (process.env.IAM_HYDRA_FIXTURE !== "1") return; + callbackServer = createServer((request, response) => { + if (request.url === "/backchannel" && request.method === "POST") { + let body = ""; + request.on("data", chunk => { body += chunk.toString(); }); + request.on("end", () => { + logoutTokens.push(new URLSearchParams(body).get("logout_token") ?? ""); + response.writeHead(200); response.end(); + }); + return; + } + response.writeHead(request.url?.startsWith("/callback?") || request.url === "/logged-out" ? 200 : 404, { "Content-Type": "text/html" }); + response.end("Fixture callback"); + }); + await new Promise(resolve => callbackServer!.listen(14446, "127.0.0.1", resolve)); +}); +test.afterAll(async () => { + if (callbackServer) await new Promise((resolve, reject) => callbackServer!.close(error => error ? reject(error) : resolve())); +}); + +test("AD + passkey -> Hydra authorization code -> signed OIDC token and coordinated logout", async ({ page, context }) => { + test.skip(process.env.IAM_HYDRA_FIXTURE !== "1", "Start hydraBrowserFixture; never runs against production"); + const cdp = await context.newCDPSession(page); + await cdp.send("WebAuthn.enable"); + await cdp.send("WebAuthn.addVirtualAuthenticator", { options: { + protocol: "ctap2", transport: "internal", hasResidentKey: true, + hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, + } }); + const verifier = randomBytes(32).toString("base64url"); + const state = randomBytes(24).toString("base64url"); + const nonce = randomBytes(24).toString("base64url"); + const authorize = new URL("http://localhost:14444/oauth2/auth"); + authorize.search = new URLSearchParams({ client_id: "gitea-fixture", response_type: "code", + redirect_uri: "http://localhost:14446/callback", scope: "openid profile email groups", state, nonce, + code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256", + }).toString(); + await page.goto(authorize.toString()); + await expect(page.getByRole("heading", { name: "登录你的账号" })).toBeVisible(); + await page.getByLabel("用户名", { exact: true }).fill("alice"); + await page.getByLabel("密码", { exact: true }).fill("fixture-password"); + await page.getByRole("button", { name: "继续", exact: true }).click(); + await page.getByRole("button", { name: "注册 Passkey", exact: true }).click(); + await expect(page.getByRole("status")).toContainText("Passkey 已保存"); + await page.getByRole("button", { name: "验证 Passkey", exact: true }).click(); + await expect(page.getByRole("heading", { name: "继续登录 gitea-fixture" })).toBeVisible(); + await page.getByRole("button", { name: "继续至应用", exact: true }).click(); + await expect.poll(() => new URL(page.url()).origin + new URL(page.url()).pathname) + .toBe("http://localhost:14446/callback"); + const callback = new URL(page.url()); + expect(callback.searchParams.get("state")).toBe(state); + expect(callback.searchParams.has("error")).toBe(false); + const code = callback.searchParams.get("code")!; + expect(code).toBeTruthy(); + const exchange = await context.request.post("http://localhost:14444/oauth2/token", { + headers: { Authorization: "Basic " + Buffer.from("gitea-fixture:fixture-client-secret").toString("base64") }, + form: { grant_type: "authorization_code", code, redirect_uri: "http://localhost:14446/callback", code_verifier: verifier }, + }); + expect(exchange.status()).toBe(200); + const tokens = await exchange.json(); + expect(tokens.refresh_token).toBeUndefined(); + const [headerPart, payloadPart, signature] = tokens.id_token.split("."); + const header = JSON.parse(Buffer.from(headerPart, "base64url").toString()); + expect(header.alg).toBe("RS256"); + const discovery = await context.request.get("http://localhost:14444/.well-known/openid-configuration").then(r => r.json()); + expect(discovery.issuer).toBe("http://localhost:14444/"); + const keys = await context.request.get(discovery.jwks_uri).then(r => r.json()); + const jwk = keys.keys.find((key: { kid: string }) => key.kid === header.kid); + expect(verify("RSA-SHA256", Buffer.from(headerPart + "." + payloadPart), + createPublicKey({ key: jwk, format: "jwk" }), Buffer.from(signature, "base64url"))).toBe(true); + const claims = JSON.parse(Buffer.from(payloadPart, "base64url").toString()); + expect(claims).toMatchObject({ iss: discovery.issuer, sub: "human:fixture-existing-oidc-subject", + nonce, preferred_username: "alice", email: "alice@example.test", email_verified: false }); + expect([claims.aud].flat()).toContain("gitea-fixture"); + expect(claims.exp).toBeGreaterThan(Date.now() / 1000); + expect(claims.groups).toEqual(["MixedCase", "gitea-admins"]); + expect(claims.amr).toEqual(expect.arrayContaining(["pwd", "mfa"])); + const replay = await context.request.post("http://localhost:14444/oauth2/token", { + headers: { Authorization: "Basic " + Buffer.from("gitea-fixture:fixture-client-secret").toString("base64") }, + form: { grant_type: "authorization_code", code, redirect_uri: "http://localhost:14446/callback", code_verifier: verifier }, + }); + expect(replay.status()).toBe(400); + // Hydra remembers its session, but Spring still presents explicit authorization confirmation. + await page.goto(authorize.toString()); + await expect(page.getByRole("heading", { name: "继续登录 gitea-fixture" })).toBeVisible(); + await page.getByRole("button", { name: "继续至应用", exact: true }).click(); + await expect.poll(() => new URL(page.url()).origin + new URL(page.url()).pathname).toBe("http://localhost:14446/callback"); + expect(new URL(page.url()).searchParams.has("error")).toBe(false); + const logout = new URL("http://localhost:14444/oauth2/sessions/logout"); + logout.search = new URLSearchParams({ id_token_hint: tokens.id_token, post_logout_redirect_uri: "http://localhost:14446/logged-out" }).toString(); + await page.goto(logout.toString()); + await expect(page.getByRole("heading", { name: "退出统一登录" })).toBeVisible(); + await page.getByRole("button", { name: "确认退出", exact: true }).click(); + await expect.poll(() => new URL(page.url()).origin + new URL(page.url()).pathname).toBe("http://localhost:14446/logged-out"); + await expect.poll(() => logoutTokens.length).toBe(1); + const [lh, lp, ls] = logoutTokens[0].split("."); + const logoutHeader = JSON.parse(Buffer.from(lh, "base64url").toString()); + expect(logoutHeader.alg).toBe("RS256"); + const logoutKey = keys.keys.find((key: { kid: string }) => key.kid === logoutHeader.kid); + expect(verify("RSA-SHA256", Buffer.from(lh + "." + lp), createPublicKey({ key: logoutKey, format: "jwk" }), Buffer.from(ls, "base64url"))).toBe(true); + const notification = JSON.parse(Buffer.from(lp, "base64url").toString()); + expect(notification.iss).toBe(discovery.issuer); + expect([notification.aud].flat()).toContain("gitea-fixture"); + expect(claims.sid).toBeTruthy(); + expect(notification.sid).toBe(claims.sid); + expect(notification.jti).toBeTruthy(); + expect(notification.nonce).toBeUndefined(); + expect(Math.abs(notification.iat - Date.now() / 1000)).toBeLessThan(60); + expect(notification.events).toEqual({ "http://schemas.openid.net/event/backchannel-logout": {} }); + const session = await context.request.get("https://localhost:18083/api/iam/session"); + expect(session.status()).toBe(401); +}); diff --git a/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/PageRenderer.java b/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/PageRenderer.java index d057063..b087546 100644 --- a/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/PageRenderer.java +++ b/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/PageRenderer.java @@ -11,6 +11,8 @@ import tools.jackson.databind.json.JsonMapper; /** Shared HTML shell; the page context is data, never executable JavaScript. */ @Component public class PageRenderer { + public static final String CONTENT_SECURITY_POLICY = "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'"; private static final String SLOT = "__IAM_PAGE_CONTEXT__"; private final String shell; private final JsonMapper json = JsonMapper.builder().build(); 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 ca4ec73..f5ac90a 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 @@ -21,7 +21,10 @@ public class SignInController { private final PageRenderer renderer; private final ObjectProvider policies; - public SignInController(PageRenderer renderer, ObjectProvider policies) { + private final ObjectProvider logout; + public SignInController(PageRenderer renderer, ObjectProvider policies, + ObjectProvider logout) { + this.logout=logout; this.renderer = renderer; this.policies = policies; } @@ -41,18 +44,28 @@ public class SignInController { } @GetMapping(value = "/signin/complete", produces = MediaType.TEXT_HTML_VALUE) - ResponseEntity complete(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) { + ResponseEntity complete(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf, + jakarta.servlet.http.HttpServletRequest request) { + if (top.ddupan.iam.login.authorization.interfaces.web.HydraBrowserRequests.pending(request)) { + return ResponseEntity.status(303).header("Cache-Control", "no-store").location(java.net.URI.create("/oauth2/login")).build(); + } 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", step, "passkey", passkey, "name", user.displayName(), - "error", "", "action", "/signin/restart", "csrf", csrf(csrf), + var page = renderer.render(Map.of("step", step, "passkey", passkey, "name", user.displayName(), + "error", "", "action", "/signin/restart", "logoutAction", logout.getIfAvailable()==null ? "" : "/signin/logout", "csrf", csrf(csrf), "identity", Map.of("username", user.username(), "subjectId", user.id().value(), "email", user.email(), "groups", user.memberships().stream().map(GroupMembership::name).toList(), "groupDns", user.memberships().stream().map(GroupMembership::externalId).toList()))); + var gateway = logout.getIfAvailable(); + if (gateway == null) return page; + var uri = java.net.URI.create(gateway.startUrl()); + return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy", + PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'", + "form-action 'self' " + uri.getScheme() + "://" + uri.getRawAuthority())).body(page.getBody()); } private static Map csrf(CsrfToken token) { diff --git a/src/main/java/top/ddupan/iam/login/authorization/application/AuthorizationPolicy.java b/src/main/java/top/ddupan/iam/login/authorization/application/AuthorizationPolicy.java new file mode 100644 index 0000000..7652626 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/application/AuthorizationPolicy.java @@ -0,0 +1,54 @@ +package top.ddupan.iam.login.authorization.application; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import top.ddupan.iam.login.authentication.domain.User; +import top.ddupan.iam.login.authorization.domain.AuthorizationRequest; + +/** Explicit first-party policy; never infers account continuity from email or username. */ +public final class AuthorizationPolicy { + private static final Set SCOPES = Set.of("openid", "profile", "email", "groups"); + private final Set clients; + private final Map subjects; + + public AuthorizationPolicy(Set clients, Map subjects) { + this.clients = clients == null ? Set.of() : Set.copyOf(clients); + this.subjects = Map.copyOf(subjects); + if (subjects.isEmpty() || subjects.values().stream().anyMatch(s -> s == null || s.isBlank()) + || subjects.values().stream().distinct().count() != subjects.size()) { + throw new IllegalArgumentException("Explicit unique subject bindings and clients are required"); + } + } + + public void validate(AuthorizationRequest request) { + if (!(request.loginEnabled() == null ? clients.contains(request.clientId()) : request.loginEnabled()) || !request.audience().isEmpty() + || !request.scopes().contains("openid") || !SCOPES.containsAll(request.scopes())) { + throw new IllegalArgumentException("Authorization request is outside configured policy"); + } + } + + public String subject(User user) { + var subject = subjects.get(user.id()); + if (subject == null) throw new IllegalArgumentException("No reviewed subject binding"); + return subject; + } + + public Map claims(User user, AuthorizationRequest request) { + validate(request); + var claims = new LinkedHashMap(); + if (request.scopes().contains("profile")) { + claims.put("preferred_username", user.username()); + claims.put("name", user.displayName()); + } + if (request.scopes().contains("email") && !user.email().isBlank()) { + claims.put("email", user.email()); + // AD mail is a directory attribute, not evidence of mailbox verification. + claims.put("email_verified", false); + } + if (request.scopes().contains("groups")) { + claims.put("groups", user.memberships().stream().map(User.GroupMembership::name).toList()); + } + return Map.copyOf(claims); + } +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/application/AuthorizeApplication.java b/src/main/java/top/ddupan/iam/login/authorization/application/AuthorizeApplication.java new file mode 100644 index 0000000..23c3b32 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/application/AuthorizeApplication.java @@ -0,0 +1,47 @@ +package top.ddupan.iam.login.authorization.application; + +import java.time.Instant; +import java.util.Set; +import top.ddupan.iam.login.authentication.domain.User; +import top.ddupan.iam.login.authorization.application.port.HydraGateway; +import top.ddupan.iam.login.authorization.domain.AuthorizationRequest; + +/** Issuer authorization use case; independent of Servlet and Spring authentication/session state. */ +public final class AuthorizeApplication { + public record AcceptedLogin(String subject, String redirect) { } + private final AuthorizationPolicy policy; + private final HydraGateway hydra; + public AuthorizeApplication(AuthorizationPolicy policy, HydraGateway hydra) { + this.policy = policy; this.hydra = hydra; + } + public AuthorizationRequest start(String challenge) { + var request = hydra.login(challenge); + policy.validate(request); + return request; + } + public String subject(User user) { return policy.subject(user); } + public AcceptedLogin login(AuthorizationRequest expected, String binding, User user, Instant authenticatedAt) { + var current = hydra.login(expected.challenge()); + sameRequest(expected, current); + var subject = policy.subject(user); + if (current.skip() && !subject.equals(current.subject()) + || current.subject() != null && !current.subject().isBlank() && !current.subject().equals(subject)) { + throw new IllegalArgumentException("Issuer subject mismatch"); + } + return new AcceptedLogin(subject, hydra.acceptLogin(current.challenge(), subject, binding, authenticatedAt)); + } + public String consent(AuthorizationRequest expected, String binding, String subject, User user, String challenge) { + var current = hydra.consent(challenge); + sameRequest(expected, current); + if (!subject.equals(current.subject()) || !policy.subject(user).equals(current.subject()) + || !binding.equals(current.binding()) || !expected.challengeDigest().equals(current.loginChallengeDigest())) { + throw new IllegalArgumentException("Issuer/browser binding mismatch"); + } + return hydra.acceptConsent(challenge, current.scopes(), policy.claims(user, current)); + } + private void sameRequest(AuthorizationRequest expected, AuthorizationRequest current) { + policy.validate(current); + if (!expected.clientId().equals(current.clientId()) || !Set.copyOf(expected.scopes()).equals(Set.copyOf(current.scopes())) + || !expected.requestUrl().equals(current.requestUrl())) throw new IllegalArgumentException("Issuer request changed"); + } +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/application/port/HydraGateway.java b/src/main/java/top/ddupan/iam/login/authorization/application/port/HydraGateway.java new file mode 100644 index 0000000..976c0a8 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/application/port/HydraGateway.java @@ -0,0 +1,13 @@ +package top.ddupan.iam.login.authorization.application.port; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import top.ddupan.iam.login.authorization.domain.AuthorizationRequest; + +public interface HydraGateway { + AuthorizationRequest login(String challenge); + AuthorizationRequest consent(String challenge); + String acceptLogin(String challenge, String subject, String binding, Instant authenticatedAt); + String acceptConsent(String challenge, List scopes, Map claims); +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/application/port/LogoutGateway.java b/src/main/java/top/ddupan/iam/login/authorization/application/port/LogoutGateway.java new file mode 100644 index 0000000..efc532e --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/application/port/LogoutGateway.java @@ -0,0 +1,8 @@ +package top.ddupan.iam.login.authorization.application.port; + +public interface LogoutGateway { + record Request(String challenge, String subject, String sid, String postLogoutRedirectUri) { } + Request request(String challenge); + String accept(String challenge); + String startUrl(); +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/domain/AuthorizationRequest.java b/src/main/java/top/ddupan/iam/login/authorization/domain/AuthorizationRequest.java new file mode 100644 index 0000000..f870f10 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/domain/AuthorizationRequest.java @@ -0,0 +1,24 @@ +package top.ddupan.iam.login.authorization.domain; + +import java.util.List; + +/** Verified metadata read from the issuer's private administrative API. */ +public record AuthorizationRequest(String challenge, String clientId, List scopes, + List audience, String subject, String loginChallengeDigest, String binding, String requestUrl, + boolean skip, Boolean loginEnabled) { + public AuthorizationRequest(String challenge, String clientId, List scopes, List audience, + String subject, String loginChallengeDigest, String binding, String requestUrl, boolean skip) { + this(challenge,clientId,scopes,audience,subject,loginChallengeDigest,binding,requestUrl,skip,null); + } + public AuthorizationRequest { + scopes = List.copyOf(scopes); + audience = List.copyOf(audience); + } + public String challengeDigest() { return digest(challenge); } + public static String digest(String challenge) { + try { + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(challenge.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException ex) { throw new IllegalStateException("SHA-256 unavailable", ex); } + } +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraAdminClient.java b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraAdminClient.java new file mode 100644 index 0000000..d9ca4a3 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraAdminClient.java @@ -0,0 +1,122 @@ +package top.ddupan.iam.login.authorization.infrastructure.hydra; + +import java.net.URI; +import java.net.http.HttpClient; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +import top.ddupan.iam.login.authorization.application.port.HydraGateway; +import top.ddupan.iam.login.authorization.domain.AuthorizationRequest; + +/** Private, fixed-origin admin client. Never follows redirects or forwards upstream errors/challenges. */ +public final class HydraAdminClient implements HydraGateway { + private final RestClient client; + private final URI publicUrl; + public HydraAdminClient(HydraProperties properties) { + client = restClient(properties).mutate() + .defaultStatusHandler(status -> !status.is2xxSuccessful(), (request, response) -> { + throw new IllegalArgumentException("Hydra returned a non-success status"); + }).build(); + publicUrl = properties.publicUrl(); + } + public static RestClient restClient(HydraProperties properties) { + var http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + var factory = new JdkClientHttpRequestFactory(http); + factory.setReadTimeout(Duration.ofSeconds(5)); + return RestClient.builder().baseUrl(properties.adminUrl().toString()).requestFactory(factory).build(); + } + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) + public record Client(String client_id, Map metadata) { } + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) + public record Context(String browser_binding, String login_challenge_digest) { } + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) + public record Request(String challenge, Client client, List requested_scope, + List requested_access_token_audience, String subject, String login_challenge, + Context context, String request_url, boolean skip) { } + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) + public record Redirect(String redirect_to) { } + + @Override public AuthorizationRequest login(String challenge) { return request("login", challenge); } + @Override public AuthorizationRequest consent(String challenge) { return request("consent", challenge); } + + private AuthorizationRequest request(String kind, String challenge) { + validateChallenge(challenge); + try { + var result = client.get().uri(builder -> builder.path("/admin/oauth2/auth/requests/" + kind) + .queryParam(kind + "_challenge", "{challenge}").build(challenge)).retrieve().body(Request.class); + if (result == null || result.client() == null || !challenge.equals(result.challenge())) { + throw new IllegalArgumentException("Invalid issuer response"); + } + validateAuthorizationUrl(result.request_url()); + var registered = client.get().uri("/admin/clients/{id}", result.client().client_id()).retrieve().body(Client.class); + if (registered == null || !result.client().client_id().equals(registered.client_id())) + throw new IllegalArgumentException("Client no longer registered"); + var eligibility = registered.metadata() == null ? null : registered.metadata().get("iam_login_enabled"); + Boolean enabled = eligibility == null ? null : Boolean.TRUE.equals(eligibility); + return new AuthorizationRequest(challenge, result.client().client_id(), + result.requested_scope() == null ? List.of() : result.requested_scope(), + result.requested_access_token_audience() == null ? List.of() : result.requested_access_token_audience(), + result.subject(), result.context() == null ? null : result.context().login_challenge_digest(), result.context() == null ? null : result.context().browser_binding(), + result.request_url(), result.skip(), enabled); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("Hydra request unavailable"); + } + } + @Override public String acceptLogin(String challenge, String subject, String binding, Instant authenticatedAt) { + return accept("login", challenge, Map.of("subject", subject, "remember", true, + "authenticated_at", authenticatedAt.toString(), "amr", List.of("pwd", "mfa"), + "context", Map.of("browser_binding", binding, "login_challenge_digest", AuthorizationRequest.digest(challenge)))); + } + @Override public String acceptConsent(String challenge, List scopes, Map claims) { + return accept("consent", challenge, Map.of("grant_scope", scopes, "grant_access_token_audience", List.of(), + "remember", false, "session", Map.of("id_token", claims))); + } + private String accept(String kind, String challenge, Object payload) { + validateChallenge(challenge); + try { + var result = client.put().uri(builder -> builder.path("/admin/oauth2/auth/requests/" + kind + "/accept") + .queryParam(kind + "_challenge", "{challenge}").build(challenge)).body(payload) + .retrieve().body(Redirect.class); + if (result == null) throw new IllegalArgumentException("Missing redirect"); + var target = URI.create(result.redirect_to()); + if (!publicUrl.getScheme().equals(target.getScheme()) || !publicUrl.getRawAuthority().equals(target.getRawAuthority()) + || target.getUserInfo() != null || target.getFragment() != null || !"/oauth2/auth".equals(target.getRawPath())) { + throw new IllegalArgumentException("Invalid redirect"); + } + return target.toString(); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("Hydra acceptance unavailable"); + } + } + private void validateAuthorizationUrl(String value) { + var uri = URI.create(value); + if (!publicUrl.getScheme().equals(uri.getScheme()) || !publicUrl.getRawAuthority().equals(uri.getRawAuthority()) + || uri.getUserInfo() != null || uri.getFragment() != null || !"/oauth2/auth".equals(uri.getRawPath())) { + throw new IllegalArgumentException("Unexpected authorization origin"); + } + var parameters = new java.util.HashMap(); + for (var pair : uri.getRawQuery().split("&")) { + var parts = pair.split("=", 2); + var key = java.net.URLDecoder.decode(parts[0], java.nio.charset.StandardCharsets.UTF_8); + var parameter = java.net.URLDecoder.decode(parts.length == 2 ? parts[1] : "", java.nio.charset.StandardCharsets.UTF_8); + if (parameters.putIfAbsent(key, parameter) != null) throw new IllegalArgumentException("Duplicate OAuth parameter"); + } + if (!"code".equals(parameters.get("response_type")) + || parameters.containsKey("prompt") && !Set.of("login", "consent", "select_account").contains(parameters.get("prompt"))) { + throw new IllegalArgumentException("Only interactive authorization code is enabled"); + } + if (parameters.containsKey("max_age") && Long.parseLong(parameters.get("max_age")) < 0) { + throw new IllegalArgumentException("Invalid max_age"); + } + } + private static void validateChallenge(String challenge) { + if (challenge == null || challenge.isBlank() || challenge.length() > 8192) { + throw new IllegalArgumentException("Invalid challenge"); + } + } +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraLogoutClient.java b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraLogoutClient.java new file mode 100644 index 0000000..a1d12b3 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraLogoutClient.java @@ -0,0 +1,67 @@ +package top.ddupan.iam.login.authorization.infrastructure.hydra; + +import java.net.URI; +import java.util.Objects; +import org.springframework.web.client.RestClient; +import top.ddupan.iam.login.authorization.application.port.LogoutGateway; + +public final class HydraLogoutClient implements LogoutGateway { + private final RestClient http; + private final URI origin; + public HydraLogoutClient(HydraProperties properties) { + origin = properties.publicUrl(); + http = HydraAdminClient.restClient(properties).mutate() + .defaultStatusHandler(status -> !status.is2xxSuccessful(), (req,res) -> { throw new IllegalArgumentException("Logout unavailable"); }).build(); + } + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true) + public record Response(String challenge,String subject,String sid,String request_url, LogoutClient client) { } + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true) + public record LogoutClient(java.util.List post_logout_redirect_uris) { } + @Override public Request request(String challenge) { + validate(challenge); + try { + var result = Objects.requireNonNull(http.get().uri(b -> b.path("/admin/oauth2/auth/requests/logout") + .queryParam("logout_challenge","{challenge}").build(challenge)).retrieve().body(Response.class)); + if (!challenge.equals(result.challenge())) throw new IllegalArgumentException("Wrong challenge"); + // Hydra stores the original HTTP request-target, which may be origin-relative. + validateTarget(origin.resolve(result.request_url()).toString()); + String callback = ""; + var query = URI.create(result.request_url()).getRawQuery(); + if (query != null) for (var part : query.split("&")) { + var pair = part.split("=",2); + if ("post_logout_redirect_uri".equals(java.net.URLDecoder.decode(pair[0],java.nio.charset.StandardCharsets.UTF_8))) { + if (!callback.isEmpty() || pair.length!=2) throw new IllegalArgumentException("Duplicate logout redirect"); + callback=java.net.URLDecoder.decode(pair[1],java.nio.charset.StandardCharsets.UTF_8); + } + } + if (!callback.isEmpty()) { + if (result.client()==null || result.client().post_logout_redirect_uris()==null + || !result.client().post_logout_redirect_uris().contains(callback)) throw new IllegalArgumentException("Unregistered logout redirect"); + var uri = URI.create(callback); + if (uri.getHost()==null || uri.getUserInfo()!=null || uri.getFragment()!=null + || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()) + && java.util.Set.of("localhost","127.0.0.1").contains(uri.getHost()))) + throw new IllegalArgumentException("Invalid logout callback"); + } + return new Request(challenge,result.subject(),result.sid(),callback); + } catch (RuntimeException ex) { throw new IllegalArgumentException("Logout request unavailable"); } + } + @Override public String accept(String challenge) { + validate(challenge); + try { + var result = Objects.requireNonNull(http.put().uri(b -> b.path("/admin/oauth2/auth/requests/logout/accept") + .queryParam("logout_challenge","{challenge}").build(challenge)).retrieve().body(HydraAdminClient.Redirect.class)); + validateTarget(result.redirect_to()); return result.redirect_to(); + } catch (RuntimeException ex) { throw new IllegalArgumentException("Logout acceptance unavailable"); } + } + @Override public String startUrl() { return origin.resolve("/oauth2/sessions/logout").toString(); } + private void validateTarget(String target) { + var uri = URI.create(target); + if (!origin.getScheme().equals(uri.getScheme()) || !origin.getRawAuthority().equals(uri.getRawAuthority()) + || uri.getUserInfo()!=null || uri.getFragment()!=null || !"/oauth2/sessions/logout".equals(uri.getRawPath())) + throw new IllegalArgumentException("Invalid logout redirect"); + } + private void validate(String challenge) { + if (challenge == null || challenge.isBlank() || challenge.length()>8192) throw new IllegalArgumentException("Invalid challenge"); + } +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraProperties.java b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraProperties.java new file mode 100644 index 0000000..9fe3f26 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraProperties.java @@ -0,0 +1,26 @@ +package top.ddupan.iam.login.authorization.infrastructure.hydra; + +import java.net.URI; +import java.util.List; +import java.util.Set; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("iam.hydra") +public record HydraProperties(boolean enabled, URI adminUrl, URI publicUrl, Set clients, + List subjects) { + public record SubjectBinding(String authority, String directoryId, String subject) { } + public HydraProperties { + if (enabled) { + validateUrl(adminUrl, true); validateUrl(publicUrl, false); + if (subjects == null) throw new IllegalArgumentException("Hydra policy is required"); + } + } + private static void validateUrl(URI uri, boolean administrative) { + if (uri == null || uri.getHost() == null || uri.getUserInfo() != null || uri.getQuery() != null + || uri.getFragment() != null || !(uri.getPath().isEmpty() || uri.getPath().equals("/")) + || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()) + && (administrative || uri.getHost().equals("localhost") || uri.getHost().equals("127.0.0.1")))) { + throw new IllegalArgumentException("Invalid Hydra origin (public HTTP is restricted to loopback)"); + } + } +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraBrowserRequests.java b/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraBrowserRequests.java new file mode 100644 index 0000000..8a37cac --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraBrowserRequests.java @@ -0,0 +1,62 @@ +package top.ddupan.iam.login.authorization.interfaces.web; + +import java.time.Instant; +import java.time.Duration; +import java.util.UUID; +import jakarta.servlet.http.HttpServletRequest; +import top.ddupan.iam.login.authorization.domain.AuthorizationRequest; + +/** Short-lived issuer request binding only. Authentication state remains in Spring Security. */ +public final class HydraBrowserRequests { + private static final String PENDING = HydraBrowserRequests.class.getName() + ".pending"; + private static final String ACCEPTED = HydraBrowserRequests.class.getName() + ".accepted"; + public record Intent(AuthorizationRequest request, String binding, Instant expiresAt) { } + public record Accepted(Intent intent, String directoryName, String subject) { } + private HydraBrowserRequests() { } + + public static void start(HttpServletRequest request, AuthorizationRequest authorization) { + var session = request.getSession(); + synchronized (session) { + session.removeAttribute(ACCEPTED); + session.setAttribute(PENDING, new Intent(authorization, UUID.randomUUID().toString(), + Instant.now().plus(Duration.ofMinutes(10)))); + } + } + public static boolean pending(HttpServletRequest request) { + var session = request.getSession(false); + return session != null && session.getAttribute(PENDING) instanceof Intent; + } + public static Intent intent(HttpServletRequest request) { + var session = request.getSession(false); + var intent = session == null ? null : (Intent) session.getAttribute(PENDING); + if (intent == null || !Instant.now().isBefore(intent.expiresAt())) { + throw new IllegalArgumentException("No pending issuer request"); + } + return intent; + } + public static Intent consume(HttpServletRequest request, String binding) { + var session = request.getSession(false); + if (session == null) throw new IllegalArgumentException("No browser session"); + synchronized (session) { + var intent = intent(request); + if (!intent.binding().equals(binding)) throw new IllegalArgumentException("Browser binding mismatch"); + session.removeAttribute(PENDING); + return intent; + } + } + public static void accepted(HttpServletRequest request, Accepted accepted) { + request.getSession().setAttribute(ACCEPTED, accepted); + } + public static Accepted consumeAccepted(HttpServletRequest request) { + var session = request.getSession(false); + if (session == null) throw new IllegalArgumentException("No browser session"); + synchronized (session) { + var accepted = (Accepted) session.getAttribute(ACCEPTED); + session.removeAttribute(ACCEPTED); + if (accepted == null || !Instant.now().isBefore(accepted.intent().expiresAt())) { + throw new IllegalArgumentException("No accepted issuer request"); + } + return accepted; + } + } +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraController.java b/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraController.java new file mode 100644 index 0000000..3c011f4 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraController.java @@ -0,0 +1,94 @@ +package top.ddupan.iam.login.authorization.interfaces.web; + +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.authority.FactorGrantedAuthority; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.*; +import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal; +import top.ddupan.iam.login.authentication.interfaces.web.PageRenderer; +import top.ddupan.iam.login.authorization.application.AuthorizeApplication; + +@RestController +@ConditionalOnProperty(prefix = "iam.hydra", name = "enabled", havingValue = "true") +public class HydraController { + private final AuthorizeApplication policy; + private final PageRenderer renderer; + public HydraController(AuthorizeApplication policy, PageRenderer renderer) { + this.policy = policy; this.renderer = renderer; + } + @GetMapping("/oauth2/start") + ResponseEntity start(@RequestParam("login_challenge") String challenge, + HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + var login = policy.start(challenge); + var query = org.springframework.web.util.UriComponentsBuilder.fromUriString(login.requestUrl()).build().getQueryParams(); + if (query.containsKey("prompt") || query.containsKey("max_age")) { + new SecurityContextLogoutHandler().logout(request, response, authentication); + } + HydraBrowserRequests.start(request, login); + return redirect("/oauth2/login"); + } + @GetMapping("/oauth2/login") + ResponseEntity login(HttpServletRequest request, @AuthenticationPrincipal DirectoryPrincipal principal, + CsrfToken csrf) { + var intent = HydraBrowserRequests.intent(request); + policy.subject(principal.user()); + var page = renderer.render(Map.of("step", "authorize", "name", principal.user().displayName(), "error", "", + "action", "/oauth2/login", "csrf", Map.of("name", csrf.getParameterName(), "value", csrf.getToken(), + "headerName", csrf.getHeaderName()), "binding", intent.binding(), + "client", intent.request().clientId(), "scopes", intent.request().scopes())); + var authorization = URI.create(intent.request().requestUrl()); + var rawCallback = org.springframework.web.util.UriComponentsBuilder.fromUri(authorization).build() + .getQueryParams().getFirst("redirect_uri"); + if (rawCallback == null) throw new IllegalArgumentException("Explicit callback is required"); + var callback = URI.create(java.net.URLDecoder.decode(rawCallback, java.nio.charset.StandardCharsets.UTF_8)); + var targets = formOrigin(authorization) + " " + formOrigin(callback); + return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy", + PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'", "form-action 'self' " + targets)) + .body(page.getBody()); + } + @PostMapping("/oauth2/login") + ResponseEntity accept(@RequestParam String binding, HttpServletRequest request, + @AuthenticationPrincipal DirectoryPrincipal principal, Authentication authentication) { + var intent = HydraBrowserRequests.consume(request, binding); + var authenticatedAt = authentication.getAuthorities().stream() + .filter(FactorGrantedAuthority.class::isInstance).map(FactorGrantedAuthority.class::cast) + .map(FactorGrantedAuthority::getIssuedAt).max(Instant::compareTo).orElseThrow(); + var accepted = policy.login(intent.request(), intent.binding(), principal.user(), authenticatedAt); + HydraBrowserRequests.accepted(request, new HydraBrowserRequests.Accepted(intent, principal.getName(), accepted.subject())); + return redirect(accepted.redirect()); + } + @GetMapping("/oauth2/consent") + ResponseEntity consent(@RequestParam("consent_challenge") String challenge, HttpServletRequest request, + @AuthenticationPrincipal DirectoryPrincipal principal) { + var accepted = HydraBrowserRequests.consumeAccepted(request); + if (!principal.getName().equals(accepted.directoryName())) throw new IllegalArgumentException("Browser identity changed"); + return redirect(policy.consent(accepted.intent().request(), accepted.intent().binding(), accepted.subject(), + principal.user(), challenge)); + } + @ExceptionHandler(IllegalArgumentException.class) + ResponseEntity invalid() { + return ResponseEntity.badRequest().header("Cache-Control", "no-store") + .body("授权请求无效或已过期,请从应用重新开始。"); + } + private static String formOrigin(URI uri) { + if (uri.getHost() == null || uri.getUserInfo() != null || uri.getFragment() != null + || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()) + && Set.of("localhost", "127.0.0.1").contains(uri.getHost()))) { + throw new IllegalArgumentException("Unexpected form destination"); + } + return uri.getScheme() + "://" + uri.getRawAuthority(); + } + private static ResponseEntity redirect(String target) { + return ResponseEntity.status(303).header("Cache-Control", "no-store").location(URI.create(target)).build(); + } +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraLogoutController.java b/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraLogoutController.java new file mode 100644 index 0000000..ab50f7e --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraLogoutController.java @@ -0,0 +1,69 @@ +package top.ddupan.iam.login.authorization.interfaces.web; + +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.*; +import top.ddupan.iam.login.authentication.interfaces.web.PageRenderer; +import top.ddupan.iam.login.authorization.application.port.LogoutGateway; +import top.ddupan.iam.login.authorization.application.AuthorizationPolicy; +import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal; + +@RestController +@ConditionalOnProperty(prefix="iam.hydra",name="enabled",havingValue="true") +public class HydraLogoutController { + private static final String KEY = HydraLogoutController.class.getName(); + private record Pending(LogoutGateway.Request request,String binding,Instant expires) { } + private final LogoutGateway hydra; + private final PageRenderer renderer; + private final AuthorizationPolicy policy; + public HydraLogoutController(LogoutGateway hydra,PageRenderer renderer,AuthorizationPolicy policy) { + this.hydra=hydra; this.renderer=renderer; this.policy=policy; + } + @GetMapping("/oauth2/logout") ResponseEntity page(@RequestParam("logout_challenge") String challenge, + HttpServletRequest request,Authentication auth,CsrfToken csrf) { + var logout = hydra.request(challenge); + if (auth != null && auth.getPrincipal() instanceof DirectoryPrincipal principal && logout.subject()!=null + && !logout.subject().isBlank() && !policy.subject(principal.user()).equals(logout.subject())) + throw new IllegalArgumentException("Different logout subject"); + var pending = new Pending(logout,UUID.randomUUID().toString(),Instant.now().plusSeconds(300)); + request.getSession().setAttribute(KEY,pending); + var page = renderer.render(Map.of("step","logout","name","","error","","action","/oauth2/logout", + "binding",pending.binding(),"csrf",Map.of("name",csrf.getParameterName(),"value",csrf.getToken(),"headerName",csrf.getHeaderName()))); + String targets=origin(hydra.startUrl()); + if (!logout.postLogoutRedirectUri().isEmpty()) targets += " " + origin(logout.postLogoutRedirectUri()); + return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy", + PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'", "form-action 'self' " + targets)) + .body(page.getBody()); + } + @PostMapping("/oauth2/logout") ResponseEntity logout(@RequestParam String binding,HttpServletRequest request, + HttpServletResponse response,Authentication authentication) { + var session=request.getSession(false); + if (session==null) throw new IllegalArgumentException("No logout session"); + Pending pending; + synchronized(session) { + pending=(Pending)session.getAttribute(KEY); + if (pending==null || !pending.binding().equals(binding) || !Instant.now().isBefore(pending.expires())) + throw new IllegalArgumentException("Invalid logout binding"); + session.removeAttribute(KEY); + } + var current=hydra.request(pending.request().challenge()); + if (!Objects.equals(current,pending.request())) throw new IllegalArgumentException("Logout request changed"); + var target=hydra.accept(current.challenge()); + new SecurityContextLogoutHandler().logout(request,response,authentication); + return ResponseEntity.status(303).header("Cache-Control","no-store").location(URI.create(target)).build(); + } + private static String origin(String url) { var uri=URI.create(url); return uri.getScheme()+"://"+uri.getRawAuthority(); } + @ExceptionHandler(IllegalArgumentException.class) ResponseEntity invalid() { + return ResponseEntity.badRequest().header("Cache-Control","no-store").body("注销请求无效或已过期,请重新发起。"); + } +} diff --git a/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistry.java b/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistry.java new file mode 100644 index 0000000..3035cd9 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistry.java @@ -0,0 +1,16 @@ +package top.ddupan.iam.login.clients.application; + +import java.util.List; +import top.ddupan.iam.login.clients.domain.OidcClient; + +/** Hydra is the sole persistence authority. Secrets exist only in create/rotate responses. */ +public interface ClientRegistry { + record Created(OidcClient client, String secret) { + @Override public String toString() { return "Created[client=" + client.id() + ", secret=REDACTED]"; } + } + List list(int page, int size); + OidcClient get(String id); + Created create(OidcClient client); + OidcClient update(OidcClient client); + void delete(String id); +} diff --git a/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistryException.java b/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistryException.java new file mode 100644 index 0000000..248ca5f --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistryException.java @@ -0,0 +1,8 @@ +package top.ddupan.iam.login.clients.application; + +public final class ClientRegistryException extends RuntimeException { + public enum Kind { NOT_FOUND, CONFLICT, UNAVAILABLE } + private final Kind kind; + public ClientRegistryException(Kind kind) { super("Client registry " + kind); this.kind = kind; } + public Kind kind() { return kind; } +} diff --git a/src/main/java/top/ddupan/iam/login/clients/domain/OidcClient.java b/src/main/java/top/ddupan/iam/login/clients/domain/OidcClient.java new file mode 100644 index 0000000..a873560 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/clients/domain/OidcClient.java @@ -0,0 +1,38 @@ +package top.ddupan.iam.login.clients.domain; + +import java.net.URI; +import java.util.List; +import java.util.Set; + +/** First-party confidential authorization-code client. No caller-controlled grants or metadata. */ +public record OidcClient(String id, String name, List redirectUris, Set scopes, + List postLogoutRedirectUris, String backchannelLogoutUri, String frontchannelLogoutUri, + boolean loginEnabled) { + public OidcClient { + requireId(id); + if (name == null || name.isBlank() || name.length() > 200) throw new IllegalArgumentException("Invalid name"); + if (redirectUris == null || redirectUris.isEmpty() || redirectUris.size() > 20) throw new IllegalArgumentException("Invalid callbacks"); + redirectUris = List.copyOf(redirectUris); + if (scopes == null || !scopes.contains("openid") || !Set.of("openid", "profile", "email", "groups").containsAll(scopes)) + throw new IllegalArgumentException("Invalid scopes"); + scopes = Set.copyOf(scopes); + postLogoutRedirectUris = postLogoutRedirectUris == null ? List.of() : List.copyOf(postLogoutRedirectUris); + if (postLogoutRedirectUris.size() > 20) throw new IllegalArgumentException("Too many logout redirects"); + redirectUris.forEach(OidcClient::requireUri); postLogoutRedirectUris.forEach(OidcClient::requireUri); + backchannelLogoutUri = backchannelLogoutUri == null ? "" : backchannelLogoutUri; + frontchannelLogoutUri = frontchannelLogoutUri == null ? "" : frontchannelLogoutUri; + if (!backchannelLogoutUri.isEmpty()) requireUri(backchannelLogoutUri); + if (!frontchannelLogoutUri.isEmpty()) requireUri(frontchannelLogoutUri); + } + public static void requireId(String id) { + if (id == null || !id.matches("[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}")) throw new IllegalArgumentException("Invalid client ID"); + } + private static void requireUri(String value) { + if (value == null || value.length() > 2048 || value.contains("*")) throw new IllegalArgumentException("Invalid URI"); + var uri = URI.create(value); + if (uri.getHost() == null || uri.getUserInfo() != null || uri.getFragment() != null + || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()) + && Set.of("localhost", "127.0.0.1", "[::1]").contains(uri.getHost()))) + throw new IllegalArgumentException("HTTPS or loopback callback required"); + } +} diff --git a/src/main/java/top/ddupan/iam/login/clients/infrastructure/HydraClientRegistry.java b/src/main/java/top/ddupan/iam/login/clients/infrastructure/HydraClientRegistry.java new file mode 100644 index 0000000..bb44170 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/clients/infrastructure/HydraClientRegistry.java @@ -0,0 +1,93 @@ +package top.ddupan.iam.login.clients.infrastructure; + +import java.util.*; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.client.RestClient; +import top.ddupan.iam.login.clients.application.*; +import top.ddupan.iam.login.clients.domain.OidcClient; +import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties; +import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraAdminClient; + +public final class HydraClientRegistry implements ClientRegistry { + public static final String ENABLED = "iam_login_enabled"; + private static final ParameterizedTypeReference> OBJECT = new ParameterizedTypeReference<>() {}; + private static final ParameterizedTypeReference>> ARRAY = new ParameterizedTypeReference<>() {}; + private final RestClient http; + public HydraClientRegistry(HydraProperties properties) { + http = HydraAdminClient.restClient(properties).mutate() + .defaultStatusHandler(status -> !status.is2xxSuccessful(), (request, response) -> { + throw new ClientRegistryException(switch (response.getStatusCode().value()) { + case 404 -> ClientRegistryException.Kind.NOT_FOUND; + case 400, 409 -> ClientRegistryException.Kind.CONFLICT; + default -> ClientRegistryException.Kind.UNAVAILABLE; + }); + }).build(); + } + private T call(java.util.function.Supplier operation) { + try { return operation.get(); } + catch (ClientRegistryException ex) { throw ex; } + catch (RuntimeException ex) { throw new ClientRegistryException(ClientRegistryException.Kind.UNAVAILABLE); } + } + @Override public List list(int page, int size) { + if (page < 0 || size < 1 || size > 100) throw new IllegalArgumentException("Invalid pagination"); + return call(() -> Objects.requireNonNull(http.get().uri(b -> b.path("/admin/clients") + .queryParam("page", page).queryParam("page_size", size).build()).retrieve().body(ARRAY)) + .stream().filter(this::supported).map(this::view).toList()); + } + private Map read(String id) { + OidcClient.requireId(id); + return Objects.requireNonNull(http.get().uri("/admin/clients/{id}", id).retrieve().body(OBJECT)); + } + @Override public OidcClient get(String id) { return call(() -> view(read(id))); } + @Override public Created create(OidcClient client) { + return call(() -> { + var result = Objects.requireNonNull(http.post().uri("/admin/clients").body(payload(client)) + .retrieve().body(OBJECT)); + return new Created(view(result), Objects.toString(result.get("client_secret"), "")); + }); + } + @Override public OidcClient update(OidcClient client) { + return call(() -> { + var previous = read(client.id()); + if (!supported(previous)) throw new ClientRegistryException(ClientRegistryException.Kind.CONFLICT); + // Preserve issuer-owned fields and metadata, but never send back a stored secret/hash. + var update = new LinkedHashMap<>(previous); update.remove("client_secret"); + var metadata = new LinkedHashMap(); + if (previous.get("metadata") instanceof Map map) map.forEach((k,v) -> metadata.put(k.toString(),v)); + metadata.put(ENABLED, client.loginEnabled()); + update.putAll(payload(client)); update.put("metadata", metadata); + return view(Objects.requireNonNull(http.put().uri("/admin/clients/{id}", client.id()) + .body(update).retrieve().body(OBJECT))); + }); + } + @Override public void delete(String id) { + OidcClient.requireId(id); + call(() -> { http.delete().uri("/admin/clients/{id}",id).retrieve().toBodilessEntity(); return null; }); + } + private boolean supported(Map data) { + return List.of("authorization_code").equals(data.get("grant_types")) + && "client_secret_basic".equals(data.get("token_endpoint_auth_method")); + } + private Map payload(OidcClient c) { + var map = new LinkedHashMap(); + map.put("client_id",c.id()); map.put("client_name",c.name()); map.put("redirect_uris",c.redirectUris()); + map.put("scope",String.join(" ",new TreeSet<>(c.scopes()))); + map.put("grant_types",List.of("authorization_code")); map.put("response_types",List.of("code")); + map.put("token_endpoint_auth_method","client_secret_basic"); map.put("subject_type","public"); + map.put("post_logout_redirect_uris",c.postLogoutRedirectUris()); + map.put("backchannel_logout_uri",c.backchannelLogoutUri()); + map.put("backchannel_logout_session_required",true); + map.put("frontchannel_logout_uri",c.frontchannelLogoutUri()); + map.put("frontchannel_logout_session_required",true); + map.put("metadata",Map.of(ENABLED,c.loginEnabled())); return map; + } + @SuppressWarnings("unchecked") private OidcClient view(Map m) { + if (!supported(m)) throw new ClientRegistryException(ClientRegistryException.Kind.CONFLICT); + String id = (String)m.get("client_id"); + String name = Objects.toString(m.get("client_name"),""); + return new OidcClient(id, name.isBlank() ? id : name, + (List)m.get("redirect_uris"),new HashSet<>(Arrays.asList(Objects.toString(m.get("scope"),"").split(" +"))), + (List)m.get("post_logout_redirect_uris"),(String)m.get("backchannel_logout_uri"), + (String)m.get("frontchannel_logout_uri"),m.get("metadata") instanceof Map meta && Boolean.TRUE.equals(meta.get(ENABLED))); + } +} diff --git a/src/main/java/top/ddupan/iam/login/clients/interfaces/web/ClientsController.java b/src/main/java/top/ddupan/iam/login/clients/interfaces/web/ClientsController.java new file mode 100644 index 0000000..53f1ced --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/clients/interfaces/web/ClientsController.java @@ -0,0 +1,45 @@ +package top.ddupan.iam.login.clients.interfaces.web; + +import java.util.Map; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.*; +import top.ddupan.iam.login.clients.application.*; +import top.ddupan.iam.login.clients.domain.OidcClient; + +@RestController +@ConditionalOnProperty(prefix="iam.hydra", name="enabled", havingValue="true") +public class ClientsController { + private final ClientRegistry registry; + private static final org.slf4j.Logger AUDIT = org.slf4j.LoggerFactory.getLogger("iam.audit.clients"); + public ClientsController(ClientRegistry registry) { this.registry = registry; } + @GetMapping("/api/iam/session") Object session(CsrfToken csrf) { + return Map.of("csrf",Map.of("headerName",csrf.getHeaderName(),"token",csrf.getToken())); + } + @GetMapping("/api/iam/clients") Object list(@RequestParam(defaultValue="0") int page, + @RequestParam(defaultValue="20") int size) { return registry.list(page,size); } + @GetMapping("/api/iam/clients/{id}") OidcClient get(@PathVariable String id) { return registry.get(id); } + @PostMapping("/api/iam/clients") ResponseEntity create(@RequestBody OidcClient input, Authentication auth) { + var created = registry.create(input); audit("create",input.id(),auth); + return ResponseEntity.status(201).header("Cache-Control","no-store").body(created); + } + @PutMapping("/api/iam/clients/{id}") OidcClient update(@PathVariable String id, @RequestBody OidcClient input, Authentication auth) { + if (!id.equals(input.id())) throw new IllegalArgumentException("Client ID mismatch"); + var updated = registry.update(input); audit("update",id,auth); return updated; + } + @DeleteMapping("/api/iam/clients/{id}") ResponseEntity delete(@PathVariable String id, Authentication auth) { + registry.delete(id); audit("delete",id,auth); return ResponseEntity.noContent().build(); + } + private void audit(String action,String id,Authentication auth) { + AUDIT.info("client action={} client={} actor={}",action,id,auth.getName()); + } + @ExceptionHandler(IllegalArgumentException.class) ResponseEntity invalid() { + return ResponseEntity.badRequest().body(Map.of("error","invalid_client_request")); + } + @ExceptionHandler(ClientRegistryException.class) ResponseEntity unavailable(ClientRegistryException ex) { + int status = switch (ex.kind()) { case NOT_FOUND -> 404; case CONFLICT -> 409; case UNAVAILABLE -> 502; }; + return ResponseEntity.status(status).body(Map.of("error",ex.kind().name().toLowerCase(java.util.Locale.ROOT))); + } +} diff --git a/src/main/java/top/ddupan/iam/login/configuration/ClientManagementConfiguration.java b/src/main/java/top/ddupan/iam/login/configuration/ClientManagementConfiguration.java new file mode 100644 index 0000000..daa3a0d --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/configuration/ClientManagementConfiguration.java @@ -0,0 +1,51 @@ +package top.ddupan.iam.login.configuration; + +import java.util.Set; +import javax.naming.ldap.LdapName; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal; +import top.ddupan.iam.login.authentication.infrastructure.webauthn.MfaPolicy; +import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties; +import top.ddupan.iam.login.clients.application.ClientRegistry; +import top.ddupan.iam.login.clients.infrastructure.HydraClientRegistry; +import top.ddupan.iam.login.clients.domain.OidcClient; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(prefix="iam.hydra",name="enabled",havingValue="true") +@EnableConfigurationProperties(ClientManagementConfiguration.Access.class) +@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({OidcClient.class,ClientRegistry.Created.class}) +class ClientManagementConfiguration { + @ConfigurationProperties("iam.clients") + record Access(Set adminGroupDns) { + Access { adminGroupDns = adminGroupDns == null ? Set.of() : Set.copyOf(adminGroupDns); adminGroupDns.forEach(Access::dn); } + static LdapName dn(String value) { + try { return new LdapName(value); } catch (javax.naming.InvalidNameException ex) { throw new IllegalArgumentException("Invalid administrator group DN"); } + } + boolean allowed(DirectoryPrincipal principal) { + return principal.user().memberships().stream().anyMatch(group -> + adminGroupDns.stream().anyMatch(admin -> dn(admin).equals(dn(group.externalId())))); + } + } + @Bean ClientRegistry clientRegistry(HydraProperties properties) { return new HydraClientRegistry(properties); } + @Bean @Order(2) SecurityFilterChain clientManagement(HttpSecurity http,MfaPolicy mfa,Access access) throws Exception { + return http.securityMatcher("/api/iam/**").redirectToHttps(org.springframework.security.config.Customizer.withDefaults()) + .authorizeHttpRequests(auth -> auth.anyRequest().access((authentication,request) -> { + var current = authentication.get(); + var factors = mfa.complete.authorize(authentication,request); + return new AuthorizationDecision(current != null && current.getPrincipal() instanceof DirectoryPrincipal principal + && factors != null && factors.isGranted() && access.allowed(principal)); + })) + .exceptionHandling(ex -> ex.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .requestCache(cache -> cache.disable()).logout(logout -> logout.disable()).build(); + } +} diff --git a/src/main/java/top/ddupan/iam/login/configuration/HydraConfiguration.java b/src/main/java/top/ddupan/iam/login/configuration/HydraConfiguration.java new file mode 100644 index 0000000..33ed601 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/configuration/HydraConfiguration.java @@ -0,0 +1,34 @@ +package top.ddupan.iam.login.configuration; + +import java.util.stream.Collectors; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import top.ddupan.iam.login.authentication.domain.User; +import top.ddupan.iam.login.authentication.infrastructure.webauthn.MfaPolicy; +import top.ddupan.iam.login.authorization.application.AuthorizeApplication; +import top.ddupan.iam.login.authorization.application.AuthorizationPolicy; +import top.ddupan.iam.login.authorization.application.port.HydraGateway; +import top.ddupan.iam.login.authorization.infrastructure.hydra.*; + +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(prefix = "iam.hydra", name = "enabled", havingValue = "true") +@EnableConfigurationProperties(HydraProperties.class) +@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({HydraAdminClient.Request.class, + HydraLogoutClient.Response.class, HydraLogoutClient.LogoutClient.class, HydraAdminClient.Client.class, HydraAdminClient.Context.class, HydraAdminClient.Redirect.class}) +class HydraConfiguration { + @Bean top.ddupan.iam.login.authorization.application.port.LogoutGateway logoutGateway(HydraProperties properties) { + return new HydraLogoutClient(properties); + } + @Bean HydraGateway hydraGateway(HydraProperties properties, MfaPolicy requiredMfa) { + return new HydraAdminClient(properties); + } + @Bean AuthorizationPolicy authorizationPolicy(HydraProperties properties) { + return new AuthorizationPolicy(properties.clients(), properties.subjects().stream().collect(Collectors.toMap( + binding -> new User.UserId(binding.authority(), binding.directoryId()), HydraProperties.SubjectBinding::subject))); + } + @Bean AuthorizeApplication authorizeApplication(AuthorizationPolicy policy, HydraGateway hydra) { + return new AuthorizeApplication(policy, hydra); + } +} 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 09a04bc..f5d06a9 100644 --- a/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java +++ b/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java @@ -40,47 +40,56 @@ class SecurityConfiguration { } @Bean - @Order(2) + @Order(3) @ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true") SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords, - ObjectProvider webAuthn) throws Exception { + ObjectProvider webAuthn, + ObjectProvider logoutGateway) throws Exception { var passwordFactor = AuthorizationManagerFactories.multiFactor() .requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10))) .build(); var mfa = webAuthn.getIfAvailable(); - http.securityMatcher("/signin", "/signin/**", "/assets/**", "/webauthn/**", "/login/webauthn") + http.securityMatcher("/signin", "/signin/**", "/assets/**", "/webauthn/**", "/login/webauthn", "/oauth2/**") .redirectToHttps(Customizer.withDefaults()) .authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords))) .authorizeHttpRequests(auth -> { if (mfa != null) { - auth.requestMatchers("/signin/complete").access(mfa.policy.complete); + auth.requestMatchers("/signin/complete", "/oauth2/login", "/oauth2/consent").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() + auth.requestMatchers("/error", "/signin", "/signin/password", "/assets/**", "/oauth2/start", "/oauth2/logout").permitAll() .requestMatchers("/signin/mfa").access(passwordFactor.authenticated()) // 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")) + .logout(logout -> logout.logoutRequestMatcher(request -> PathPatternRequestMatcher.withDefaults().matcher(org.springframework.http.HttpMethod.POST,"/signin/restart").matches(request) + || PathPatternRequestMatcher.withDefaults().matcher(org.springframework.http.HttpMethod.POST,"/signin/logout").matches(request)) + .logoutSuccessHandler((request,response,authentication) -> { + var gateway = logoutGateway.getIfAvailable(); + response.setStatus(303); + response.setHeader("Location",gateway!=null && PathPatternRequestMatcher.withDefaults().matcher("/signin/logout").matches(request) + ? gateway.startUrl() : "/signin"); + })) .exceptionHandling(exceptions -> exceptions .defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/signin"), PathPatternRequestMatcher.withDefaults().matcher("/signin/**")) + .defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/signin"), + PathPatternRequestMatcher.withDefaults().matcher("/oauth2/**")) .defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), org.springframework.security.web.util.matcher.AnyRequestMatcher.INSTANCE)) .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'"))); + top.ddupan.iam.login.authentication.interfaces.web.PageRenderer.CONTENT_SECURITY_POLICY))); if (mfa != null) mfa.configure(http); var chain = http.build(); return mfa == null ? chain : mfa.finish(http, chain); } @Bean - @Order(3) + @Order(4) SecurityFilterChain fallback(HttpSecurity http) throws Exception { return http.authorizeHttpRequests(auth -> auth .requestMatchers("/error").permitAll() diff --git a/src/test/java/top/ddupan/iam/login/WebAuthnBrowserFixture.java b/src/test/java/top/ddupan/iam/login/WebAuthnBrowserFixture.java index 2207586..9b05733 100644 --- a/src/test/java/top/ddupan/iam/login/WebAuthnBrowserFixture.java +++ b/src/test/java/top/ddupan/iam/login/WebAuthnBrowserFixture.java @@ -15,7 +15,7 @@ public final class WebAuthnBrowserFixture { .asCompatibleSubstituteFor("postgres")); database.start(); var application = new SpringApplication(IamLoginApplication.class); - application.setDefaultProperties(Map.ofEntries( + var properties = new java.util.HashMap(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"), @@ -28,9 +28,21 @@ public final class WebAuthnBrowserFixture { 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 hydra = Boolean.getBoolean("iam.fixture.hydra") ? new top.ddupan.iam.login.support.HydraFixture() : null; + if (hydra != null) { + properties.put("iam.hydra.enabled", "true"); + properties.put("iam.hydra.admin-url", "http://127.0.0.1:14445"); + properties.put("iam.hydra.public-url", "http://localhost:14444"); + properties.put("iam.clients.admin-group-dns[0]", "CN=gitea-admins,dc=example,dc=test"); + properties.put("iam.hydra.subjects[0].authority", "example.test"); + properties.put("iam.hydra.subjects[0].directory-id", "00112233-4455-6677-8899-aabbccddeeff"); + properties.put("iam.hydra.subjects[0].subject", "human:fixture-existing-oidc-subject"); + } + application.setDefaultProperties(properties); var context = application.run(args); Runtime.getRuntime().addShutdownHook(new Thread(() -> { context.close(); directory.close(); database.stop(); + if (hydra != null) hydra.close(); })); } } 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 index 8905cd5..c98957f 100644 --- 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 @@ -25,6 +25,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder 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.clients.admin-group-dns[0]=CN=gitea-admins,dc=example,dc=test", "iam.hydra.enabled=true", "iam.hydra.admin-url=http://127.0.0.1:14445", "iam.hydra.public-url=http://localhost:14444", + "iam.hydra.clients[0]=gitea-fixture", "iam.hydra.subjects[0].authority=example.test", + "iam.hydra.subjects[0].directory-id=00112233-4455-6677-8899-aabbccddeeff", + "iam.hydra.subjects[0].subject=human:existing-subject", "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 @@ -46,6 +50,58 @@ class WebAuthnIntegrationTests { @AfterAll static void close() { Fixtures.DIRECTORY.close(); Fixtures.DATABASE.stop(); } @Autowired MockMvc mvc; @Autowired JdbcOperations jdbc; + @org.springframework.test.context.bean.override.mockito.MockitoBean + top.ddupan.iam.login.authorization.application.port.HydraGateway hydra; + + + @org.springframework.test.context.bean.override.mockito.MockitoBean + top.ddupan.iam.login.clients.application.ClientRegistry clients; + @org.springframework.test.context.bean.override.mockito.MockitoBean + top.ddupan.iam.login.authorization.application.port.LogoutGateway logout; + + @Test + void clientAdministrationRequiresMfaAdminGroupAndCsrf() throws Exception { + mvc.perform(get("/api/iam/clients").with(https())).andExpect(status().isUnauthorized()); + var session=login(); + mvc.perform(get("/api/iam/clients").session(session).with(https())).andExpect(status().isForbidden()); + secondFactor(session); + org.mockito.Mockito.when(clients.list(0,20)).thenReturn(List.of()); + mvc.perform(get("/api/iam/clients").session(session).with(https())).andExpect(status().isOk()); + mvc.perform(get("/api/iam/session").session(session).with(https())).andExpect(jsonPath("csrf.token").isNotEmpty()); + mvc.perform(delete("/api/iam/clients/example").session(session).with(https())).andExpect(status().isForbidden()); + mvc.perform(delete("/api/iam/clients/example").session(session).with(https()).with(csrf())).andExpect(status().isNoContent()); + org.mockito.Mockito.verify(clients).delete("example"); + var security=(SecurityContext)session.getAttribute("SPRING_SECURITY_CONTEXT"); + var old=security.getAuthentication(); + var principal=(top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal)old.getPrincipal(); + var user=principal.user(); + var noGroups=new top.ddupan.iam.login.authentication.domain.User(user.id(),user.username(),user.displayName(),user.email(),List.of()); + security.setAuthentication(UsernamePasswordAuthenticationToken.authenticated( + new top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal(noGroups),null,old.getAuthorities())); + mvc.perform(get("/api/iam/clients").session(session).with(https())).andExpect(status().isForbidden()); + mvc.perform(get("/admin/clients").session(session).with(https())).andExpect(status().isForbidden()); + } + + @Test + void logoutRequiresCsrfBrowserBindingAndInvalidatesLocalSession() throws Exception { + var session=login(); secondFactor(session); + var data=new top.ddupan.iam.login.authorization.application.port.LogoutGateway.Request( + "logout-challenge","human:existing-subject","sid",""); + org.mockito.Mockito.when(logout.request("logout-challenge")).thenReturn(data); + org.mockito.Mockito.when(logout.startUrl()).thenReturn("http://localhost:14444/oauth2/sessions/logout"); + org.mockito.Mockito.when(logout.accept("logout-challenge")).thenReturn("http://localhost:14444/oauth2/sessions/logout?logout_verifier=fixture"); + var html=mvc.perform(get("/oauth2/logout").session(session).with(https()).param("logout_challenge","logout-challenge")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + var match=java.util.regex.Pattern.compile("\"binding\":\"([^\"]+)\"").matcher(html); + assertThat(match.find()).isTrue(); var binding=match.group(1); + mvc.perform(post("/oauth2/logout").session(session).with(https()).param("binding",binding)).andExpect(status().isForbidden()); + mvc.perform(post("/oauth2/logout").session(session).with(https()).with(csrf()).param("binding","other")) + .andExpect(status().isBadRequest()); + mvc.perform(post("/oauth2/logout").session(session).with(https()).with(csrf()).param("binding",binding)) + .andExpect(status().isSeeOther()); + assertThat(session.isInvalid()).isTrue(); + org.mockito.Mockito.verify(logout).accept("logout-challenge"); + } @Test void passwordOnlyCanEnrollButCannotCompleteOrDelete() throws Exception { @@ -77,6 +133,64 @@ class WebAuthnIntegrationTests { .andExpect(status().is3xxRedirection()); } + private static final String AUTH_URL = "http://localhost:14444/oauth2/auth?client_id=gitea-fixture&response_type=code"; + private top.ddupan.iam.login.authorization.domain.AuthorizationRequest hydraRequest(String challenge, String subject, + String login, String binding) { + return new top.ddupan.iam.login.authorization.domain.AuthorizationRequest(challenge, "gitea-fixture", + List.of("openid", "profile", "groups"), List.of(), subject, + login == null ? null : top.ddupan.iam.login.authorization.domain.AuthorizationRequest.digest(login), binding, AUTH_URL, false); + } + private void secondFactor(MockHttpSession session) { + var context = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT"); + var previous = context.getAuthentication(); + var factors = new java.util.ArrayList(previous.getAuthorities()); + factors.add(FactorGrantedAuthority.withAuthority("FACTOR_WEBAUTHN").issuedAt(Instant.now()).build()); + context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(previous.getPrincipal(), null, factors)); + } + + @Test + void hydraRequiresMfaCsrfBrowserBindingAndConsumesBothChallengesOnce() throws Exception { + org.mockito.Mockito.when(hydra.login("login-challenge")).thenReturn(hydraRequest("login-challenge", "", null, null)); + var session = login(); + mvc.perform(get("/oauth2/start").with(https()).session(session).param("login_challenge", "login-challenge")) + .andExpect(redirectedUrl("/oauth2/login")); + var intent = top.ddupan.iam.login.authorization.interfaces.web.HydraBrowserRequests.intent( + new org.springframework.mock.web.MockHttpServletRequest() {{ setSession(session); }}); + mvc.perform(post("/oauth2/login").with(https()).session(session).with(csrf()).param("binding", intent.binding())) + .andExpect(status().is3xxRedirection()); + org.mockito.Mockito.verify(hydra, org.mockito.Mockito.never()).acceptLogin(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + secondFactor(session); + mvc.perform(post("/oauth2/login").with(https()).session(session).param("binding", intent.binding())) + .andExpect(status().isForbidden()); + mvc.perform(post("/oauth2/login").with(https()).session(session).with(csrf()).param("binding", "other-browser")) + .andExpect(status().isBadRequest()); + org.mockito.Mockito.when(hydra.acceptLogin(org.mockito.ArgumentMatchers.eq("login-challenge"), + org.mockito.ArgumentMatchers.eq("human:existing-subject"), org.mockito.ArgumentMatchers.eq(intent.binding()), + org.mockito.ArgumentMatchers.any())).thenReturn("http://localhost:14444/oauth2/auth?login_verifier=fixture"); + mvc.perform(post("/oauth2/login").with(https()).session(session).with(csrf()).param("binding", intent.binding())) + .andExpect(status().isSeeOther()); + mvc.perform(post("/oauth2/login").with(https()).session(session).with(csrf()).param("binding", intent.binding())) + .andExpect(status().isBadRequest()); + org.mockito.Mockito.when(hydra.consent("consent-challenge")).thenReturn( + hydraRequest("consent-challenge", "human:existing-subject", "login-challenge", intent.binding())); + org.mockito.Mockito.when(hydra.acceptConsent(org.mockito.ArgumentMatchers.eq("consent-challenge"), + org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.anyMap())) + .thenReturn("http://localhost:14444/oauth2/auth?consent_verifier=fixture"); + mvc.perform(get("/oauth2/consent").with(https()).session(session).param("consent_challenge", "consent-challenge")) + .andExpect(status().isSeeOther()); + mvc.perform(get("/oauth2/consent").with(https()).session(session).param("consent_challenge", "consent-challenge")) + .andExpect(status().isBadRequest()); + } + + @Test + void consentCannotStartInAnotherBrowserEvenAfterMfa() throws Exception { + var session = login(); secondFactor(session); + mvc.perform(get("/oauth2/consent").with(https()).session(session).param("consent_challenge", "stolen-challenge")) + .andExpect(status().isBadRequest()); + org.mockito.Mockito.verifyNoInteractions(hydra); + } + private MockHttpSession login() throws Exception { var session = new MockHttpSession(); mvc.perform(post("/signin/password").session(session).with(https()).with(csrf()) diff --git a/src/test/java/top/ddupan/iam/login/authorization/AuthorizationPolicyTests.java b/src/test/java/top/ddupan/iam/login/authorization/AuthorizationPolicyTests.java new file mode 100644 index 0000000..c1e9eb9 --- /dev/null +++ b/src/test/java/top/ddupan/iam/login/authorization/AuthorizationPolicyTests.java @@ -0,0 +1,46 @@ +package top.ddupan.iam.login.authorization; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import top.ddupan.iam.login.authentication.domain.User; +import top.ddupan.iam.login.authorization.application.AuthorizationPolicy; +import top.ddupan.iam.login.authorization.domain.AuthorizationRequest; +import static org.assertj.core.api.Assertions.*; + +class AuthorizationPolicyTests { + private final User user = new User(new User.UserId("example.test", "immutable-guid"), "alice", "Alice", + "alice@example.test", List.of(new User.GroupMembership("gitea-admins", "CN=gitea-admins,DC=example,DC=test"))); + private final AuthorizationPolicy policy = new AuthorizationPolicy(Set.of("gitea"), Map.of(user.id(), "human:old-issuer-sub-hash")); + + @Test void subjectContinuityRequiresExplicitImmutableBinding() { + assertThat(policy.subject(user)).isEqualTo("human:old-issuer-sub-hash"); + var renamed = new User(user.id(), "renamed", "Renamed", "new@example.test", List.of()); + assertThat(policy.subject(renamed)).isEqualTo(policy.subject(user)); + var impostor = new User(new User.UserId("example.test", "another-guid"), user.username(), user.displayName(), user.email(), List.of()); + assertThatIllegalArgumentException().isThrownBy(() -> policy.subject(impostor)); + assertThatIllegalArgumentException().isThrownBy(() -> new AuthorizationPolicy(Set.of("gitea"), + Map.of(user.id(), "same-sub", impostor.id(), "same-sub"))); + } + @Test void claimsFollowRequestedScopesAndDoNotInventMailboxVerification() { + assertThat(policy.claims(user, request("gitea", List.of("openid"), List.of()))).isEmpty(); + var claims = policy.claims(user, request("gitea", List.of("openid", "email", "groups"), List.of())); + assertThat(claims).containsEntry("email_verified", false).containsEntry("groups", List.of("gitea-admins")) + .doesNotContainKey("preferred_username"); + } + @Test void clientsScopesAndAudienceFailClosed() { + assertThatIllegalArgumentException().isThrownBy(() -> policy.validate(request("other", List.of("openid"), List.of()))); + assertThatIllegalArgumentException().isThrownBy(() -> policy.validate(request("gitea", List.of("openid", "offline_access"), List.of()))); + assertThatIllegalArgumentException().isThrownBy(() -> policy.validate(request("gitea", List.of("openid"), List.of("other-api")))); + } + @Test void registeredMetadataAllowsNewClientsAndExplicitDisableOverridesLegacyAllowlist() { + var enabled=new AuthorizationRequest("challenge","new-client",List.of("openid"),List.of(),"",null,null,"https://issuer/oauth2/auth",false,true); + policy.validate(enabled); + var disabled=new AuthorizationRequest("challenge","gitea",List.of("openid"),List.of(),"",null,null,"https://issuer/oauth2/auth",false,false); + assertThatIllegalArgumentException().isThrownBy(() -> policy.validate(disabled)); + } + private AuthorizationRequest request(String client, List scopes, List audience) { + return new AuthorizationRequest("challenge", client, scopes, audience, "", null, null, "https://issuer/oauth2/auth", false); + } +} diff --git a/src/test/java/top/ddupan/iam/login/authorization/AuthorizationUseCaseTests.java b/src/test/java/top/ddupan/iam/login/authorization/AuthorizationUseCaseTests.java new file mode 100644 index 0000000..fa17215 --- /dev/null +++ b/src/test/java/top/ddupan/iam/login/authorization/AuthorizationUseCaseTests.java @@ -0,0 +1,55 @@ +package top.ddupan.iam.login.authorization; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import top.ddupan.iam.login.authentication.domain.User; +import top.ddupan.iam.login.authorization.application.*; +import top.ddupan.iam.login.authorization.application.port.HydraGateway; +import top.ddupan.iam.login.authorization.domain.AuthorizationRequest; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class AuthorizationUseCaseTests { + @Test void consentRejectsChangedBindingSubjectClientScopesAndOriginalChallenge() { + var gateway = mock(HydraGateway.class); + var user = new User(new User.UserId("directory", "id"), "alice", "Alice", "", List.of()); + var useCase = new AuthorizeApplication(new AuthorizationPolicy(Set.of("gitea"), Map.of(user.id(), "old-sub")), gateway); + var expected = new AuthorizationRequest("opaque-login-challenge", "gitea", List.of("openid"), List.of(), + "", null, null, "https://issuer/oauth2/auth", false); + for (var consent : List.of( + request("other", "old-sub", "binding", expected.challengeDigest(), List.of("openid")), + request("gitea", "other-sub", "binding", expected.challengeDigest(), List.of("openid")), + request("gitea", "old-sub", "other-browser", expected.challengeDigest(), List.of("openid")), + request("gitea", "old-sub", "binding", "another-login", List.of("openid")), + request("gitea", "old-sub", "binding", expected.challengeDigest(), List.of("openid", "groups")))) { + when(gateway.consent("consent")).thenReturn(consent); + assertThatIllegalArgumentException().isThrownBy(() -> useCase.consent(expected, "binding", "old-sub", user, "consent")); + } + verify(gateway, never()).acceptConsent(anyString(), anyList(), anyMap()); + } + @Test void rememberedIssuerSessionStillRequiresMatchingAuthenticatedUser() { + var gateway = mock(HydraGateway.class); + var user = new User(new User.UserId("directory", "id"), "alice", "Alice", "", List.of()); + var useCase = new AuthorizeApplication(new AuthorizationPolicy(Set.of("gitea"), Map.of(user.id(), "old-sub")), gateway); + var at = java.time.Instant.now(); + for (String subject : List.of("other-sub", "")) { + var request = new AuthorizationRequest("challenge", "gitea", List.of("openid"), List.of(), + subject, null, null, "https://issuer/oauth2/auth", true); + when(gateway.login("challenge")).thenReturn(request); + assertThatIllegalArgumentException().isThrownBy(() -> useCase.login(request,"binding",user,at)); + } + verify(gateway, never()).acceptLogin(anyString(),anyString(),anyString(),any()); + var request = new AuthorizationRequest("challenge", "gitea", List.of("openid"), List.of(), + "old-sub", null, null, "https://issuer/oauth2/auth", true); + when(gateway.login("challenge")).thenReturn(request); + when(gateway.acceptLogin("challenge","old-sub","binding",at)).thenReturn("https://issuer/oauth2/auth"); + assertThat(useCase.start("challenge")).isEqualTo(request); + assertThat(useCase.login(request,"binding",user,at).subject()).isEqualTo("old-sub"); + } + private AuthorizationRequest request(String client, String subject, String binding, String digest, List scopes) { + return new AuthorizationRequest("consent", client, scopes, List.of(), subject, digest, binding, "https://issuer/oauth2/auth", false); + } +} diff --git a/src/test/java/top/ddupan/iam/login/authorization/HydraAdminClientTests.java b/src/test/java/top/ddupan/iam/login/authorization/HydraAdminClientTests.java new file mode 100644 index 0000000..20c9bbc --- /dev/null +++ b/src/test/java/top/ddupan/iam/login/authorization/HydraAdminClientTests.java @@ -0,0 +1,69 @@ +package top.ddupan.iam.login.authorization; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import top.ddupan.iam.login.authorization.infrastructure.hydra.*; +import static org.assertj.core.api.Assertions.*; + +class HydraAdminClientTests { + @Test void logoutAcceptsOriginRelativeRequestsButRejectsForeignOrigins() throws Exception { + var payload = new AtomicReference(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + var bytes = payload.get().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); exchange.close(); + }); + server.start(); + try { + var client = new HydraLogoutClient(new HydraProperties(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort()), URI.create("http://localhost:14444"), + Set.of(), List.of())); + payload.set("{\"challenge\":\"challenge\",\"subject\":\"subject\",\"sid\":\"session\",\"request_url\":\"/oauth2/sessions/logout\"}"); + assertThat(client.request("challenge").subject()).isEqualTo("subject"); + for (String url : List.of("//attacker.example/oauth2/sessions/logout", "https://attacker.example/oauth2/sessions/logout", "/admin/clients")) { + payload.set("{\"challenge\":\"challenge\",\"request_url\":\"" + url + "\"}"); + assertThatIllegalArgumentException().isThrownBy(() -> client.request("challenge")); + } + } finally { server.stop(0); } + } + @Test void acceptsOnlyIssuerAuthorizationRedirectsAndNeverFollowsAdminRedirects() throws Exception { + var status = new AtomicInteger(200); + var payload = new AtomicReference<>("{\"redirect_to\":\"http://localhost:14444/oauth2/auth?login_verifier=fixture\"}"); + var requests = new AtomicInteger(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + requests.incrementAndGet(); exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.getResponseHeaders().set("Location", "http://127.0.0.1:" + server.getAddress().getPort() + "/redirected"); + var bytes = payload.get().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status.get(), bytes.length); + exchange.getResponseBody().write(bytes); exchange.close(); + }); + server.start(); + try { + var client = new HydraAdminClient(new HydraProperties(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort()), URI.create("http://localhost:14444"), + Set.of("gitea"), List.of())); + assertThat(client.acceptLogin("challenge", "subject", "binding", Instant.now())) + .startsWith("http://localhost:14444/oauth2/auth?"); + for (var target : List.of("https://attacker.example/oauth2/auth", "http://localhost:14444/admin/clients", + "http://localhost:14444/oauth2/auth#fragment", "http://user@localhost:14444/oauth2/auth")) { + payload.set("{\"redirect_to\":\"" + target + "\"}"); + assertThatIllegalArgumentException().isThrownBy(() -> client.acceptLogin("challenge", "subject", "binding", Instant.now())); + } + status.set(302); var before = requests.get(); + assertThatIllegalArgumentException().isThrownBy(() -> client.login("challenge")); + assertThat(requests.get()).isEqualTo(before + 1); + } finally { server.stop(0); } + } +} diff --git a/src/test/java/top/ddupan/iam/login/clients/HydraRegistryIntegrationTests.java b/src/test/java/top/ddupan/iam/login/clients/HydraRegistryIntegrationTests.java new file mode 100644 index 0000000..552874e --- /dev/null +++ b/src/test/java/top/ddupan/iam/login/clients/HydraRegistryIntegrationTests.java @@ -0,0 +1,82 @@ +package top.ddupan.iam.login.clients; + +import java.net.URI; +import java.net.http.*; +import java.time.Duration; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; +import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties; +import top.ddupan.iam.login.clients.application.ClientRegistryException; +import top.ddupan.iam.login.clients.domain.OidcClient; +import top.ddupan.iam.login.clients.infrastructure.HydraClientRegistry; +import static org.assertj.core.api.Assertions.*; + +class HydraRegistryIntegrationTests { + private static final String IMAGE="oryd/hydra:v26.2.0@sha256:ff67c7fb5f95074fa53374d41151713554960504b340cd3f95b09e65deaea2a9"; + private static final URI ADMIN=URI.create("http://127.0.0.1:14845"); + @Test void crudPersistsAcrossIssuerRestartAndDoesNotReturnSecretsOnRead() throws Exception { + try (var database=new PostgreSQLContainer(DockerImageName.parse( + "postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873").asCompatibleSubstituteFor("postgres"))) { + database.start(); + String dsn="postgres://"+database.getUsername()+":"+database.getPassword()+"@127.0.0.1:" + +database.getMappedPort(5432)+"/"+database.getDatabaseName()+"?sslmode=disable"; + try (var migrate=new GenericContainer<>(DockerImageName.parse(IMAGE)).withNetworkMode("host") + .withEnv("DSN",dsn).withCommand("migrate","sql","-e","--yes") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy())) { migrate.start(); } + try (var hydra=new GenericContainer<>(DockerImageName.parse(IMAGE)).withNetworkMode("host") + .withEnv("DSN",dsn).withEnv("SERVE_PUBLIC_HOST","127.0.0.1").withEnv("SERVE_PUBLIC_PORT","14844") + .withEnv("SERVE_ADMIN_HOST","127.0.0.1").withEnv("SERVE_ADMIN_PORT","14845") + .withEnv("URLS_SELF_ISSUER","http://localhost:14844/").withEnv("LOG_LEVEL","error") + .withEnv("SECRETS_SYSTEM","fixture-only-stable-system-secret-32-characters").withCommand("serve","all","--dev")) { + hydra.start(); ready(); + var registry=new HydraClientRegistry(new HydraProperties(true,ADMIN,URI.create("http://localhost:14844"),Set.of(),List.of())); + var client=new OidcClient("managed-fixture","Fixture",List.of("https://rp.example/callback"),Set.of("openid","groups"), + List.of("https://rp.example/bye"),"https://rp.example/backchannel","",true); + var created=registry.create(client); + assertThat(created.client()).isEqualTo(client); + assertThat(created.secret()).isNotBlank(); + assertThat(created.toString()).doesNotContain(created.secret()); + assertThat(registry.list(0,20)).contains(client); + hydra.getDockerClient().restartContainerCmd(hydra.getContainerId()).exec(); ready(); + assertThat(registry.get(client.id())).isEqualTo(client); + var changed=new OidcClient(client.id(),"Updated",List.of("https://rp.example/new-callback"),Set.of("openid"), + List.of(),"","",false); + assertThat(registry.update(changed)).isEqualTo(changed); + assertThat(registry.get(client.id()).loginEnabled()).isFalse(); + try (var http=HttpClient.newHttpClient()) { + String basic=Base64.getEncoder().encodeToString((client.id()+":"+created.secret()).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var response=http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:14844/oauth2/token")) + .header("Authorization","Basic "+basic).header("Content-Type","application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString("grant_type=authorization_code&code=invalid-code&redirect_uri=https%3A%2F%2Frp.example%2Fnew-callback")) + .build(),HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(400); + assertThat(response.body()).contains("invalid_grant").doesNotContain("invalid_client"); + } + registry.delete(client.id()); + assertThatThrownBy(() -> registry.get(client.id())).isInstanceOfSatisfying(ClientRegistryException.class, + ex -> assertThat(ex.kind()).isEqualTo(ClientRegistryException.Kind.NOT_FOUND)); + } + } + } + private static void ready() throws Exception { + try (var http=HttpClient.newHttpClient()) { + for (int i=0;i<100;i++) { + try { + if(http.send(HttpRequest.newBuilder(ADMIN.resolve("/health/ready")).timeout(Duration.ofSeconds(1)).GET().build(), + HttpResponse.BodyHandlers.discarding()).statusCode()==200) return; + } catch (java.io.IOException ignored) { } + Thread.sleep(200); + } + } + throw new IllegalStateException("Fixture issuer not ready"); + } + @Test void rejectsCallbackWildcardsFragmentsAndInsecureNonLoopback() { + for (var target:List.of("https://rp.example/*","https://rp.example/callback#x","http://rp.example/callback","https://user@rp.example/callback")) { + assertThatIllegalArgumentException().isThrownBy(() -> new OidcClient("id","Name",List.of(target),Set.of("openid"),List.of(),"","",true)); + } + } +} diff --git a/src/test/java/top/ddupan/iam/login/support/HydraFixture.java b/src/test/java/top/ddupan/iam/login/support/HydraFixture.java new file mode 100644 index 0000000..7966e1d --- /dev/null +++ b/src/test/java/top/ddupan/iam/login/support/HydraFixture.java @@ -0,0 +1,62 @@ +package top.ddupan.iam.login.support; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +/** Disposable issuer, bound to loopback only. Contains no production clients, secrets or users. */ +public final class HydraFixture implements AutoCloseable { + private final GenericContainer hydra; + public HydraFixture() { + hydra = new GenericContainer<>(DockerImageName.parse( + "oryd/hydra:v26.2.0@sha256:ff67c7fb5f95074fa53374d41151713554960504b340cd3f95b09e65deaea2a9")) + .withNetworkMode("host") + .withEnv("DSN", "memory") + .withEnv("SERVE_PUBLIC_HOST", "127.0.0.1").withEnv("SERVE_PUBLIC_PORT", "14444") + .withEnv("SERVE_ADMIN_HOST", "127.0.0.1").withEnv("SERVE_ADMIN_PORT", "14445") + .withEnv("URLS_SELF_ISSUER", "http://localhost:14444/") + .withEnv("URLS_LOGIN", "https://localhost:18083/oauth2/start") + .withEnv("URLS_CONSENT", "https://localhost:18083/oauth2/consent") + .withEnv("URLS_LOGOUT", "https://localhost:18083/oauth2/logout") + .withEnv("URLS_POST_LOGOUT_REDIRECT", "https://localhost:18083/signin") + .withEnv("LOG_LEVEL", "error") + .withEnv("SECRETS_SYSTEM", "fixture-only-hydra-system-secret-32-characters") + .withCommand("serve", "all", "--dev") + .waitingFor(new AbstractWaitStrategy() { + @Override protected void waitUntilReady() { + try (var http = HttpClient.newHttpClient()) { + for (int attempt = 0; attempt < 100; attempt++) { + try { + var response = http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:14445/health/ready")) + .timeout(Duration.ofSeconds(1)).GET().build(), HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() == 200) return; + } catch (java.io.IOException ignored) { } + Thread.sleep(200); + } + throw new IllegalStateException("Fixture Hydra did not become ready"); + } catch (InterruptedException ex) { Thread.currentThread().interrupt(); throw new IllegalStateException(ex); } + } + }); + hydra.start(); + try (var http = HttpClient.newHttpClient()) { + var response = http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:14445/admin/clients")) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(""" + {"client_id":"gitea-fixture","client_secret":"fixture-client-secret", + "redirect_uris":["http://localhost:14446/callback"],"grant_types":["authorization_code"], + "response_types":["code"],"scope":"openid profile email groups", + "token_endpoint_auth_method":"client_secret_basic","subject_type":"public", + "metadata":{"iam_login_enabled":true}, + "backchannel_logout_uri":"http://localhost:14446/backchannel", + "backchannel_logout_session_required":true, + "post_logout_redirect_uris":["http://localhost:14446/logged-out"]} + """)).build(), HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() != 201) throw new IllegalStateException("Unable to create fixture client"); + } catch (Exception ex) { hydra.stop(); throw new IllegalStateException("Fixture client initialization failed", ex); } + } + @Override public void close() { hydra.stop(); } +} From fdf2e4958bcaacc52aadb7078b0e6746f8c01101 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Tue, 29 Sep 2026 13:21:34 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=E6=94=B6=E6=95=9B=20IAM=20=E4=BB=93?= =?UTF-8?q?=E5=82=A8=E4=B8=8E=20Hydra=20=E7=BD=91=E5=85=B3=E5=B9=B6?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=95=B0=E6=8D=AE=E6=BA=90=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- build.gradle | 1 + docs/client-management.md | 1 + docs/hydra-login.md | 6 ++ docs/native-validation.md | 7 ++ docs/webauthn.md | 11 +++ .../JdbcCredentialRegistration.java | 23 +++++++ .../DirectoryRelyingPartyOperations.java | 16 ++--- .../interfaces/web/SignInController.java | 6 +- .../application/port/HydraGateway.java | 4 ++ .../application/port/LogoutGateway.java | 8 --- .../hydra/HydraAdminClient.java | 62 +++++++++++++---- .../hydra/HydraHttpProperties.java | 16 +++++ .../hydra/HydraLogoutClient.java | 67 ------------------- .../interfaces/web/HydraLogoutController.java | 16 ++--- .../application/ClientRegistryException.java | 8 --- .../ClientRepository.java} | 7 +- .../domain/ClientRepositoryException.java | 8 +++ ...gistry.java => HydraClientRepository.java} | 26 ++++--- .../interfaces/web/ClientsController.java | 20 +++--- .../ClientManagementConfiguration.java | 9 ++- .../configuration/HydraConfiguration.java | 20 ++++-- .../configuration/SecurityConfiguration.java | 4 +- .../configuration/WebAuthnConfiguration.java | 15 ++--- src/main/resources/application.yaml | 2 - .../iam/login/IamLoginApplicationTests.java | 2 +- .../webauthn/WebAuthnIntegrationTests.java | 61 ++++++++++++++--- .../web/SignInIntegrationTests.java | 2 +- .../authorization/HydraAdminClientTests.java | 17 +++-- ...a => HydraRepositoryIntegrationTests.java} | 13 ++-- 30 files changed, 267 insertions(+), 195 deletions(-) create mode 100644 src/main/java/top/ddupan/iam/login/authentication/infrastructure/persistence/JdbcCredentialRegistration.java delete mode 100644 src/main/java/top/ddupan/iam/login/authorization/application/port/LogoutGateway.java create mode 100644 src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraHttpProperties.java delete mode 100644 src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraLogoutClient.java delete mode 100644 src/main/java/top/ddupan/iam/login/clients/application/ClientRegistryException.java rename src/main/java/top/ddupan/iam/login/clients/{application/ClientRegistry.java => domain/ClientRepository.java} (62%) create mode 100644 src/main/java/top/ddupan/iam/login/clients/domain/ClientRepositoryException.java rename src/main/java/top/ddupan/iam/login/clients/infrastructure/{HydraClientRegistry.java => HydraClientRepository.java} (80%) rename src/test/java/top/ddupan/iam/login/clients/{HydraRegistryIntegrationTests.java => HydraRepositoryIntegrationTests.java} (92%) diff --git a/README.md b/README.md index 915b2b1..aac32b7 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ npm --prefix frontend run build ./gradlew bootRun ``` +应用启动需要配置 PostgreSQL 数据源,连接与开发容器说明见[第二因素](docs/webauthn.md)。 测试需要可用的 Docker,生成器配置了 Grafana LGTM Testcontainer。 测试覆盖 AD 第一因素、浏览器流程和监控集成。人类登录统一从 `/signin` 进入。 使用 GraalVM 25 验证原生测试与编译: @@ -87,11 +88,12 @@ configuration → 装配上述实现 - `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。 使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。 - `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。 +- `authentication/infrastructure/persistence`:封装注册并发锁及事务,不把表结构带入 Security 策略。 - `authentication/infrastructure/webauthn`:凭据归属与注册策略、challenge 仓储扩展;密码学校验和 JDBC 存储交给 Spring Security。 - `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。 - `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。 - `authorization`:领域请求、应用授权用例与策略、Hydra Admin 基础设施、浏览器请求绑定分层维护;客户端注册与签发仍归 Hydra。 -- `clients`:受 MFA 与直接管理组保护的客户端 CRUD;持久化与密钥由 Hydra 持有,见[管理接口](docs/client-management.md)。 +- `clients/domain/ClientRepository`:客户端仓储契约,由 Hydra HTTP 实现;受 MFA 与直接管理组保护的客户端 CRUD;持久化与密钥由 Hydra 持有,见[管理接口](docs/client-management.md)。 - `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。 测试覆盖应用用例、AD 仓储和完整 Spring Security 过滤器链,LDAP 夹具集中在测试 `support` 包。 diff --git a/build.gradle b/build.gradle index e35cbc5..d6cde2d 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-restclient' implementation 'org.springframework.security:spring-security-webauthn' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/docs/client-management.md b/docs/client-management.md index 6da9b9b..ef17af1 100644 --- a/docs/client-management.md +++ b/docs/client-management.md @@ -1,6 +1,7 @@ # OAuth2/OIDC 客户端管理 本服务通过受限 API 管理 Hydra 中的第一方 confidential authorization-code 客户端。 +领域层定义 `ClientRepository`,基础设施的 `HydraClientRepository` 实现远端持久化。 API 没有管理 UI,沿用浏览器 Spring session;调用者必须完成有效的密码 + WebAuthn MFA, 且直接属于配置的管理组。默认组列表为空,拒绝全部管理操作。 diff --git a/docs/hydra-login.md b/docs/hydra-login.md index f0dbcf7..7f26a1b 100644 --- a/docs/hydra-login.md +++ b/docs/hydra-login.md @@ -46,6 +46,9 @@ iam: enabled: true admin-url: http://hydra-admin.hydra.svc.cluster.local:4445 public-url: https://hydra.ad.ddupan.top + http: + connect-timeout: 3s + read-timeout: 5s clients: [gitea] # 仅供尚未写入管理标记的旧客户端过渡 subjects: - authority: ad.example.test @@ -63,6 +66,9 @@ Hydra 环境配置需将 login URL 指向 `/oauth2/start`,consent URL 指向 ` logout URL 指向 `/oauth2/logout`,默认 post-logout URL 指向本服务 `/signin`。 本仓库的实现与测试不等于这些生产设置已变更。Admin URL 可以使用现役受 NetworkPolicy 约束的集群内 HTTP,也可通过 loopback port-forward;不能公开管理端口。 +HTTP client 在配置层使用 Boot 提供的 `RestClient.Builder` 装配并注入,保留框架定制与观测能力。 +上述连接/读取超时有默认值且必须为正,可按部署环境覆盖;禁止跟随重定向不提供关闭开关。 +Login、Consent 和 Logout 共用 `HydraGateway` 与 Admin client。 公共 origin 必须 HTTPS,HTTP 只接受隔离测试的 loopback。客户端请求必须显式带 redirect_uri。 ## 客户端注册与管理边界 diff --git a/docs/native-validation.md b/docs/native-validation.md index fc0be03..0288b01 100644 --- a/docs/native-validation.md +++ b/docs/native-validation.md @@ -44,3 +44,10 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需 通过上述测试后才准备生产切换;保留现役 OIDC 上游适配器作为回退路径。应用镜像以 不可变 digest 交给 homelab-infra,部署状态与真实人类 MFA 验收分别记录。 + +### 测试 AOT 的待排查项(2026-09-29) + +本轮重构的 `processTestAot` 在处理测试上下文后未退出;线程栈显示 `DestroyJavaVM` +等待测试夹具的非 daemon LDAP listener。该轮日常验证使用 +`test bootJar -x processTestAot -x compileAotTestJava -x processAotTestResources` +执行 JVM 测试,不把该结果视为测试 AOT 或 Native 验收。阶段性原生验证前需解决夹具生命周期。 diff --git a/docs/webauthn.md b/docs/webauthn.md index f39444d..6cc37c4 100644 --- a/docs/webauthn.md +++ b/docs/webauthn.md @@ -70,3 +70,14 @@ IAM_WEBAUTHN_FIXTURE=1 npm --prefix frontend run test:browser -- webauthn.spec.t 测试专用启动类仅在 test classpath,不进入生产 JAR,也不提供生产调试 API。 维护者已确认开发入口的 AD + passkey 人类路径能够工作。更多认证器兼容性和 Native 路径 仍需独立验收,不能套用虚拟认证器结果。生产共享 PostgreSQL 的接入留在部署阶段。 + +## 数据源与注册事务 + +PostgreSQL 是应用运行依赖,通过 `spring.datasource.*` 配置,连接池参数使用 +`spring.datasource.hikari.*`。DataSource、JdbcTemplate 与事务管理器由 Boot 自动配置, +WebAuthn 配置仅装配官方凭据仓储及认证策略;不在应用配置中排除 DataSource 自动配置。 +仅不涉及持久化的独立测试显式排除它。 + +首次注册的数据库锁由 `authentication/infrastructure/persistence/JdbcCredentialRegistration` +封装;获得用户行锁后,在同一事务中重新检查注册策略并调用官方凭据保存逻辑。 +WebAuthn 策略集成不直接引用 SQL 或表结构。失败回滚与跨连接串行化由 PostgreSQL 集成测试覆盖。 diff --git a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/persistence/JdbcCredentialRegistration.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/persistence/JdbcCredentialRegistration.java new file mode 100644 index 0000000..d473d15 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/persistence/JdbcCredentialRegistration.java @@ -0,0 +1,23 @@ +package top.ddupan.iam.login.authentication.infrastructure.persistence; + +import java.util.function.Supplier; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.transaction.support.TransactionTemplate; + +/** Serializes registration per user across sessions/processes in the same database transaction. */ +public final class JdbcCredentialRegistration { + private final JdbcOperations jdbc; + private final TransactionTemplate transactions; + + public JdbcCredentialRegistration(JdbcOperations jdbc, TransactionTemplate transactions) { + this.jdbc = jdbc; + this.transactions = transactions; + } + + public T register(String userId, Supplier registration) { + return transactions.execute(status -> { + jdbc.queryForObject("select id from user_entities where id = ? for update", String.class, userId); + return registration.get(); + }); + } +} diff --git a/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/DirectoryRelyingPartyOperations.java b/src/main/java/top/ddupan/iam/login/authentication/infrastructure/webauthn/DirectoryRelyingPartyOperations.java index 3b45466..8150dd6 100644 --- 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 @@ -1,11 +1,10 @@ package top.ddupan.iam.login.authentication.infrastructure.webauthn; -import org.springframework.jdbc.core.JdbcOperations; +import top.ddupan.iam.login.authentication.infrastructure.persistence.JdbcCredentialRegistration; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.webauthn.api.*; import org.springframework.security.web.webauthn.management.*; -import 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. */ @@ -14,18 +13,16 @@ public final class DirectoryRelyingPartyOperations implements WebAuthnRelyingPar private final MfaPolicy policy; private final PublicKeyCredentialUserEntityRepository users; private final UserCredentialRepository credentials; - private final JdbcOperations jdbc; - private final TransactionTemplate transactions; + private final JdbcCredentialRegistration registrations; public DirectoryRelyingPartyOperations(WebAuthnRelyingPartyOperations delegate, MfaPolicy policy, PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials, - JdbcOperations jdbc, TransactionTemplate transactions) { + JdbcCredentialRegistration registrations) { this.delegate = delegate; this.policy = policy; this.users = users; this.credentials = credentials; - this.jdbc = jdbc; - this.transactions = transactions; + this.registrations = registrations; } @Override @@ -41,10 +38,7 @@ public final class DirectoryRelyingPartyOperations implements WebAuthnRelyingPar 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()); + return registrations.register(owner.getId().toBase64UrlString(), () -> { policy.requireEnrollment(SecurityContextHolder.getContext().getAuthentication()); return delegate.registerCredential(request); }); 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 f5ac90a..fa6163c 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 @@ -21,9 +21,9 @@ public class SignInController { private final PageRenderer renderer; private final ObjectProvider policies; - private final ObjectProvider logout; + private final ObjectProvider logout; public SignInController(PageRenderer renderer, ObjectProvider policies, - ObjectProvider logout) { + ObjectProvider logout) { this.logout=logout; this.renderer = renderer; this.policies = policies; @@ -62,7 +62,7 @@ public class SignInController { "groupDns", user.memberships().stream().map(GroupMembership::externalId).toList()))); var gateway = logout.getIfAvailable(); if (gateway == null) return page; - var uri = java.net.URI.create(gateway.startUrl()); + var uri = java.net.URI.create(gateway.logoutUrl()); return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy", PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'", "form-action 'self' " + uri.getScheme() + "://" + uri.getRawAuthority())).body(page.getBody()); diff --git a/src/main/java/top/ddupan/iam/login/authorization/application/port/HydraGateway.java b/src/main/java/top/ddupan/iam/login/authorization/application/port/HydraGateway.java index 976c0a8..2a76b77 100644 --- a/src/main/java/top/ddupan/iam/login/authorization/application/port/HydraGateway.java +++ b/src/main/java/top/ddupan/iam/login/authorization/application/port/HydraGateway.java @@ -6,6 +6,10 @@ import java.util.Map; import top.ddupan.iam.login.authorization.domain.AuthorizationRequest; public interface HydraGateway { + record LogoutRequest(String challenge, String subject, String sid, String postLogoutRedirectUri) { } + LogoutRequest logout(String challenge); + String acceptLogout(String challenge); + String logoutUrl(); AuthorizationRequest login(String challenge); AuthorizationRequest consent(String challenge); String acceptLogin(String challenge, String subject, String binding, Instant authenticatedAt); diff --git a/src/main/java/top/ddupan/iam/login/authorization/application/port/LogoutGateway.java b/src/main/java/top/ddupan/iam/login/authorization/application/port/LogoutGateway.java deleted file mode 100644 index efc532e..0000000 --- a/src/main/java/top/ddupan/iam/login/authorization/application/port/LogoutGateway.java +++ /dev/null @@ -1,8 +0,0 @@ -package top.ddupan.iam.login.authorization.application.port; - -public interface LogoutGateway { - record Request(String challenge, String subject, String sid, String postLogoutRedirectUri) { } - Request request(String challenge); - String accept(String challenge); - String startUrl(); -} diff --git a/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraAdminClient.java b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraAdminClient.java index d9ca4a3..60ba374 100644 --- a/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraAdminClient.java +++ b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraAdminClient.java @@ -1,13 +1,10 @@ package top.ddupan.iam.login.authorization.infrastructure.hydra; import java.net.URI; -import java.net.http.HttpClient; -import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; -import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; import top.ddupan.iam.login.authorization.application.port.HydraGateway; import top.ddupan.iam.login.authorization.domain.AuthorizationRequest; @@ -16,20 +13,13 @@ import top.ddupan.iam.login.authorization.domain.AuthorizationRequest; public final class HydraAdminClient implements HydraGateway { private final RestClient client; private final URI publicUrl; - public HydraAdminClient(HydraProperties properties) { - client = restClient(properties).mutate() + public HydraAdminClient(HydraProperties properties, RestClient http) { + client = http.mutate() .defaultStatusHandler(status -> !status.is2xxSuccessful(), (request, response) -> { throw new IllegalArgumentException("Hydra returned a non-success status"); }).build(); publicUrl = properties.publicUrl(); } - public static RestClient restClient(HydraProperties properties) { - var http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)) - .followRedirects(HttpClient.Redirect.NEVER).build(); - var factory = new JdkClientHttpRequestFactory(http); - factory.setReadTimeout(Duration.ofSeconds(5)); - return RestClient.builder().baseUrl(properties.adminUrl().toString()).requestFactory(factory).build(); - } @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) public record Client(String client_id, Map metadata) { } @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) @@ -119,4 +109,52 @@ public final class HydraAdminClient implements HydraGateway { throw new IllegalArgumentException("Invalid challenge"); } } + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true) + public record LogoutResponse(String challenge,String subject,String sid,String request_url, LogoutClient client) { } + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true) + public record LogoutClient(java.util.List post_logout_redirect_uris) { } + @Override public LogoutRequest logout(String challenge) { + validateChallenge(challenge); + try { + var result = java.util.Objects.requireNonNull(client.get().uri(b -> b.path("/admin/oauth2/auth/requests/logout") + .queryParam("logout_challenge","{challenge}").build(challenge)).retrieve().body(LogoutResponse.class)); + if (!challenge.equals(result.challenge())) throw new IllegalArgumentException("Wrong challenge"); + // Hydra stores the original HTTP request-target, which may be origin-relative. + validateTarget(publicUrl.resolve(result.request_url()).toString()); + String callback = ""; + var query = URI.create(result.request_url()).getRawQuery(); + if (query != null) for (var part : query.split("&")) { + var pair = part.split("=",2); + if ("post_logout_redirect_uri".equals(java.net.URLDecoder.decode(pair[0],java.nio.charset.StandardCharsets.UTF_8))) { + if (!callback.isEmpty() || pair.length!=2) throw new IllegalArgumentException("Duplicate logout redirect"); + callback=java.net.URLDecoder.decode(pair[1],java.nio.charset.StandardCharsets.UTF_8); + } + } + if (!callback.isEmpty()) { + if (result.client()==null || result.client().post_logout_redirect_uris()==null + || !result.client().post_logout_redirect_uris().contains(callback)) throw new IllegalArgumentException("Unregistered logout redirect"); + var uri = URI.create(callback); + if (uri.getHost()==null || uri.getUserInfo()!=null || uri.getFragment()!=null + || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()) + && java.util.Set.of("localhost","127.0.0.1").contains(uri.getHost()))) + throw new IllegalArgumentException("Invalid logout callback"); + } + return new LogoutRequest(challenge,result.subject(),result.sid(),callback); + } catch (RuntimeException ex) { throw new IllegalArgumentException("Logout request unavailable"); } + } + @Override public String acceptLogout(String challenge) { + validateChallenge(challenge); + try { + var result = java.util.Objects.requireNonNull(client.put().uri(b -> b.path("/admin/oauth2/auth/requests/logout/accept") + .queryParam("logout_challenge","{challenge}").build(challenge)).retrieve().body(HydraAdminClient.Redirect.class)); + validateTarget(result.redirect_to()); return result.redirect_to(); + } catch (RuntimeException ex) { throw new IllegalArgumentException("Logout acceptance unavailable"); } + } + @Override public String logoutUrl() { return publicUrl.resolve("/oauth2/sessions/logout").toString(); } + private void validateTarget(String target) { + var uri = URI.create(target); + if (!publicUrl.getScheme().equals(uri.getScheme()) || !publicUrl.getRawAuthority().equals(uri.getRawAuthority()) + || uri.getUserInfo()!=null || uri.getFragment()!=null || !"/oauth2/sessions/logout".equals(uri.getRawPath())) + throw new IllegalArgumentException("Invalid logout redirect"); + } } diff --git a/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraHttpProperties.java b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraHttpProperties.java new file mode 100644 index 0000000..de871d1 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraHttpProperties.java @@ -0,0 +1,16 @@ +package top.ddupan.iam.login.authorization.infrastructure.hydra; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties("iam.hydra.http") +public record HydraHttpProperties(@DefaultValue("3s") Duration connectTimeout, + @DefaultValue("5s") Duration readTimeout) { + public HydraHttpProperties { + if (connectTimeout == null || readTimeout == null || connectTimeout.isNegative() + || connectTimeout.isZero() || readTimeout.isNegative() || readTimeout.isZero()) { + throw new IllegalArgumentException("Hydra HTTP timeouts must be positive"); + } + } +} diff --git a/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraLogoutClient.java b/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraLogoutClient.java deleted file mode 100644 index a1d12b3..0000000 --- a/src/main/java/top/ddupan/iam/login/authorization/infrastructure/hydra/HydraLogoutClient.java +++ /dev/null @@ -1,67 +0,0 @@ -package top.ddupan.iam.login.authorization.infrastructure.hydra; - -import java.net.URI; -import java.util.Objects; -import org.springframework.web.client.RestClient; -import top.ddupan.iam.login.authorization.application.port.LogoutGateway; - -public final class HydraLogoutClient implements LogoutGateway { - private final RestClient http; - private final URI origin; - public HydraLogoutClient(HydraProperties properties) { - origin = properties.publicUrl(); - http = HydraAdminClient.restClient(properties).mutate() - .defaultStatusHandler(status -> !status.is2xxSuccessful(), (req,res) -> { throw new IllegalArgumentException("Logout unavailable"); }).build(); - } - @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true) - public record Response(String challenge,String subject,String sid,String request_url, LogoutClient client) { } - @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true) - public record LogoutClient(java.util.List post_logout_redirect_uris) { } - @Override public Request request(String challenge) { - validate(challenge); - try { - var result = Objects.requireNonNull(http.get().uri(b -> b.path("/admin/oauth2/auth/requests/logout") - .queryParam("logout_challenge","{challenge}").build(challenge)).retrieve().body(Response.class)); - if (!challenge.equals(result.challenge())) throw new IllegalArgumentException("Wrong challenge"); - // Hydra stores the original HTTP request-target, which may be origin-relative. - validateTarget(origin.resolve(result.request_url()).toString()); - String callback = ""; - var query = URI.create(result.request_url()).getRawQuery(); - if (query != null) for (var part : query.split("&")) { - var pair = part.split("=",2); - if ("post_logout_redirect_uri".equals(java.net.URLDecoder.decode(pair[0],java.nio.charset.StandardCharsets.UTF_8))) { - if (!callback.isEmpty() || pair.length!=2) throw new IllegalArgumentException("Duplicate logout redirect"); - callback=java.net.URLDecoder.decode(pair[1],java.nio.charset.StandardCharsets.UTF_8); - } - } - if (!callback.isEmpty()) { - if (result.client()==null || result.client().post_logout_redirect_uris()==null - || !result.client().post_logout_redirect_uris().contains(callback)) throw new IllegalArgumentException("Unregistered logout redirect"); - var uri = URI.create(callback); - if (uri.getHost()==null || uri.getUserInfo()!=null || uri.getFragment()!=null - || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()) - && java.util.Set.of("localhost","127.0.0.1").contains(uri.getHost()))) - throw new IllegalArgumentException("Invalid logout callback"); - } - return new Request(challenge,result.subject(),result.sid(),callback); - } catch (RuntimeException ex) { throw new IllegalArgumentException("Logout request unavailable"); } - } - @Override public String accept(String challenge) { - validate(challenge); - try { - var result = Objects.requireNonNull(http.put().uri(b -> b.path("/admin/oauth2/auth/requests/logout/accept") - .queryParam("logout_challenge","{challenge}").build(challenge)).retrieve().body(HydraAdminClient.Redirect.class)); - validateTarget(result.redirect_to()); return result.redirect_to(); - } catch (RuntimeException ex) { throw new IllegalArgumentException("Logout acceptance unavailable"); } - } - @Override public String startUrl() { return origin.resolve("/oauth2/sessions/logout").toString(); } - private void validateTarget(String target) { - var uri = URI.create(target); - if (!origin.getScheme().equals(uri.getScheme()) || !origin.getRawAuthority().equals(uri.getRawAuthority()) - || uri.getUserInfo()!=null || uri.getFragment()!=null || !"/oauth2/sessions/logout".equals(uri.getRawPath())) - throw new IllegalArgumentException("Invalid logout redirect"); - } - private void validate(String challenge) { - if (challenge == null || challenge.isBlank() || challenge.length()>8192) throw new IllegalArgumentException("Invalid challenge"); - } -} diff --git a/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraLogoutController.java b/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraLogoutController.java index ab50f7e..914afa1 100644 --- a/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraLogoutController.java +++ b/src/main/java/top/ddupan/iam/login/authorization/interfaces/web/HydraLogoutController.java @@ -14,7 +14,7 @@ import org.springframework.security.web.authentication.logout.SecurityContextLog import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.*; import top.ddupan.iam.login.authentication.interfaces.web.PageRenderer; -import top.ddupan.iam.login.authorization.application.port.LogoutGateway; +import top.ddupan.iam.login.authorization.application.port.HydraGateway; import top.ddupan.iam.login.authorization.application.AuthorizationPolicy; import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal; @@ -22,16 +22,16 @@ import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrin @ConditionalOnProperty(prefix="iam.hydra",name="enabled",havingValue="true") public class HydraLogoutController { private static final String KEY = HydraLogoutController.class.getName(); - private record Pending(LogoutGateway.Request request,String binding,Instant expires) { } - private final LogoutGateway hydra; + private record Pending(HydraGateway.LogoutRequest request,String binding,Instant expires) { } + private final HydraGateway hydra; private final PageRenderer renderer; private final AuthorizationPolicy policy; - public HydraLogoutController(LogoutGateway hydra,PageRenderer renderer,AuthorizationPolicy policy) { + public HydraLogoutController(HydraGateway hydra,PageRenderer renderer,AuthorizationPolicy policy) { this.hydra=hydra; this.renderer=renderer; this.policy=policy; } @GetMapping("/oauth2/logout") ResponseEntity page(@RequestParam("logout_challenge") String challenge, HttpServletRequest request,Authentication auth,CsrfToken csrf) { - var logout = hydra.request(challenge); + var logout = hydra.logout(challenge); if (auth != null && auth.getPrincipal() instanceof DirectoryPrincipal principal && logout.subject()!=null && !logout.subject().isBlank() && !policy.subject(principal.user()).equals(logout.subject())) throw new IllegalArgumentException("Different logout subject"); @@ -39,7 +39,7 @@ public class HydraLogoutController { request.getSession().setAttribute(KEY,pending); var page = renderer.render(Map.of("step","logout","name","","error","","action","/oauth2/logout", "binding",pending.binding(),"csrf",Map.of("name",csrf.getParameterName(),"value",csrf.getToken(),"headerName",csrf.getHeaderName()))); - String targets=origin(hydra.startUrl()); + String targets=origin(hydra.logoutUrl()); if (!logout.postLogoutRedirectUri().isEmpty()) targets += " " + origin(logout.postLogoutRedirectUri()); return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy", PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'", "form-action 'self' " + targets)) @@ -56,9 +56,9 @@ public class HydraLogoutController { throw new IllegalArgumentException("Invalid logout binding"); session.removeAttribute(KEY); } - var current=hydra.request(pending.request().challenge()); + var current=hydra.logout(pending.request().challenge()); if (!Objects.equals(current,pending.request())) throw new IllegalArgumentException("Logout request changed"); - var target=hydra.accept(current.challenge()); + var target=hydra.acceptLogout(current.challenge()); new SecurityContextLogoutHandler().logout(request,response,authentication); return ResponseEntity.status(303).header("Cache-Control","no-store").location(URI.create(target)).build(); } diff --git a/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistryException.java b/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistryException.java deleted file mode 100644 index 248ca5f..0000000 --- a/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistryException.java +++ /dev/null @@ -1,8 +0,0 @@ -package top.ddupan.iam.login.clients.application; - -public final class ClientRegistryException extends RuntimeException { - public enum Kind { NOT_FOUND, CONFLICT, UNAVAILABLE } - private final Kind kind; - public ClientRegistryException(Kind kind) { super("Client registry " + kind); this.kind = kind; } - public Kind kind() { return kind; } -} diff --git a/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistry.java b/src/main/java/top/ddupan/iam/login/clients/domain/ClientRepository.java similarity index 62% rename from src/main/java/top/ddupan/iam/login/clients/application/ClientRegistry.java rename to src/main/java/top/ddupan/iam/login/clients/domain/ClientRepository.java index 3035cd9..5912cf1 100644 --- a/src/main/java/top/ddupan/iam/login/clients/application/ClientRegistry.java +++ b/src/main/java/top/ddupan/iam/login/clients/domain/ClientRepository.java @@ -1,10 +1,9 @@ -package top.ddupan.iam.login.clients.application; +package top.ddupan.iam.login.clients.domain; import java.util.List; -import top.ddupan.iam.login.clients.domain.OidcClient; -/** Hydra is the sole persistence authority. Secrets exist only in create/rotate responses. */ -public interface ClientRegistry { +/** Client persistence contract. Secrets exist only in create responses. */ +public interface ClientRepository { record Created(OidcClient client, String secret) { @Override public String toString() { return "Created[client=" + client.id() + ", secret=REDACTED]"; } } diff --git a/src/main/java/top/ddupan/iam/login/clients/domain/ClientRepositoryException.java b/src/main/java/top/ddupan/iam/login/clients/domain/ClientRepositoryException.java new file mode 100644 index 0000000..e4bba34 --- /dev/null +++ b/src/main/java/top/ddupan/iam/login/clients/domain/ClientRepositoryException.java @@ -0,0 +1,8 @@ +package top.ddupan.iam.login.clients.domain; + +public final class ClientRepositoryException extends RuntimeException { + public enum Kind { NOT_FOUND, CONFLICT, UNAVAILABLE } + private final Kind kind; + public ClientRepositoryException(Kind kind) { super("Client repository " + kind); this.kind = kind; } + public Kind kind() { return kind; } +} diff --git a/src/main/java/top/ddupan/iam/login/clients/infrastructure/HydraClientRegistry.java b/src/main/java/top/ddupan/iam/login/clients/infrastructure/HydraClientRepository.java similarity index 80% rename from src/main/java/top/ddupan/iam/login/clients/infrastructure/HydraClientRegistry.java rename to src/main/java/top/ddupan/iam/login/clients/infrastructure/HydraClientRepository.java index bb44170..cf4879d 100644 --- a/src/main/java/top/ddupan/iam/login/clients/infrastructure/HydraClientRegistry.java +++ b/src/main/java/top/ddupan/iam/login/clients/infrastructure/HydraClientRepository.java @@ -3,30 +3,28 @@ package top.ddupan.iam.login.clients.infrastructure; import java.util.*; import org.springframework.core.ParameterizedTypeReference; import org.springframework.web.client.RestClient; -import top.ddupan.iam.login.clients.application.*; +import top.ddupan.iam.login.clients.domain.*; import top.ddupan.iam.login.clients.domain.OidcClient; -import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties; -import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraAdminClient; -public final class HydraClientRegistry implements ClientRegistry { +public final class HydraClientRepository implements ClientRepository { public static final String ENABLED = "iam_login_enabled"; private static final ParameterizedTypeReference> OBJECT = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference>> ARRAY = new ParameterizedTypeReference<>() {}; private final RestClient http; - public HydraClientRegistry(HydraProperties properties) { - http = HydraAdminClient.restClient(properties).mutate() + public HydraClientRepository(RestClient client) { + http = client.mutate() .defaultStatusHandler(status -> !status.is2xxSuccessful(), (request, response) -> { - throw new ClientRegistryException(switch (response.getStatusCode().value()) { - case 404 -> ClientRegistryException.Kind.NOT_FOUND; - case 400, 409 -> ClientRegistryException.Kind.CONFLICT; - default -> ClientRegistryException.Kind.UNAVAILABLE; + throw new ClientRepositoryException(switch (response.getStatusCode().value()) { + case 404 -> ClientRepositoryException.Kind.NOT_FOUND; + case 400, 409 -> ClientRepositoryException.Kind.CONFLICT; + default -> ClientRepositoryException.Kind.UNAVAILABLE; }); }).build(); } private T call(java.util.function.Supplier operation) { try { return operation.get(); } - catch (ClientRegistryException ex) { throw ex; } - catch (RuntimeException ex) { throw new ClientRegistryException(ClientRegistryException.Kind.UNAVAILABLE); } + catch (ClientRepositoryException ex) { throw ex; } + catch (RuntimeException ex) { throw new ClientRepositoryException(ClientRepositoryException.Kind.UNAVAILABLE); } } @Override public List list(int page, int size) { if (page < 0 || size < 1 || size > 100) throw new IllegalArgumentException("Invalid pagination"); @@ -49,7 +47,7 @@ public final class HydraClientRegistry implements ClientRegistry { @Override public OidcClient update(OidcClient client) { return call(() -> { var previous = read(client.id()); - if (!supported(previous)) throw new ClientRegistryException(ClientRegistryException.Kind.CONFLICT); + if (!supported(previous)) throw new ClientRepositoryException(ClientRepositoryException.Kind.CONFLICT); // Preserve issuer-owned fields and metadata, but never send back a stored secret/hash. var update = new LinkedHashMap<>(previous); update.remove("client_secret"); var metadata = new LinkedHashMap(); @@ -82,7 +80,7 @@ public final class HydraClientRegistry implements ClientRegistry { map.put("metadata",Map.of(ENABLED,c.loginEnabled())); return map; } @SuppressWarnings("unchecked") private OidcClient view(Map m) { - if (!supported(m)) throw new ClientRegistryException(ClientRegistryException.Kind.CONFLICT); + if (!supported(m)) throw new ClientRepositoryException(ClientRepositoryException.Kind.CONFLICT); String id = (String)m.get("client_id"); String name = Objects.toString(m.get("client_name"),""); return new OidcClient(id, name.isBlank() ? id : name, diff --git a/src/main/java/top/ddupan/iam/login/clients/interfaces/web/ClientsController.java b/src/main/java/top/ddupan/iam/login/clients/interfaces/web/ClientsController.java index 53f1ced..4f43c68 100644 --- a/src/main/java/top/ddupan/iam/login/clients/interfaces/web/ClientsController.java +++ b/src/main/java/top/ddupan/iam/login/clients/interfaces/web/ClientsController.java @@ -6,31 +6,31 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.*; -import top.ddupan.iam.login.clients.application.*; +import top.ddupan.iam.login.clients.domain.*; import top.ddupan.iam.login.clients.domain.OidcClient; @RestController @ConditionalOnProperty(prefix="iam.hydra", name="enabled", havingValue="true") public class ClientsController { - private final ClientRegistry registry; + private final ClientRepository repository; private static final org.slf4j.Logger AUDIT = org.slf4j.LoggerFactory.getLogger("iam.audit.clients"); - public ClientsController(ClientRegistry registry) { this.registry = registry; } + public ClientsController(ClientRepository repository) { this.repository = repository; } @GetMapping("/api/iam/session") Object session(CsrfToken csrf) { return Map.of("csrf",Map.of("headerName",csrf.getHeaderName(),"token",csrf.getToken())); } @GetMapping("/api/iam/clients") Object list(@RequestParam(defaultValue="0") int page, - @RequestParam(defaultValue="20") int size) { return registry.list(page,size); } - @GetMapping("/api/iam/clients/{id}") OidcClient get(@PathVariable String id) { return registry.get(id); } - @PostMapping("/api/iam/clients") ResponseEntity create(@RequestBody OidcClient input, Authentication auth) { - var created = registry.create(input); audit("create",input.id(),auth); + @RequestParam(defaultValue="20") int size) { return repository.list(page,size); } + @GetMapping("/api/iam/clients/{id}") OidcClient get(@PathVariable String id) { return repository.get(id); } + @PostMapping("/api/iam/clients") ResponseEntity create(@RequestBody OidcClient input, Authentication auth) { + var created = repository.create(input); audit("create",input.id(),auth); return ResponseEntity.status(201).header("Cache-Control","no-store").body(created); } @PutMapping("/api/iam/clients/{id}") OidcClient update(@PathVariable String id, @RequestBody OidcClient input, Authentication auth) { if (!id.equals(input.id())) throw new IllegalArgumentException("Client ID mismatch"); - var updated = registry.update(input); audit("update",id,auth); return updated; + var updated = repository.update(input); audit("update",id,auth); return updated; } @DeleteMapping("/api/iam/clients/{id}") ResponseEntity delete(@PathVariable String id, Authentication auth) { - registry.delete(id); audit("delete",id,auth); return ResponseEntity.noContent().build(); + repository.delete(id); audit("delete",id,auth); return ResponseEntity.noContent().build(); } private void audit(String action,String id,Authentication auth) { AUDIT.info("client action={} client={} actor={}",action,id,auth.getName()); @@ -38,7 +38,7 @@ public class ClientsController { @ExceptionHandler(IllegalArgumentException.class) ResponseEntity invalid() { return ResponseEntity.badRequest().body(Map.of("error","invalid_client_request")); } - @ExceptionHandler(ClientRegistryException.class) ResponseEntity unavailable(ClientRegistryException ex) { + @ExceptionHandler(ClientRepositoryException.class) ResponseEntity unavailable(ClientRepositoryException ex) { int status = switch (ex.kind()) { case NOT_FOUND -> 404; case CONFLICT -> 409; case UNAVAILABLE -> 502; }; return ResponseEntity.status(status).body(Map.of("error",ex.kind().name().toLowerCase(java.util.Locale.ROOT))); } diff --git a/src/main/java/top/ddupan/iam/login/configuration/ClientManagementConfiguration.java b/src/main/java/top/ddupan/iam/login/configuration/ClientManagementConfiguration.java index daa3a0d..171ccdc 100644 --- a/src/main/java/top/ddupan/iam/login/configuration/ClientManagementConfiguration.java +++ b/src/main/java/top/ddupan/iam/login/configuration/ClientManagementConfiguration.java @@ -15,15 +15,14 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal; import top.ddupan.iam.login.authentication.infrastructure.webauthn.MfaPolicy; -import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties; -import top.ddupan.iam.login.clients.application.ClientRegistry; -import top.ddupan.iam.login.clients.infrastructure.HydraClientRegistry; +import top.ddupan.iam.login.clients.domain.ClientRepository; +import top.ddupan.iam.login.clients.infrastructure.HydraClientRepository; import top.ddupan.iam.login.clients.domain.OidcClient; @Configuration(proxyBeanMethods=false) @ConditionalOnProperty(prefix="iam.hydra",name="enabled",havingValue="true") @EnableConfigurationProperties(ClientManagementConfiguration.Access.class) -@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({OidcClient.class,ClientRegistry.Created.class}) +@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({OidcClient.class,ClientRepository.Created.class}) class ClientManagementConfiguration { @ConfigurationProperties("iam.clients") record Access(Set adminGroupDns) { @@ -36,7 +35,7 @@ class ClientManagementConfiguration { adminGroupDns.stream().anyMatch(admin -> dn(admin).equals(dn(group.externalId())))); } } - @Bean ClientRegistry clientRegistry(HydraProperties properties) { return new HydraClientRegistry(properties); } + @Bean ClientRepository clientRepository(org.springframework.web.client.RestClient hydraAdminHttpClient) { return new HydraClientRepository(hydraAdminHttpClient); } @Bean @Order(2) SecurityFilterChain clientManagement(HttpSecurity http,MfaPolicy mfa,Access access) throws Exception { return http.securityMatcher("/api/iam/**").redirectToHttps(org.springframework.security.config.Customizer.withDefaults()) .authorizeHttpRequests(auth -> auth.anyRequest().access((authentication,request) -> { diff --git a/src/main/java/top/ddupan/iam/login/configuration/HydraConfiguration.java b/src/main/java/top/ddupan/iam/login/configuration/HydraConfiguration.java index 33ed601..67d0967 100644 --- a/src/main/java/top/ddupan/iam/login/configuration/HydraConfiguration.java +++ b/src/main/java/top/ddupan/iam/login/configuration/HydraConfiguration.java @@ -14,15 +14,23 @@ import top.ddupan.iam.login.authorization.infrastructure.hydra.*; @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(prefix = "iam.hydra", name = "enabled", havingValue = "true") -@EnableConfigurationProperties(HydraProperties.class) +@EnableConfigurationProperties({HydraProperties.class, HydraHttpProperties.class}) @org.springframework.aot.hint.annotation.RegisterReflectionForBinding({HydraAdminClient.Request.class, - HydraLogoutClient.Response.class, HydraLogoutClient.LogoutClient.class, HydraAdminClient.Client.class, HydraAdminClient.Context.class, HydraAdminClient.Redirect.class}) + HydraAdminClient.LogoutResponse.class, HydraAdminClient.LogoutClient.class, HydraAdminClient.Client.class, HydraAdminClient.Context.class, HydraAdminClient.Redirect.class}) class HydraConfiguration { - @Bean top.ddupan.iam.login.authorization.application.port.LogoutGateway logoutGateway(HydraProperties properties) { - return new HydraLogoutClient(properties); + @Bean + org.springframework.web.client.RestClient hydraAdminHttpClient( + org.springframework.web.client.RestClient.Builder builder, HydraProperties properties, + HydraHttpProperties timeouts) { + var http = java.net.http.HttpClient.newBuilder().connectTimeout(timeouts.connectTimeout()) + .followRedirects(java.net.http.HttpClient.Redirect.NEVER).build(); + var factory = new org.springframework.http.client.JdkClientHttpRequestFactory(http); + factory.setReadTimeout(timeouts.readTimeout()); + return builder.baseUrl(properties.adminUrl().toString()).requestFactory(factory).build(); } - @Bean HydraGateway hydraGateway(HydraProperties properties, MfaPolicy requiredMfa) { - return new HydraAdminClient(properties); + @Bean HydraGateway hydraGateway(HydraProperties properties, MfaPolicy requiredMfa, + org.springframework.web.client.RestClient hydraAdminHttpClient) { + return new HydraAdminClient(properties, hydraAdminHttpClient); } @Bean AuthorizationPolicy authorizationPolicy(HydraProperties properties) { return new AuthorizationPolicy(properties.clients(), properties.subjects().stream().collect(Collectors.toMap( 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 f5d06a9..20368ab 100644 --- a/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java +++ b/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java @@ -44,7 +44,7 @@ class SecurityConfiguration { @ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true") SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords, ObjectProvider webAuthn, - ObjectProvider logoutGateway) throws Exception { + ObjectProvider logoutGateway) throws Exception { var passwordFactor = AuthorizationManagerFactories.multiFactor() .requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10))) .build(); @@ -71,7 +71,7 @@ class SecurityConfiguration { var gateway = logoutGateway.getIfAvailable(); response.setStatus(303); response.setHeader("Location",gateway!=null && PathPatternRequestMatcher.withDefaults().matcher("/signin/logout").matches(request) - ? gateway.startUrl() : "/signin"); + ? gateway.logoutUrl() : "/signin"); })) .exceptionHandling(exceptions -> exceptions .defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/signin"), diff --git a/src/main/java/top/ddupan/iam/login/configuration/WebAuthnConfiguration.java b/src/main/java/top/ddupan/iam/login/configuration/WebAuthnConfiguration.java index 35b3c0d..8a0c989 100644 --- a/src/main/java/top/ddupan/iam/login/configuration/WebAuthnConfiguration.java +++ b/src/main/java/top/ddupan/iam/login/configuration/WebAuthnConfiguration.java @@ -3,11 +3,8 @@ 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; @@ -22,13 +19,8 @@ import top.ddupan.iam.login.authentication.infrastructure.webauthn.*; @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(prefix = "iam.webauthn", name = "enabled", havingValue = "true") -@EnableConfigurationProperties({WebAuthnProperties.class, DataSourceProperties.class}) +@EnableConfigurationProperties(WebAuthnProperties.class) class WebAuthnConfiguration { - @Bean - DataSource webAuthnDataSource(DataSourceProperties properties) { - return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build(); - } - @Bean PublicKeyCredentialUserEntityRepository credentialUsers(JdbcOperations jdbc) { return new JdbcPublicKeyCredentialUserEntityRepository(jdbc); @@ -62,8 +54,9 @@ class WebAuthnConfiguration { .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)); + return new DirectoryRelyingPartyOperations(delegate, policy, users, credentials, + new top.ddupan.iam.login.authentication.infrastructure.persistence.JdbcCredentialRegistration( + jdbc, new TransactionTemplate(transactions))); } @Bean diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3583e46..25d34e1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,6 +1,4 @@ spring: - autoconfigure: - exclude: org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration application: name: iam-login management: diff --git a/src/test/java/top/ddupan/iam/login/IamLoginApplicationTests.java b/src/test/java/top/ddupan/iam/login/IamLoginApplicationTests.java index ddac059..ece21df 100644 --- a/src/test/java/top/ddupan/iam/login/IamLoginApplicationTests.java +++ b/src/test/java/top/ddupan/iam/login/IamLoginApplicationTests.java @@ -21,7 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@SpringBootTest +@SpringBootTest(properties = "spring.autoconfigure.exclude=org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration") @AutoConfigureMockMvc @AutoConfigureMetrics @AutoConfigureTracing 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 index c98957f..232101b 100644 --- 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 @@ -25,7 +25,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder 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.clients.admin-group-dns[0]=CN=gitea-admins,dc=example,dc=test", "iam.hydra.enabled=true", "iam.hydra.admin-url=http://127.0.0.1:14445", "iam.hydra.public-url=http://localhost:14444", + "spring.datasource.hikari.maximum-pool-size=3", "iam.clients.admin-group-dns[0]=CN=gitea-admins,dc=example,dc=test", "iam.hydra.enabled=true", "iam.hydra.admin-url=http://127.0.0.1:14445", "iam.hydra.public-url=http://localhost:14444", "iam.hydra.clients[0]=gitea-fixture", "iam.hydra.subjects[0].authority=example.test", "iam.hydra.subjects[0].directory-id=00112233-4455-6677-8899-aabbccddeeff", "iam.hydra.subjects[0].subject=human:existing-subject", @@ -55,9 +55,52 @@ class WebAuthnIntegrationTests { @org.springframework.test.context.bean.override.mockito.MockitoBean - top.ddupan.iam.login.clients.application.ClientRegistry clients; - @org.springframework.test.context.bean.override.mockito.MockitoBean - top.ddupan.iam.login.authorization.application.port.LogoutGateway logout; + top.ddupan.iam.login.clients.domain.ClientRepository clients; + + @Autowired org.springframework.transaction.PlatformTransactionManager transactionManager; + @Autowired javax.sql.DataSource dataSource; + + @Test void registrationLockSerializesAndRollsBackOnFailure() throws Exception { + assertThat(((com.zaxxer.hikari.HikariDataSource)dataSource).getMaximumPoolSize()).isEqualTo(3); + var registrations = new top.ddupan.iam.login.authentication.infrastructure.persistence.JdbcCredentialRegistration( + jdbc, new org.springframework.transaction.support.TransactionTemplate(transactionManager)); + String id = java.util.UUID.randomUUID().toString(); + jdbc.update("insert into user_entities(id,name,display_name) values (?,?,?)", id,id,"before"); + var locked = new java.util.concurrent.CountDownLatch(1); + var release = new java.util.concurrent.CountDownLatch(1); + var started = new java.util.concurrent.CountDownLatch(1); + try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { + var first = executor.submit(() -> registrations.register(id, () -> { + jdbc.update("update user_entities set display_name='committed' where id=?",id); + locked.countDown(); + try { + if (!release.await(5,java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("Test timed out"); + } catch (InterruptedException ex) { Thread.currentThread().interrupt(); throw new IllegalStateException(ex); } + return true; + })); + try { + assertThat(locked.await(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + var second = executor.submit(() -> { + started.countDown(); + return registrations.register(id, () -> jdbc.queryForObject( + "select display_name from user_entities where id=?",String.class,id)); + }); + assertThat(started.await(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + assertThatThrownBy(() -> second.get(200,java.util.concurrent.TimeUnit.MILLISECONDS)) + .isInstanceOf(java.util.concurrent.TimeoutException.class); + release.countDown(); + assertThat(first.get(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + assertThat(second.get(5,java.util.concurrent.TimeUnit.SECONDS)).isEqualTo("committed"); + } finally { release.countDown(); } + } + assertThatThrownBy(() -> registrations.register(id, () -> { + jdbc.update("update user_entities set display_name='rolled back' where id=?",id); + throw new IllegalStateException("Registration rejected"); + })).isInstanceOf(IllegalStateException.class); + assertThat(jdbc.queryForObject("select display_name from user_entities where id=?",String.class,id)) + .isEqualTo("committed"); + jdbc.update("delete from user_entities where id=?",id); + } @Test void clientAdministrationRequiresMfaAdminGroupAndCsrf() throws Exception { @@ -85,11 +128,11 @@ class WebAuthnIntegrationTests { @Test void logoutRequiresCsrfBrowserBindingAndInvalidatesLocalSession() throws Exception { var session=login(); secondFactor(session); - var data=new top.ddupan.iam.login.authorization.application.port.LogoutGateway.Request( + var data=new top.ddupan.iam.login.authorization.application.port.HydraGateway.LogoutRequest( "logout-challenge","human:existing-subject","sid",""); - org.mockito.Mockito.when(logout.request("logout-challenge")).thenReturn(data); - org.mockito.Mockito.when(logout.startUrl()).thenReturn("http://localhost:14444/oauth2/sessions/logout"); - org.mockito.Mockito.when(logout.accept("logout-challenge")).thenReturn("http://localhost:14444/oauth2/sessions/logout?logout_verifier=fixture"); + org.mockito.Mockito.when(hydra.logout("logout-challenge")).thenReturn(data); + org.mockito.Mockito.when(hydra.logoutUrl()).thenReturn("http://localhost:14444/oauth2/sessions/logout"); + org.mockito.Mockito.when(hydra.acceptLogout("logout-challenge")).thenReturn("http://localhost:14444/oauth2/sessions/logout?logout_verifier=fixture"); var html=mvc.perform(get("/oauth2/logout").session(session).with(https()).param("logout_challenge","logout-challenge")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); var match=java.util.regex.Pattern.compile("\"binding\":\"([^\"]+)\"").matcher(html); @@ -100,7 +143,7 @@ class WebAuthnIntegrationTests { mvc.perform(post("/oauth2/logout").session(session).with(https()).with(csrf()).param("binding",binding)) .andExpect(status().isSeeOther()); assertThat(session.isInvalid()).isTrue(); - org.mockito.Mockito.verify(logout).accept("logout-challenge"); + org.mockito.Mockito.verify(hydra).acceptLogout("logout-challenge"); } @Test diff --git a/src/test/java/top/ddupan/iam/login/authentication/interfaces/web/SignInIntegrationTests.java b/src/test/java/top/ddupan/iam/login/authentication/interfaces/web/SignInIntegrationTests.java index 259608f..ad9a28a 100644 --- a/src/test/java/top/ddupan/iam/login/authentication/interfaces/web/SignInIntegrationTests.java +++ b/src/test/java/top/ddupan/iam/login/authentication/interfaces/web/SignInIntegrationTests.java @@ -25,7 +25,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; /** Real LDAPS sockets and Spring Data LDAP; AD bind/subcode semantics are simulated. */ -@SpringBootTest(properties = {"iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test", +@SpringBootTest(properties = {"spring.autoconfigure.exclude=org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", "iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test", "management.otlp.metrics.export.enabled=false", "spring.security.user.name=fixture-monitor", "spring.security.user.password=fixture-monitor-password"}) @AutoConfigureMockMvc @org.springframework.boot.micrometer.metrics.test.autoconfigure.AutoConfigureMetrics diff --git a/src/test/java/top/ddupan/iam/login/authorization/HydraAdminClientTests.java b/src/test/java/top/ddupan/iam/login/authorization/HydraAdminClientTests.java index 20c9bbc..ff19277 100644 --- a/src/test/java/top/ddupan/iam/login/authorization/HydraAdminClientTests.java +++ b/src/test/java/top/ddupan/iam/login/authorization/HydraAdminClientTests.java @@ -14,6 +14,13 @@ import top.ddupan.iam.login.authorization.infrastructure.hydra.*; import static org.assertj.core.api.Assertions.*; class HydraAdminClientTests { + private org.springframework.web.client.RestClient http(HttpServer server) { + var factory = new org.springframework.http.client.JdkClientHttpRequestFactory( + java.net.http.HttpClient.newBuilder().followRedirects(java.net.http.HttpClient.Redirect.NEVER).build()); + return org.springframework.web.client.RestClient.builder() + .baseUrl("http://127.0.0.1:" + server.getAddress().getPort()).requestFactory(factory).build(); + } + @Test void logoutAcceptsOriginRelativeRequestsButRejectsForeignOrigins() throws Exception { var payload = new AtomicReference(); var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); @@ -25,14 +32,14 @@ class HydraAdminClientTests { }); server.start(); try { - var client = new HydraLogoutClient(new HydraProperties(true, + var client = new HydraAdminClient(new HydraProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()), URI.create("http://localhost:14444"), - Set.of(), List.of())); + Set.of(), List.of()), http(server)); payload.set("{\"challenge\":\"challenge\",\"subject\":\"subject\",\"sid\":\"session\",\"request_url\":\"/oauth2/sessions/logout\"}"); - assertThat(client.request("challenge").subject()).isEqualTo("subject"); + assertThat(client.logout("challenge").subject()).isEqualTo("subject"); for (String url : List.of("//attacker.example/oauth2/sessions/logout", "https://attacker.example/oauth2/sessions/logout", "/admin/clients")) { payload.set("{\"challenge\":\"challenge\",\"request_url\":\"" + url + "\"}"); - assertThatIllegalArgumentException().isThrownBy(() -> client.request("challenge")); + assertThatIllegalArgumentException().isThrownBy(() -> client.logout("challenge")); } } finally { server.stop(0); } } @@ -53,7 +60,7 @@ class HydraAdminClientTests { try { var client = new HydraAdminClient(new HydraProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()), URI.create("http://localhost:14444"), - Set.of("gitea"), List.of())); + Set.of("gitea"), List.of()), http(server)); assertThat(client.acceptLogin("challenge", "subject", "binding", Instant.now())) .startsWith("http://localhost:14444/oauth2/auth?"); for (var target : List.of("https://attacker.example/oauth2/auth", "http://localhost:14444/admin/clients", diff --git a/src/test/java/top/ddupan/iam/login/clients/HydraRegistryIntegrationTests.java b/src/test/java/top/ddupan/iam/login/clients/HydraRepositoryIntegrationTests.java similarity index 92% rename from src/test/java/top/ddupan/iam/login/clients/HydraRegistryIntegrationTests.java rename to src/test/java/top/ddupan/iam/login/clients/HydraRepositoryIntegrationTests.java index 552874e..b263e47 100644 --- a/src/test/java/top/ddupan/iam/login/clients/HydraRegistryIntegrationTests.java +++ b/src/test/java/top/ddupan/iam/login/clients/HydraRepositoryIntegrationTests.java @@ -9,13 +9,12 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; -import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties; -import top.ddupan.iam.login.clients.application.ClientRegistryException; +import top.ddupan.iam.login.clients.domain.ClientRepositoryException; import top.ddupan.iam.login.clients.domain.OidcClient; -import top.ddupan.iam.login.clients.infrastructure.HydraClientRegistry; +import top.ddupan.iam.login.clients.infrastructure.HydraClientRepository; import static org.assertj.core.api.Assertions.*; -class HydraRegistryIntegrationTests { +class HydraRepositoryIntegrationTests { private static final String IMAGE="oryd/hydra:v26.2.0@sha256:ff67c7fb5f95074fa53374d41151713554960504b340cd3f95b09e65deaea2a9"; private static final URI ADMIN=URI.create("http://127.0.0.1:14845"); @Test void crudPersistsAcrossIssuerRestartAndDoesNotReturnSecretsOnRead() throws Exception { @@ -33,7 +32,7 @@ class HydraRegistryIntegrationTests { .withEnv("URLS_SELF_ISSUER","http://localhost:14844/").withEnv("LOG_LEVEL","error") .withEnv("SECRETS_SYSTEM","fixture-only-stable-system-secret-32-characters").withCommand("serve","all","--dev")) { hydra.start(); ready(); - var registry=new HydraClientRegistry(new HydraProperties(true,ADMIN,URI.create("http://localhost:14844"),Set.of(),List.of())); + var registry=new HydraClientRepository(org.springframework.web.client.RestClient.builder().baseUrl(ADMIN.toString()).build()); var client=new OidcClient("managed-fixture","Fixture",List.of("https://rp.example/callback"),Set.of("openid","groups"), List.of("https://rp.example/bye"),"https://rp.example/backchannel","",true); var created=registry.create(client); @@ -57,8 +56,8 @@ class HydraRegistryIntegrationTests { assertThat(response.body()).contains("invalid_grant").doesNotContain("invalid_client"); } registry.delete(client.id()); - assertThatThrownBy(() -> registry.get(client.id())).isInstanceOfSatisfying(ClientRegistryException.class, - ex -> assertThat(ex.kind()).isEqualTo(ClientRegistryException.Kind.NOT_FOUND)); + assertThatThrownBy(() -> registry.get(client.id())).isInstanceOfSatisfying(ClientRepositoryException.class, + ex -> assertThat(ex.kind()).isEqualTo(ClientRepositoryException.Kind.NOT_FOUND)); } } }