From 62b6e9db408e0b5a08ec5529e685edfd3217f193 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Mon, 28 Sep 2026 12:30:06 +0000 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Hydra=20=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E3=80=81=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=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(); } +}