Merge pull request '接入 WebAuthn MFA、Hydra 授权与客户端管理' (#6) from feat/webauthn-mfa into main
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# iam-login
|
# iam-login
|
||||||
|
|
||||||
独立 IAM 的登录与认证服务,以 Java、Spring Security 和 GraalVM Native 实现,作为
|
独立 IAM 的登录与认证服务,以 Java、Spring Security 和 GraalVM Native 实现,作为
|
||||||
Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及等待 MFA 的浏览器页面;MFA 与 Hydra 登录链路仍待实现。
|
Hydra 的 Login/Consent 应用。已实现 AD 密码与直接所属组查询,以及 WebAuthn 第二因素浏览器流程;已实现 Hydra Login/Consent 的隔离接入,生产切换仍待验收。
|
||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ npm --prefix frontend run build
|
|||||||
./gradlew bootRun
|
./gradlew bootRun
|
||||||
```
|
```
|
||||||
|
|
||||||
|
应用启动需要配置 PostgreSQL 数据源,连接与开发容器说明见[第二因素](docs/webauthn.md)。
|
||||||
测试需要可用的 Docker,生成器配置了 Grafana LGTM Testcontainer。
|
测试需要可用的 Docker,生成器配置了 Grafana LGTM Testcontainer。
|
||||||
测试覆盖 AD 第一因素、浏览器流程和监控集成。人类登录统一从 `/signin` 进入。
|
测试覆盖 AD 第一因素、浏览器流程和监控集成。人类登录统一从 `/signin` 进入。
|
||||||
使用 GraalVM 25 验证原生测试与编译:
|
使用 GraalVM 25 验证原生测试与编译:
|
||||||
@@ -68,11 +69,11 @@ JVM、AOT、Native 测试及原生应用 HTTP 检查已通过,实测范围与
|
|||||||
[本地验证结果](docs/bootstrap.md#2026-09-25-本地验证结果)。
|
[本地验证结果](docs/bootstrap.md#2026-09-25-本地验证结果)。
|
||||||
新增 AD 路径本轮按维护者要求只进行 JVM 验证,不沿用基线的 Native 验收结论。
|
新增 AD 路径本轮按维护者要求只进行 JVM 验证,不沿用基线的 Native 验收结论。
|
||||||
重构前的真实目录密码与属性/直接所属组读取已通过维护者浏览器验收;Spring Data LDAP
|
重构前的真实目录密码与属性/直接所属组读取已通过维护者浏览器验收;Spring Data LDAP
|
||||||
版本已通过 JVM 和浏览器回归,真实人类复验待反馈。MFA 与 Hydra 链路仍待实现。
|
版本已通过 JVM 和浏览器回归,真实人类复验待反馈。WebAuthn 实现与验证边界见 [第二因素](docs/webauthn.md),Hydra 接入方式与生产主体映射边界见 [Login/Consent](docs/hydra-login.md)。
|
||||||
|
|
||||||
## 领域与代码组织
|
## 领域与代码组织
|
||||||
|
|
||||||
当前限界上下文为 `authentication`,使用 DDD 分层,依赖向领域内部收敛:
|
当前限界上下文为 `authentication`、`authorization` 与 `clients`,使用 DDD 分层,依赖向领域内部收敛:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
interfaces/web → infrastructure/security(principal)+ domain
|
interfaces/web → infrastructure/security(principal)+ domain
|
||||||
@@ -87,8 +88,12 @@ configuration → 装配上述实现
|
|||||||
- `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。
|
- `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。
|
||||||
使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。
|
使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。
|
||||||
- `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。
|
- `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。
|
||||||
|
- `authentication/infrastructure/persistence`:封装注册并发锁及事务,不把表结构带入 Security 策略。
|
||||||
|
- `authentication/infrastructure/webauthn`:凭据归属与注册策略、challenge 仓储扩展;密码学校验和 JDBC 存储交给 Spring Security。
|
||||||
- `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。
|
- `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。
|
||||||
- `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。
|
- `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。
|
||||||
|
- `authorization`:领域请求、应用授权用例与策略、Hydra Admin 基础设施、浏览器请求绑定分层维护;客户端注册与签发仍归 Hydra。
|
||||||
|
- `clients/domain/ClientRepository`:客户端仓储契约,由 Hydra HTTP 实现;受 MFA 与直接管理组保护的客户端 CRUD;持久化与密钥由 Hydra 持有,见[管理接口](docs/client-management.md)。
|
||||||
- `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。
|
- `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。
|
||||||
|
|
||||||
测试覆盖应用用例、AD 仓储和完整 Spring Security 过滤器链,LDAP 夹具集中在测试 `support` 包。
|
测试覆盖应用用例、AD 仓储和完整 Spring Security 过滤器链,LDAP 夹具集中在测试 `support` 包。
|
||||||
|
|||||||
@@ -21,11 +21,16 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-flyway'
|
||||||
|
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
|
||||||
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-ldap'
|
implementation 'org.springframework.boot:spring-boot-starter-data-ldap'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'
|
implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
|
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-restclient'
|
||||||
implementation 'org.springframework.security:spring-security-webauthn'
|
implementation 'org.springframework.security:spring-security-webauthn'
|
||||||
compileOnly 'org.projectlombok:lombok'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
@@ -42,6 +47,7 @@ dependencies {
|
|||||||
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
|
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
|
||||||
testImplementation 'com.unboundid:unboundid-ldapsdk'
|
testImplementation 'com.unboundid:unboundid-ldapsdk'
|
||||||
testImplementation 'org.testcontainers:testcontainers-grafana'
|
testImplementation 'org.testcontainers:testcontainers-grafana'
|
||||||
|
testImplementation 'org.testcontainers:testcontainers-postgresql'
|
||||||
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||||
testCompileOnly 'org.projectlombok:lombok'
|
testCompileOnly 'org.projectlombok:lombok'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
@@ -83,3 +89,18 @@ tasks.named('processResources') {
|
|||||||
}
|
}
|
||||||
from('frontend/dist') { into 'ui' }
|
from('frontend/dist') { into 'ui' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explicit test-only entry point for browser WebAuthn ceremonies; no fixture endpoints in production.
|
||||||
|
tasks.register('webauthnBrowserFixture', JavaExec) {
|
||||||
|
dependsOn tasks.named('testClasses')
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
mainClass = 'top.ddupan.iam.login.WebAuthnBrowserFixture'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Only the development credential database. Supply IAM_DEV_DB_PASSWORD outside Git.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: iam_login
|
||||||
|
POSTGRES_USER: iam_login
|
||||||
|
POSTGRES_PASSWORD: ${IAM_DEV_DB_PASSWORD:?Set IAM_DEV_DB_PASSWORD outside Git}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${IAM_DEV_DB_PORT:-15432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U iam_login -d iam_login"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
cpus: 1
|
||||||
|
mem_limit: 512m
|
||||||
|
volumes:
|
||||||
|
postgres:
|
||||||
+3
-2
@@ -10,8 +10,9 @@ try-with-resources 管理已认证用户仓储会话;基础设施的 `AdUserRe
|
|||||||
`AdUserEntry` 的 LDAP 注解不会进入领域对象。
|
`AdUserEntry` 的 LDAP 注解不会进入领域对象。
|
||||||
|
|
||||||
成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。
|
成功后重定向到 `/signin/mfa`,显示目录账号、objectGUID、邮箱、直接所属组及组 DN。
|
||||||
**这是密码因素验收页面,MFA 尚未接入,不是完整登录成功。** Spring Security 保存
|
**密码成功本身不是完整登录成功。** Spring Security 先保存仅含 `FACTOR_PASSWORD`
|
||||||
仅含 `FACTOR_PASSWORD` 的认证结果;其余应用请求使用 `denyAll`,不调用 Hydra,
|
的认证结果;启用 [WebAuthn](webauthn.md) 后在此继续第二因素,否则停留在等待页面。
|
||||||
|
未实现的应用请求使用 `denyAll`,不调用 Hydra,
|
||||||
不替换现役 Go/Authelia/Gitea 登录链路。
|
不替换现役 Go/Authelia/Gitea 登录链路。
|
||||||
|
|
||||||
## 目录和组语义
|
## 目录和组语义
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# OAuth2/OIDC 客户端管理
|
||||||
|
|
||||||
|
本服务通过受限 API 管理 Hydra 中的第一方 confidential authorization-code 客户端。
|
||||||
|
领域层定义 `ClientRepository`,基础设施的 `HydraClientRepository` 实现远端持久化。
|
||||||
|
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 和应用控制。
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# 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
|
||||||
|
http:
|
||||||
|
connect-timeout: 3s
|
||||||
|
read-timeout: 5s
|
||||||
|
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;不能公开管理端口。
|
||||||
|
HTTP client 在配置层使用 Boot 提供的 `RestClient.Builder` 装配并注入,保留框架定制与观测能力。
|
||||||
|
上述连接/读取超时有默认值且必须为正,可按部署环境覆盖;禁止跟随重定向不提供关闭开关。
|
||||||
|
Login、Consent 和 Logout 共用 `HydraGateway` 与 Admin client。
|
||||||
|
公共 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)。
|
||||||
@@ -29,6 +29,10 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需
|
|||||||
- 登录 Controller 与安全链使用 `@ConditionalOnProperty(iam.ad.enabled)`;AOT 在构建时
|
- 登录 Controller 与安全链使用 `@ConditionalOnProperty(iam.ad.enabled)`;AOT 在构建时
|
||||||
决定 bean 是否存在。AD Native 产物必须在 AOT 阶段启用此属性,验证实际入口与兜底链,
|
决定 bean 是否存在。AD Native 产物必须在 AOT 阶段启用此属性,验证实际入口与兜底链,
|
||||||
不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。
|
不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。
|
||||||
|
- WebAuthn 新增的 JDBC/Flyway/PostgreSQL、WebAuthn4J 校验与 JSON 路径本轮仅做 JVM 验证;
|
||||||
|
Native AOT 时还需启用 `iam.webauthn.enabled`,不得套用基础骨架的 Native 结论。
|
||||||
|
- Hydra 的 RestClient/JDK HTTP 与 JSON DTO 新增反射绑定声明;仍需在启用 `iam.hydra.enabled`
|
||||||
|
的 Native 产物上实际运行授权码流程,JVM 接入结果不构成该项验收。
|
||||||
- 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。
|
- 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。
|
||||||
- LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。
|
- LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。
|
||||||
- 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后
|
- 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后
|
||||||
@@ -40,3 +44,10 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需
|
|||||||
|
|
||||||
通过上述测试后才准备生产切换;保留现役 OIDC 上游适配器作为回退路径。应用镜像以
|
通过上述测试后才准备生产切换;保留现役 OIDC 上游适配器作为回退路径。应用镜像以
|
||||||
不可变 digest 交给 homelab-infra,部署状态与真实人类 MFA 验收分别记录。
|
不可变 digest 交给 homelab-infra,部署状态与真实人类 MFA 验收分别记录。
|
||||||
|
|
||||||
|
### 测试 AOT 的待排查项(2026-09-29)
|
||||||
|
|
||||||
|
本轮重构的 `processTestAot` 在处理测试上下文后未退出;线程栈显示 `DestroyJavaVM`
|
||||||
|
等待测试夹具的非 daemon LDAP listener。该轮日常验证使用
|
||||||
|
`test bootJar -x processTestAot -x compileAotTestJava -x processAotTestResources`
|
||||||
|
执行 JVM 测试,不把该结果视为测试 AOT 或 Native 验收。阶段性原生验证前需解决夹具生命周期。
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# WebAuthn 第二因素
|
||||||
|
|
||||||
|
WebAuthn 使用 Spring Security 官方过滤器、WebAuthn4J 校验和 JDBC 仓储。
|
||||||
|
PostgreSQL 保存 user handle、凭据公钥与签名计数等记录,不保存用户密码或认证器私钥。
|
||||||
|
AD 仍为用户与组权威;凭据按目录 authority + objectGUID 关联,用户名改名不会换主体。
|
||||||
|
|
||||||
|
## 登录与注册
|
||||||
|
|
||||||
|
1. `/signin` 使用原生表单 POST 验证 AD 密码,建立 `FACTOR_PASSWORD`。
|
||||||
|
2. `/signin/mfa`:没有凭据时注册 passkey;已有凭据时验证 passkey。
|
||||||
|
3. 注册只保存凭据,必须再次实际验证,才取得 `FACTOR_WEBAUTHN`。
|
||||||
|
4. `/signin/complete` 要求两种因素均在 10 分钟内有效;单独访问显示验证结果;存在 Hydra 请求时继续 [Login/Consent](hydra-login.md)。
|
||||||
|
|
||||||
|
首次注册信任近期 AD 密码验证。已有凭据后的新增注册同时要求密码与 WebAuthn 因素;
|
||||||
|
本轮 UI 只提供首次注册与验证,不提供新增管理、删除或自助恢复入口。按维护者确认的
|
||||||
|
自用范围,遗失全部 passkey 由管理员人工操作数据库恢复,不以开发恢复 UI 作为上线条件。
|
||||||
|
人工恢复只处理核实后的目标主体凭据,并按首次注册规则重新绑定;不能清空整个凭据库。
|
||||||
|
AD 用户、密码和组继续通过 RSAT 或目录命令行管理,不在本应用增加目录管理页面。
|
||||||
|
注册和验证均要求认证器 user verification(例如 PIN 或生物识别)。
|
||||||
|
|
||||||
|
Spring Security 负责因素合并、会话轮换、退出和 CSRF。应用仅补目录主体与凭据所有权
|
||||||
|
限制、首次注册并发检查,以及 challenge 的 5 分钟服务端有效期和单次消费。
|
||||||
|
没有应用自建登录状态机或认证 Filter。上游 options Filter 位于授权 Filter 前,因而
|
||||||
|
这些检查在 RelyingPartyOperations 扩展点执行,不能仅靠 URL 授权规则。
|
||||||
|
|
||||||
|
## 本地数据库
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 密码从 shell 或外部权限为 0600 的 env 文件提供;不要写入版本库。
|
||||||
|
docker compose -p iam-login-dev -f compose.dev.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
必须设置 `IAM_DEV_DB_PASSWORD`。PostgreSQL 仅发布在 `127.0.0.1:15432`,可用
|
||||||
|
`IAM_DEV_DB_PORT` 调整端口;数据保存在 Compose named volume 中。
|
||||||
|
普通 `down` 不删数据,**不要使用 `down -v`**,否则会删除已注册凭据。
|
||||||
|
|
||||||
|
应用额外配置:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
iam:
|
||||||
|
webauthn:
|
||||||
|
enabled: true
|
||||||
|
rp-id: laptop.tail7e769.ts.net
|
||||||
|
origin: https://laptop.tail7e769.ts.net:18082
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://127.0.0.1:15432/iam_login
|
||||||
|
username: iam_login
|
||||||
|
password: ${IAM_DEV_DB_PASSWORD}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时启用并配置 [AD](ad-login.md)。RP ID 不含协议与端口,origin 必须与浏览器实际
|
||||||
|
HTTPS 入口完全一致;改变 RP 域名后旧凭据不能直接在新域名使用。
|
||||||
|
凭据 schema 由 Flyway 管理,采用 Spring Security 7.1.1 官方 PostgreSQL 表结构,
|
||||||
|
增加主体名称唯一约束和凭据所有者索引。未启用 WebAuthn 时不创建 DataSource,AD-only
|
||||||
|
路径不需要数据库。数据库不可用时 MFA 失败,不降级为仅密码通过。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
JVM 回归使用 Testcontainers PostgreSQL 与模拟 AD;不会向真实 AD 提交测试密码。
|
||||||
|
浏览器用 Chromium 虚拟认证器产生真实注册/断言签名,再交给后端校验:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/gradle-in-docker test
|
||||||
|
scripts/gradle-in-docker webauthnBrowserFixture
|
||||||
|
# 另一终端;测试夹具固定监听 localhost:18083,使用测试证书。
|
||||||
|
IAM_WEBAUTHN_FIXTURE=1 npm --prefix frontend run test:browser -- webauthn.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
测试专用启动类仅在 test classpath,不进入生产 JAR,也不提供生产调试 API。
|
||||||
|
维护者已确认开发入口的 AD + passkey 人类路径能够工作。更多认证器兼容性和 Native 路径
|
||||||
|
仍需独立验收,不能套用虚拟认证器结果。生产共享 PostgreSQL 的接入留在部署阶段。
|
||||||
|
|
||||||
|
## 数据源与注册事务
|
||||||
|
|
||||||
|
PostgreSQL 是应用运行依赖,通过 `spring.datasource.*` 配置,连接池参数使用
|
||||||
|
`spring.datasource.hikari.*`。DataSource、JdbcTemplate 与事务管理器由 Boot 自动配置,
|
||||||
|
WebAuthn 配置仅装配官方凭据仓储及认证策略;不在应用配置中排除 DataSource 自动配置。
|
||||||
|
仅不涉及持久化的独立测试显式排除它。
|
||||||
|
|
||||||
|
首次注册的数据库锁由 `authentication/infrastructure/persistence/JdbcCredentialRegistration`
|
||||||
|
封装;获得用户行锁后,在同一事务中重新检查注册策略并调用官方凭据保存逻辑。
|
||||||
|
WebAuthn 策略集成不直接引用 SQL 或表结构。失败回滚与跨连接串行化由 PostgreSQL 集成测试覆盖。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { CsrfToken } from "../page-context";
|
||||||
|
|
||||||
|
export function Passkey({ csrf, initial }: { csrf: CsrfToken; initial: "register" | "authenticate" }) {
|
||||||
|
const [step, setStep] = useState(initial);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [registered, setRegistered] = useState(false);
|
||||||
|
|
||||||
|
async function post(path: string, body?: unknown) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: "POST", credentials: "same-origin", redirect: "error",
|
||||||
|
headers: { "Content-Type": "application/json", [csrf.headerName]: csrf.value },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) {
|
||||||
|
throw new Error("验证未完成,请重试;若登录已过期,请退出并重新验证密码。");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function perform() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
if (!PublicKeyCredential.parseCreationOptionsFromJSON || !PublicKeyCredential.parseRequestOptionsFromJSON) {
|
||||||
|
throw new Error("请使用支持 passkey 的新版浏览器。");
|
||||||
|
}
|
||||||
|
if (step === "register") {
|
||||||
|
const options = await post("/webauthn/register/options");
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
|
||||||
|
}) as PublicKeyCredential | null;
|
||||||
|
if (!credential) throw new Error("注册已取消。");
|
||||||
|
await post("/webauthn/register", { publicKey: { credential: credential.toJSON(), label: "Passkey" } });
|
||||||
|
setRegistered(true);
|
||||||
|
setStep("authenticate");
|
||||||
|
} else {
|
||||||
|
const options = await post("/webauthn/authenticate/options");
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
|
||||||
|
}) as PublicKeyCredential | null;
|
||||||
|
if (!credential) throw new Error("验证已取消。");
|
||||||
|
await post("/login/webauthn", credential.toJSON());
|
||||||
|
window.location.assign("/signin/complete");
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof DOMException ? "操作已取消或认证器不可用,可以重试。"
|
||||||
|
: cause instanceof Error ? cause.message : "验证未完成,请重试。");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="passkey">
|
||||||
|
{registered && <p role="status">Passkey 已保存,请验证一次以完成第二因素。</p>}
|
||||||
|
{step === "register" && <p>首次使用,请注册 passkey。后续登录仍需 AD 密码和 passkey。</p>}
|
||||||
|
{error && <p className="error" role="alert">{error}</p>}
|
||||||
|
<button type="button" disabled={busy} onClick={perform}>
|
||||||
|
{busy ? "等待认证器…" : step === "register" ? "注册 Passkey" : "验证 Passkey"}
|
||||||
|
</button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
export type CsrfToken = { name: string; value: string };
|
export type CsrfToken = { name: string; value: string; headerName: string };
|
||||||
|
|
||||||
type PageBase = {
|
type PageBase = {
|
||||||
name: string;
|
name: string;
|
||||||
error: string;
|
error: string;
|
||||||
action: string;
|
action: string;
|
||||||
csrf: CsrfToken;
|
csrf: CsrfToken;
|
||||||
|
logoutAction?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignInContext = PageBase & (
|
export type SignInContext = PageBase & (
|
||||||
| { step: "password" }
|
| { step: "password" }
|
||||||
|
| { step: "logout"; binding: string }
|
||||||
|
| { step: "authorize"; binding: string; client: string; scopes: string[] }
|
||||||
| {
|
| {
|
||||||
step: "mfa-pending";
|
step: "mfa-pending" | "mfa-complete";
|
||||||
|
passkey: "unavailable" | "register" | "authenticate";
|
||||||
identity: {
|
identity: {
|
||||||
username: string;
|
username: string;
|
||||||
subjectId: string;
|
subjectId: string;
|
||||||
@@ -25,7 +29,7 @@ export function readPageContext(): SignInContext {
|
|||||||
const data = document.getElementById("login-context")?.textContent;
|
const data = document.getElementById("login-context")?.textContent;
|
||||||
if (!data) throw new Error("Missing login page context");
|
if (!data) throw new Error("Missing login page context");
|
||||||
const context: SignInContext = JSON.parse(data);
|
const context: SignInContext = JSON.parse(data);
|
||||||
if (context.step !== "password" && context.step !== "mfa-pending") {
|
if (context.step !== "logout" && context.step !== "authorize" && context.step !== "password" && context.step !== "mfa-pending" && context.step !== "mfa-complete") {
|
||||||
throw new Error("Unknown login step");
|
throw new Error("Unknown login step");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
|
import { Passkey } from "../components/Passkey";
|
||||||
import { SubmitForm } from "../components/SubmitForm";
|
import { SubmitForm } from "../components/SubmitForm";
|
||||||
import type { SignInContext } from "../page-context";
|
import type { SignInContext } from "../page-context";
|
||||||
|
|
||||||
export function SignInPage({ context }: { context: SignInContext }) {
|
export function SignInPage({ context }: { context: SignInContext }) {
|
||||||
|
if (context.step === "logout") return <main><section className="card">
|
||||||
|
<h1>退出统一登录</h1>
|
||||||
|
<p>将结束本次登录,并通知支持统一注销的应用。</p>
|
||||||
|
<SubmitForm action={context.action} csrf={context.csrf} label="确认退出">
|
||||||
|
<input type="hidden" name="binding" value={context.binding} />
|
||||||
|
</SubmitForm>
|
||||||
|
</section></main>;
|
||||||
|
if (context.step === "authorize") return <main><section className="card">
|
||||||
|
<h1>继续登录 {context.client}</h1>
|
||||||
|
<p>{context.name},密码与 passkey 已验证。</p>
|
||||||
|
<p>本次向应用提供以下范围的信息:</p>
|
||||||
|
<ul>{context.scopes.map(scope => <li key={scope}>{scope}</li>)}</ul>
|
||||||
|
<SubmitForm action={context.action} csrf={context.csrf} label="继续至应用">
|
||||||
|
<input type="hidden" name="binding" value={context.binding} />
|
||||||
|
</SubmitForm>
|
||||||
|
<SubmitForm action="/signin/restart" csrf={context.csrf} label="取消并退出" />
|
||||||
|
</section></main>;
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<header><a href="/signin" className="brand">i<span>am</span></a></header>
|
<header><a href="/signin" className="brand">i<span>am</span></a></header>
|
||||||
<section className="card" aria-labelledby="title">
|
<section className="card" aria-labelledby="title">
|
||||||
<div className="eyebrow">AD 登录验证</div>
|
<div className="eyebrow">AD 登录验证</div>
|
||||||
<h1 id="title">
|
<h1 id="title">
|
||||||
{context.step === "password" ? "登录你的账号" : "密码已验证,等待 MFA"}
|
{context.step === "password" ? "登录你的账号" : context.step === "mfa-complete" ? "MFA 已验证" : "密码已验证,等待 MFA"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="intro">
|
<p className="intro">
|
||||||
{context.step === "password"
|
{context.step === "password"
|
||||||
? "使用 AD 用户名或完整 UPN 登录。"
|
? "使用 AD 用户名或完整 UPN 登录。"
|
||||||
: `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`}
|
: context.step === "mfa-complete"
|
||||||
|
? `${context.name},密码与 passkey 已验证。尚未向应用授权。`
|
||||||
|
: context.passkey === "unavailable"
|
||||||
|
? `${context.name},目录验证成功。第二因素尚未接入,本次没有完成登录或向应用授权。`
|
||||||
|
: `${context.name},请使用 passkey 完成第二因素验证。`}
|
||||||
</p>
|
</p>
|
||||||
{context.error && <p className="error" role="alert">{context.error}</p>}
|
{context.error && <p className="error" role="alert">{context.error}</p>}
|
||||||
{context.step === "mfa-pending" && (
|
{context.step !== "password" && (
|
||||||
<div className="directory-result">
|
<div className="directory-result">
|
||||||
<dl>
|
<dl>
|
||||||
<dt>账号</dt><dd>{context.identity.username}</dd>
|
<dt>账号</dt><dd>{context.identity.username}</dd>
|
||||||
@@ -34,6 +56,8 @@ export function SignInPage({ context }: { context: SignInContext }) {
|
|||||||
<p>当前仅读取 memberOf,不展开嵌套组,也不包含主组。</p>
|
<p>当前仅读取 memberOf,不展开嵌套组,也不包含主组。</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{context.step === "mfa-pending" && context.passkey !== "unavailable" &&
|
||||||
|
<Passkey csrf={context.csrf} initial={context.passkey} />}
|
||||||
<SubmitForm action={context.action} csrf={context.csrf}
|
<SubmitForm action={context.action} csrf={context.csrf}
|
||||||
label={context.step === "password" ? "继续" : "退出并重新验证"}>
|
label={context.step === "password" ? "继续" : "退出并重新验证"}>
|
||||||
{context.step === "password" && <>
|
{context.step === "password" && <>
|
||||||
@@ -47,6 +71,8 @@ export function SignInPage({ context }: { context: SignInContext }) {
|
|||||||
</label>
|
</label>
|
||||||
</>}
|
</>}
|
||||||
</SubmitForm>
|
</SubmitForm>
|
||||||
|
{context.step === "mfa-complete" && context.logoutAction &&
|
||||||
|
<SubmitForm action={context.logoutAction} csrf={context.csrf} label="退出所有应用" />}
|
||||||
</section>
|
</section>
|
||||||
<footer>独立 IAM · AD 接入验证</footer>
|
<footer>独立 IAM · AD 接入验证</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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<void>(resolve => callbackServer!.listen(14446, "127.0.0.1", resolve));
|
||||||
|
});
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (callbackServer) await new Promise<void>((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: "[email protected]", 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.use({ ignoreHTTPSErrors: true });
|
||||||
|
|
||||||
|
// Dedicated localhost fixture only. Never registers a synthetic credential against real AD.
|
||||||
|
test("AD + PostgreSQL + real WebAuthn ceremony, factor gating and persisted re-login", async ({ page, context }) => {
|
||||||
|
test.skip(process.env.IAM_WEBAUTHN_FIXTURE !== "1", "Start the test-only webauthnBrowserFixture first");
|
||||||
|
const cdp = await context.newCDPSession(page);
|
||||||
|
await cdp.send("WebAuthn.enable");
|
||||||
|
await cdp.send("WebAuthn.addVirtualAuthenticator", { options: {
|
||||||
|
protocol: "ctap2", transport: "internal", hasResidentKey: true,
|
||||||
|
hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true,
|
||||||
|
} });
|
||||||
|
async function login(username = "alice") {
|
||||||
|
await page.goto("https://localhost:18083/signin");
|
||||||
|
await page.getByLabel("用户名", { exact: true }).fill(username);
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("fixture-password");
|
||||||
|
await page.getByRole("button", { name: "继续", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "密码已验证,等待 MFA" })).toBeVisible();
|
||||||
|
}
|
||||||
|
await login();
|
||||||
|
await page.getByRole("button", { name: "注册 Passkey", exact: true }).click();
|
||||||
|
await expect(page.getByRole("status")).toContainText("Passkey 已保存");
|
||||||
|
await page.goto("https://localhost:18083/signin/complete");
|
||||||
|
await expect(page).toHaveURL(/^https:\/\/localhost:18083\/signin\/mfa(?:\?.*)?$/);
|
||||||
|
const enrollmentToken = await page.evaluate(() => JSON.parse(document.getElementById("login-context")!.textContent!).csrf);
|
||||||
|
const enrollAgain = await context.request.post("https://localhost:18083/webauthn/register/options", {
|
||||||
|
headers: { [enrollmentToken.headerName]: enrollmentToken.value }, maxRedirects: 0,
|
||||||
|
});
|
||||||
|
expect(enrollAgain.status()).toBe(302);
|
||||||
|
expect(enrollAgain.headers().location).toContain("factor.type=webauthn");
|
||||||
|
const beforeMfa = (await context.cookies()).find(c => c.name === "JSESSIONID")!.value;
|
||||||
|
await page.getByRole("button", { name: "验证 Passkey", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "MFA 已验证", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("gitea-admins", { exact: true })).toBeVisible();
|
||||||
|
expect((await context.cookies()).find(c => c.name === "JSESSIONID")!.value).not.toBe(beforeMfa);
|
||||||
|
await page.getByRole("button", { name: "退出并重新验证", exact: true }).click();
|
||||||
|
await login();
|
||||||
|
await expect(page.getByRole("button", { name: "注册 Passkey", exact: true })).toHaveCount(0);
|
||||||
|
const assertionRequest = page.waitForRequest(request => new URL(request.url()).pathname === "/login/webauthn");
|
||||||
|
await page.getByRole("button", { name: "验证 Passkey", exact: true }).click();
|
||||||
|
const assertion = (await assertionRequest).postDataJSON();
|
||||||
|
await expect(page.getByRole("heading", { name: "MFA 已验证", exact: true })).toBeVisible();
|
||||||
|
const token = await page.evaluate(() => JSON.parse(document.getElementById("login-context")!.textContent!).csrf);
|
||||||
|
const replay = await context.request.post("https://localhost:18083/login/webauthn", {
|
||||||
|
headers: { [token.headerName]: token.value }, data: assertion,
|
||||||
|
});
|
||||||
|
expect(replay.status()).toBe(401);
|
||||||
|
await page.getByRole("button", { name: "退出并重新验证", exact: true }).click();
|
||||||
|
await login("bob");
|
||||||
|
// Bob has no credential. A discoverable Alice credential must not become Bob's second factor.
|
||||||
|
const foreignStatus = await page.evaluate(async () => {
|
||||||
|
const csrf = JSON.parse(document.getElementById("login-context")!.textContent!).csrf;
|
||||||
|
const headers = { "Content-Type": "application/json", [csrf.headerName]: csrf.value };
|
||||||
|
const options = await fetch("/webauthn/authenticate/options", { method: "POST", headers }).then(r => r.json());
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
|
||||||
|
}) as PublicKeyCredential;
|
||||||
|
return fetch("/login/webauthn", { method: "POST", headers, body: JSON.stringify(credential.toJSON()) })
|
||||||
|
.then(r => r.status);
|
||||||
|
});
|
||||||
|
expect(foreignStatus).toBe(401);
|
||||||
|
});
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.persistence;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.springframework.jdbc.core.JdbcOperations;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
/** Serializes registration per user across sessions/processes in the same database transaction. */
|
||||||
|
public final class JdbcCredentialRegistration {
|
||||||
|
private final JdbcOperations jdbc;
|
||||||
|
private final TransactionTemplate transactions;
|
||||||
|
|
||||||
|
public JdbcCredentialRegistration(JdbcOperations jdbc, TransactionTemplate transactions) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.transactions = transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T register(String userId, Supplier<T> registration) {
|
||||||
|
return transactions.execute(status -> {
|
||||||
|
jdbc.queryForObject("select id from user_entities where id = ? for update", String.class, userId);
|
||||||
|
return registration.get();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -1,10 +1,16 @@
|
|||||||
package top.ddupan.iam.login.authentication.infrastructure.security;
|
package top.ddupan.iam.login.authentication.infrastructure.security;
|
||||||
|
|
||||||
import org.springframework.security.core.AuthenticatedPrincipal;
|
import org.springframework.security.core.AuthenticatedPrincipal;
|
||||||
|
import org.springframework.security.web.webauthn.api.Bytes;
|
||||||
|
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
|
||||||
import top.ddupan.iam.login.authentication.domain.User;
|
import top.ddupan.iam.login.authentication.domain.User;
|
||||||
|
|
||||||
/** An immutable directory snapshot; never contains credentials or connections. */
|
/** An immutable directory snapshot; never contains credentials or connections. */
|
||||||
public record DirectoryPrincipal(User user) implements AuthenticatedPrincipal {
|
public record DirectoryPrincipal(User user, Bytes credentialUserId)
|
||||||
|
implements AuthenticatedPrincipal, PublicKeyCredentialUserEntity {
|
||||||
|
public DirectoryPrincipal(User user) { this(user, null); }
|
||||||
|
@Override public Bytes getId() { return credentialUserId; }
|
||||||
|
@Override public String getDisplayName() { return user.displayName(); }
|
||||||
@Override
|
@Override
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return user.id().authority() + ":" + user.id().value();
|
return user.id().authority() + ":" + user.id().value();
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.persistence.JdbcCredentialRegistration;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.webauthn.api.*;
|
||||||
|
import org.springframework.security.web.webauthn.management.*;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
|
||||||
|
|
||||||
|
/** Adds directory ownership/enrollment policy; verification and storage remain upstream implementations. */
|
||||||
|
public final class DirectoryRelyingPartyOperations implements WebAuthnRelyingPartyOperations {
|
||||||
|
private final WebAuthnRelyingPartyOperations delegate;
|
||||||
|
private final MfaPolicy policy;
|
||||||
|
private final PublicKeyCredentialUserEntityRepository users;
|
||||||
|
private final UserCredentialRepository credentials;
|
||||||
|
private final JdbcCredentialRegistration registrations;
|
||||||
|
|
||||||
|
public DirectoryRelyingPartyOperations(WebAuthnRelyingPartyOperations delegate, MfaPolicy policy,
|
||||||
|
PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials,
|
||||||
|
JdbcCredentialRegistration registrations) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.policy = policy;
|
||||||
|
this.users = users;
|
||||||
|
this.credentials = credentials;
|
||||||
|
this.registrations = registrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublicKeyCredentialCreationOptions createPublicKeyCredentialCreationOptions(
|
||||||
|
PublicKeyCredentialCreationOptionsRequest request) {
|
||||||
|
policy.current();
|
||||||
|
policy.requireEnrollment(request.getAuthentication());
|
||||||
|
return delegate.createPublicKeyCredentialCreationOptions(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CredentialRecord registerCredential(RelyingPartyRegistrationRequest request) {
|
||||||
|
var principal = policy.current();
|
||||||
|
var owner = request.getCreationOptions().getUser();
|
||||||
|
if (!principal.getName().equals(owner.getName())) throw new AccessDeniedException("Credential owner mismatch");
|
||||||
|
return registrations.register(owner.getId().toBase64UrlString(), () -> {
|
||||||
|
policy.requireEnrollment(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
return delegate.registerCredential(request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublicKeyCredentialRequestOptions createCredentialRequestOptions(PublicKeyCredentialRequestOptionsRequest request) {
|
||||||
|
policy.current();
|
||||||
|
return delegate.createCredentialRequestOptions(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublicKeyCredentialUserEntity authenticate(RelyingPartyAuthenticationRequest request) {
|
||||||
|
var principal = policy.current();
|
||||||
|
var owner = users.findByUsername(principal.getName());
|
||||||
|
var credential = credentials.findByCredentialId(request.getPublicKey().getRawId());
|
||||||
|
if (owner == null || credential == null || !owner.getId().equals(credential.getUserEntityUserId())) {
|
||||||
|
throw new AccessDeniedException("Credential owner mismatch");
|
||||||
|
}
|
||||||
|
var verified = delegate.authenticate(request);
|
||||||
|
if (!verified.getName().equals(principal.getName())) throw new AccessDeniedException("Credential owner mismatch");
|
||||||
|
return new DirectoryPrincipal(principal.user(), verified.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.authorization.AuthorizationManager;
|
||||||
|
import org.springframework.security.authorization.AuthorizationManagerFactories;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
|
||||||
|
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
|
||||||
|
|
||||||
|
/** Enrollment policy; factor completion and freshness are evaluated by Spring Security. */
|
||||||
|
public final class MfaPolicy {
|
||||||
|
private final PublicKeyCredentialUserEntityRepository users;
|
||||||
|
private final UserCredentialRepository credentials;
|
||||||
|
public final AuthorizationManager<Object> password = AuthorizationManagerFactories.<Object>multiFactor()
|
||||||
|
.requireFactor(f -> f.passwordAuthority().validDuration(Duration.ofMinutes(10))).build().authenticated();
|
||||||
|
public final AuthorizationManager<Object> complete = AuthorizationManagerFactories.<Object>multiFactor()
|
||||||
|
.requireFactor(f -> f.passwordAuthority().validDuration(Duration.ofMinutes(10)))
|
||||||
|
.requireFactor(f -> f.webauthnAuthority().validDuration(Duration.ofMinutes(10))).build().authenticated();
|
||||||
|
|
||||||
|
public MfaPolicy(PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials) {
|
||||||
|
this.users = users;
|
||||||
|
this.credentials = credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DirectoryPrincipal current() {
|
||||||
|
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
password.verify(() -> authentication, null);
|
||||||
|
if (!(authentication.getPrincipal() instanceof DirectoryPrincipal principal)) {
|
||||||
|
throw new AccessDeniedException("Directory identity required");
|
||||||
|
}
|
||||||
|
return principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enrolled(String name) {
|
||||||
|
var user = users.findByUsername(name);
|
||||||
|
return user != null && !credentials.findByUserId(user.getId()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireEnrollment(Authentication authentication) {
|
||||||
|
password.verify(() -> authentication, null);
|
||||||
|
if (enrolled(authentication.getName())) complete.verify(() -> authentication, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
|
||||||
|
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
|
||||||
|
import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsRepository;
|
||||||
|
import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsRepository;
|
||||||
|
|
||||||
|
/** Framework repository extension: server-side TTL, owner binding and atomic single consumption. */
|
||||||
|
public final class SessionChallenges {
|
||||||
|
private final Clock clock;
|
||||||
|
private final MfaPolicy policy;
|
||||||
|
private static final Duration LIFETIME = Duration.ofMinutes(5);
|
||||||
|
private record Challenge(Object options, String owner, Instant expiresAt) { }
|
||||||
|
|
||||||
|
public SessionChallenges(Clock clock, MfaPolicy policy) {
|
||||||
|
this.clock = clock;
|
||||||
|
this.policy = policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save(HttpServletRequest request, String key, Object options) {
|
||||||
|
// Upstream clears after load; load already consumes atomically. Do not clear a newer challenge.
|
||||||
|
if (options == null) return;
|
||||||
|
var owner = policy.current().getName();
|
||||||
|
var session = request.getSession();
|
||||||
|
synchronized (session) {
|
||||||
|
session.setAttribute(key, new Challenge(options, owner, clock.instant().plus(LIFETIME)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object consume(HttpServletRequest request, String key) {
|
||||||
|
var session = request.getSession(false);
|
||||||
|
if (session == null) return null;
|
||||||
|
synchronized (session) {
|
||||||
|
var challenge = (Challenge) session.getAttribute(key);
|
||||||
|
session.removeAttribute(key);
|
||||||
|
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (challenge == null || !clock.instant().isBefore(challenge.expiresAt())
|
||||||
|
|| authentication == null || !challenge.owner().equals(authentication.getName())) return null;
|
||||||
|
var result = policy.password.authorize(() -> authentication, null);
|
||||||
|
if (result == null || !result.isGranted()) return null;
|
||||||
|
return challenge.options();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicKeyCredentialCreationOptionsRepository registration() {
|
||||||
|
return new PublicKeyCredentialCreationOptionsRepository() {
|
||||||
|
private static final String KEY = "iam.webauthn.registration";
|
||||||
|
@Override public void save(HttpServletRequest r, HttpServletResponse s, PublicKeyCredentialCreationOptions o) {
|
||||||
|
SessionChallenges.this.save(r, KEY, o);
|
||||||
|
}
|
||||||
|
@Override public PublicKeyCredentialCreationOptions load(HttpServletRequest r) {
|
||||||
|
return (PublicKeyCredentialCreationOptions) consume(r, KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicKeyCredentialRequestOptionsRepository authentication() {
|
||||||
|
return new PublicKeyCredentialRequestOptionsRepository() {
|
||||||
|
private static final String KEY = "iam.webauthn.authentication";
|
||||||
|
@Override public void save(HttpServletRequest r, HttpServletResponse s, PublicKeyCredentialRequestOptions o) {
|
||||||
|
SessionChallenges.this.save(r, KEY, o);
|
||||||
|
}
|
||||||
|
@Override public PublicKeyCredentialRequestOptions load(HttpServletRequest r) {
|
||||||
|
return (PublicKeyCredentialRequestOptions) consume(r, KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties("iam.webauthn")
|
||||||
|
public record WebAuthnProperties(boolean enabled, String rpId, String origin) {
|
||||||
|
public WebAuthnProperties {
|
||||||
|
if (enabled) {
|
||||||
|
if (origin == null) throw new IllegalArgumentException("WebAuthn HTTPS origin is required");
|
||||||
|
var uri = URI.create(origin);
|
||||||
|
if (rpId == null || rpId.isBlank() || !"https".equals(uri.getScheme())
|
||||||
|
|| !rpId.equals(uri.getHost()) || uri.getUserInfo() != null
|
||||||
|
|| uri.getQuery() != null || uri.getFragment() != null
|
||||||
|
|| (uri.getPath() != null && !uri.getPath().isEmpty())) {
|
||||||
|
throw new IllegalArgumentException("WebAuthn requires an exact HTTPS origin and matching RP host");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import tools.jackson.databind.json.JsonMapper;
|
|||||||
/** Shared HTML shell; the page context is data, never executable JavaScript. */
|
/** Shared HTML shell; the page context is data, never executable JavaScript. */
|
||||||
@Component
|
@Component
|
||||||
public class PageRenderer {
|
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 static final String SLOT = "__IAM_PAGE_CONTEXT__";
|
||||||
private final String shell;
|
private final String shell;
|
||||||
private final JsonMapper json = JsonMapper.builder().build();
|
private final JsonMapper json = JsonMapper.builder().build();
|
||||||
|
|||||||
+32
-4
@@ -1,6 +1,8 @@
|
|||||||
package top.ddupan.iam.login.authentication.interfaces.web;
|
package top.ddupan.iam.login.authentication.interfaces.web;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.webauthn.MfaPolicy;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -17,9 +19,14 @@ import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrin
|
|||||||
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
|
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
|
||||||
public class SignInController {
|
public class SignInController {
|
||||||
private final PageRenderer renderer;
|
private final PageRenderer renderer;
|
||||||
|
private final ObjectProvider<MfaPolicy> policies;
|
||||||
|
|
||||||
public SignInController(PageRenderer renderer) {
|
private final ObjectProvider<top.ddupan.iam.login.authorization.application.port.HydraGateway> logout;
|
||||||
|
public SignInController(PageRenderer renderer, ObjectProvider<MfaPolicy> policies,
|
||||||
|
ObjectProvider<top.ddupan.iam.login.authorization.application.port.HydraGateway> logout) {
|
||||||
|
this.logout=logout;
|
||||||
this.renderer = renderer;
|
this.renderer = renderer;
|
||||||
|
this.policies = policies;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE)
|
@GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE)
|
||||||
@@ -31,16 +38,37 @@ public class SignInController {
|
|||||||
|
|
||||||
@GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE)
|
@GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE)
|
||||||
ResponseEntity<String> pending(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
|
ResponseEntity<String> pending(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
|
||||||
|
var policy = policies.getIfAvailable();
|
||||||
|
return identity(principal, csrf, "mfa-pending", policy == null ? "unavailable"
|
||||||
|
: policy.enrolled(principal.getName()) ? "authenticate" : "register");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/signin/complete", produces = MediaType.TEXT_HTML_VALUE)
|
||||||
|
ResponseEntity<String> complete(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf,
|
||||||
|
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<String> identity(DirectoryPrincipal principal, CsrfToken csrf, String step, String passkey) {
|
||||||
var user = principal.user();
|
var user = principal.user();
|
||||||
return renderer.render(Map.of("step", "mfa-pending", "name", user.displayName(),
|
var page = renderer.render(Map.of("step", step, "passkey", passkey, "name", user.displayName(),
|
||||||
"error", "", "action", "/signin/restart", "csrf", csrf(csrf),
|
"error", "", "action", "/signin/restart", "logoutAction", logout.getIfAvailable()==null ? "" : "/signin/logout", "csrf", csrf(csrf),
|
||||||
"identity", Map.of("username", user.username(), "subjectId", user.id().value(),
|
"identity", Map.of("username", user.username(), "subjectId", user.id().value(),
|
||||||
"email", user.email(),
|
"email", user.email(),
|
||||||
"groups", user.memberships().stream().map(GroupMembership::name).toList(),
|
"groups", user.memberships().stream().map(GroupMembership::name).toList(),
|
||||||
"groupDns", user.memberships().stream().map(GroupMembership::externalId).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.logoutUrl());
|
||||||
|
return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy",
|
||||||
|
PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'",
|
||||||
|
"form-action 'self' " + uri.getScheme() + "://" + uri.getRawAuthority())).body(page.getBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Map<String, String> csrf(CsrfToken token) {
|
private static Map<String, String> csrf(CsrfToken token) {
|
||||||
return Map.of("name", token.getParameterName(), "value", token.getToken());
|
return Map.of("name", token.getParameterName(), "value", token.getToken(), "headerName", token.getHeaderName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String> SCOPES = Set.of("openid", "profile", "email", "groups");
|
||||||
|
private final Set<String> clients;
|
||||||
|
private final Map<User.UserId, String> subjects;
|
||||||
|
|
||||||
|
public AuthorizationPolicy(Set<String> clients, Map<User.UserId, String> 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<String, Object> claims(User user, AuthorizationRequest request) {
|
||||||
|
validate(request);
|
||||||
|
var claims = new LinkedHashMap<String, Object>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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 {
|
||||||
|
record LogoutRequest(String challenge, String subject, String sid, String postLogoutRedirectUri) { }
|
||||||
|
LogoutRequest logout(String challenge);
|
||||||
|
String acceptLogout(String challenge);
|
||||||
|
String logoutUrl();
|
||||||
|
AuthorizationRequest login(String challenge);
|
||||||
|
AuthorizationRequest consent(String challenge);
|
||||||
|
String acceptLogin(String challenge, String subject, String binding, Instant authenticatedAt);
|
||||||
|
String acceptConsent(String challenge, List<String> scopes, Map<String, Object> claims);
|
||||||
|
}
|
||||||
@@ -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<String> scopes,
|
||||||
|
List<String> audience, String subject, String loginChallengeDigest, String binding, String requestUrl,
|
||||||
|
boolean skip, Boolean loginEnabled) {
|
||||||
|
public AuthorizationRequest(String challenge, String clientId, List<String> scopes, List<String> 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
package top.ddupan.iam.login.authorization.infrastructure.hydra;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
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, RestClient http) {
|
||||||
|
client = http.mutate()
|
||||||
|
.defaultStatusHandler(status -> !status.is2xxSuccessful(), (request, response) -> {
|
||||||
|
throw new IllegalArgumentException("Hydra returned a non-success status");
|
||||||
|
}).build();
|
||||||
|
publicUrl = properties.publicUrl();
|
||||||
|
}
|
||||||
|
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Client(String client_id, Map<String,Object> 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<String> requested_scope,
|
||||||
|
List<String> 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<String> scopes, Map<String, Object> 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<String, String>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true)
|
||||||
|
public record LogoutResponse(String challenge,String subject,String sid,String request_url, LogoutClient client) { }
|
||||||
|
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true)
|
||||||
|
public record LogoutClient(java.util.List<String> post_logout_redirect_uris) { }
|
||||||
|
@Override public LogoutRequest logout(String challenge) {
|
||||||
|
validateChallenge(challenge);
|
||||||
|
try {
|
||||||
|
var result = java.util.Objects.requireNonNull(client.get().uri(b -> b.path("/admin/oauth2/auth/requests/logout")
|
||||||
|
.queryParam("logout_challenge","{challenge}").build(challenge)).retrieve().body(LogoutResponse.class));
|
||||||
|
if (!challenge.equals(result.challenge())) throw new IllegalArgumentException("Wrong challenge");
|
||||||
|
// Hydra stores the original HTTP request-target, which may be origin-relative.
|
||||||
|
validateTarget(publicUrl.resolve(result.request_url()).toString());
|
||||||
|
String callback = "";
|
||||||
|
var query = URI.create(result.request_url()).getRawQuery();
|
||||||
|
if (query != null) for (var part : query.split("&")) {
|
||||||
|
var pair = part.split("=",2);
|
||||||
|
if ("post_logout_redirect_uri".equals(java.net.URLDecoder.decode(pair[0],java.nio.charset.StandardCharsets.UTF_8))) {
|
||||||
|
if (!callback.isEmpty() || pair.length!=2) throw new IllegalArgumentException("Duplicate logout redirect");
|
||||||
|
callback=java.net.URLDecoder.decode(pair[1],java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!callback.isEmpty()) {
|
||||||
|
if (result.client()==null || result.client().post_logout_redirect_uris()==null
|
||||||
|
|| !result.client().post_logout_redirect_uris().contains(callback)) throw new IllegalArgumentException("Unregistered logout redirect");
|
||||||
|
var uri = URI.create(callback);
|
||||||
|
if (uri.getHost()==null || uri.getUserInfo()!=null || uri.getFragment()!=null
|
||||||
|
|| !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())
|
||||||
|
&& java.util.Set.of("localhost","127.0.0.1").contains(uri.getHost())))
|
||||||
|
throw new IllegalArgumentException("Invalid logout callback");
|
||||||
|
}
|
||||||
|
return new LogoutRequest(challenge,result.subject(),result.sid(),callback);
|
||||||
|
} catch (RuntimeException ex) { throw new IllegalArgumentException("Logout request unavailable"); }
|
||||||
|
}
|
||||||
|
@Override public String acceptLogout(String challenge) {
|
||||||
|
validateChallenge(challenge);
|
||||||
|
try {
|
||||||
|
var result = java.util.Objects.requireNonNull(client.put().uri(b -> b.path("/admin/oauth2/auth/requests/logout/accept")
|
||||||
|
.queryParam("logout_challenge","{challenge}").build(challenge)).retrieve().body(HydraAdminClient.Redirect.class));
|
||||||
|
validateTarget(result.redirect_to()); return result.redirect_to();
|
||||||
|
} catch (RuntimeException ex) { throw new IllegalArgumentException("Logout acceptance unavailable"); }
|
||||||
|
}
|
||||||
|
@Override public String logoutUrl() { return publicUrl.resolve("/oauth2/sessions/logout").toString(); }
|
||||||
|
private void validateTarget(String target) {
|
||||||
|
var uri = URI.create(target);
|
||||||
|
if (!publicUrl.getScheme().equals(uri.getScheme()) || !publicUrl.getRawAuthority().equals(uri.getRawAuthority())
|
||||||
|
|| uri.getUserInfo()!=null || uri.getFragment()!=null || !"/oauth2/sessions/logout".equals(uri.getRawPath()))
|
||||||
|
throw new IllegalArgumentException("Invalid logout redirect");
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package top.ddupan.iam.login.authorization.infrastructure.hydra;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||||
|
|
||||||
|
@ConfigurationProperties("iam.hydra.http")
|
||||||
|
public record HydraHttpProperties(@DefaultValue("3s") Duration connectTimeout,
|
||||||
|
@DefaultValue("5s") Duration readTimeout) {
|
||||||
|
public HydraHttpProperties {
|
||||||
|
if (connectTimeout == null || readTimeout == null || connectTimeout.isNegative()
|
||||||
|
|| connectTimeout.isZero() || readTimeout.isNegative() || readTimeout.isZero()) {
|
||||||
|
throw new IllegalArgumentException("Hydra HTTP timeouts must be positive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -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<String> clients,
|
||||||
|
List<SubjectBinding> 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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Void> 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<String> 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<Void> 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<Void> 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<String> 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<Void> redirect(String target) {
|
||||||
|
return ResponseEntity.status(303).header("Cache-Control", "no-store").location(URI.create(target)).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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.HydraGateway;
|
||||||
|
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(HydraGateway.LogoutRequest request,String binding,Instant expires) { }
|
||||||
|
private final HydraGateway hydra;
|
||||||
|
private final PageRenderer renderer;
|
||||||
|
private final AuthorizationPolicy policy;
|
||||||
|
public HydraLogoutController(HydraGateway hydra,PageRenderer renderer,AuthorizationPolicy policy) {
|
||||||
|
this.hydra=hydra; this.renderer=renderer; this.policy=policy;
|
||||||
|
}
|
||||||
|
@GetMapping("/oauth2/logout") ResponseEntity<String> page(@RequestParam("logout_challenge") String challenge,
|
||||||
|
HttpServletRequest request,Authentication auth,CsrfToken csrf) {
|
||||||
|
var logout = hydra.logout(challenge);
|
||||||
|
if (auth != null && auth.getPrincipal() instanceof DirectoryPrincipal principal && logout.subject()!=null
|
||||||
|
&& !logout.subject().isBlank() && !policy.subject(principal.user()).equals(logout.subject()))
|
||||||
|
throw new IllegalArgumentException("Different logout subject");
|
||||||
|
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.logoutUrl());
|
||||||
|
if (!logout.postLogoutRedirectUri().isEmpty()) targets += " " + origin(logout.postLogoutRedirectUri());
|
||||||
|
return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy",
|
||||||
|
PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'", "form-action 'self' " + targets))
|
||||||
|
.body(page.getBody());
|
||||||
|
}
|
||||||
|
@PostMapping("/oauth2/logout") ResponseEntity<Void> 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.logout(pending.request().challenge());
|
||||||
|
if (!Objects.equals(current,pending.request())) throw new IllegalArgumentException("Logout request changed");
|
||||||
|
var target=hydra.acceptLogout(current.challenge());
|
||||||
|
new SecurityContextLogoutHandler().logout(request,response,authentication);
|
||||||
|
return ResponseEntity.status(303).header("Cache-Control","no-store").location(URI.create(target)).build();
|
||||||
|
}
|
||||||
|
private static String origin(String url) { var uri=URI.create(url); return uri.getScheme()+"://"+uri.getRawAuthority(); }
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class) ResponseEntity<String> invalid() {
|
||||||
|
return ResponseEntity.badRequest().header("Cache-Control","no-store").body("注销请求无效或已过期,请重新发起。");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package top.ddupan.iam.login.clients.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Client persistence contract. Secrets exist only in create responses. */
|
||||||
|
public interface ClientRepository {
|
||||||
|
record Created(OidcClient client, String secret) {
|
||||||
|
@Override public String toString() { return "Created[client=" + client.id() + ", secret=REDACTED]"; }
|
||||||
|
}
|
||||||
|
List<OidcClient> list(int page, int size);
|
||||||
|
OidcClient get(String id);
|
||||||
|
Created create(OidcClient client);
|
||||||
|
OidcClient update(OidcClient client);
|
||||||
|
void delete(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package top.ddupan.iam.login.clients.domain;
|
||||||
|
|
||||||
|
public final class ClientRepositoryException extends RuntimeException {
|
||||||
|
public enum Kind { NOT_FOUND, CONFLICT, UNAVAILABLE }
|
||||||
|
private final Kind kind;
|
||||||
|
public ClientRepositoryException(Kind kind) { super("Client repository " + kind); this.kind = kind; }
|
||||||
|
public Kind kind() { return kind; }
|
||||||
|
}
|
||||||
@@ -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<String> redirectUris, Set<String> scopes,
|
||||||
|
List<String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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.domain.*;
|
||||||
|
import top.ddupan.iam.login.clients.domain.OidcClient;
|
||||||
|
|
||||||
|
public final class HydraClientRepository implements ClientRepository {
|
||||||
|
public static final String ENABLED = "iam_login_enabled";
|
||||||
|
private static final ParameterizedTypeReference<Map<String,Object>> OBJECT = new ParameterizedTypeReference<>() {};
|
||||||
|
private static final ParameterizedTypeReference<List<Map<String,Object>>> ARRAY = new ParameterizedTypeReference<>() {};
|
||||||
|
private final RestClient http;
|
||||||
|
public HydraClientRepository(RestClient client) {
|
||||||
|
http = client.mutate()
|
||||||
|
.defaultStatusHandler(status -> !status.is2xxSuccessful(), (request, response) -> {
|
||||||
|
throw new ClientRepositoryException(switch (response.getStatusCode().value()) {
|
||||||
|
case 404 -> ClientRepositoryException.Kind.NOT_FOUND;
|
||||||
|
case 400, 409 -> ClientRepositoryException.Kind.CONFLICT;
|
||||||
|
default -> ClientRepositoryException.Kind.UNAVAILABLE;
|
||||||
|
});
|
||||||
|
}).build();
|
||||||
|
}
|
||||||
|
private <T> T call(java.util.function.Supplier<T> operation) {
|
||||||
|
try { return operation.get(); }
|
||||||
|
catch (ClientRepositoryException ex) { throw ex; }
|
||||||
|
catch (RuntimeException ex) { throw new ClientRepositoryException(ClientRepositoryException.Kind.UNAVAILABLE); }
|
||||||
|
}
|
||||||
|
@Override public List<OidcClient> 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<String,Object> 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 ClientRepositoryException(ClientRepositoryException.Kind.CONFLICT);
|
||||||
|
// Preserve issuer-owned fields and metadata, but never send back a stored secret/hash.
|
||||||
|
var update = new LinkedHashMap<>(previous); update.remove("client_secret");
|
||||||
|
var metadata = new LinkedHashMap<String,Object>();
|
||||||
|
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<String,Object> data) {
|
||||||
|
return List.of("authorization_code").equals(data.get("grant_types"))
|
||||||
|
&& "client_secret_basic".equals(data.get("token_endpoint_auth_method"));
|
||||||
|
}
|
||||||
|
private Map<String,Object> payload(OidcClient c) {
|
||||||
|
var map = new LinkedHashMap<String,Object>();
|
||||||
|
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<String,Object> m) {
|
||||||
|
if (!supported(m)) throw new ClientRepositoryException(ClientRepositoryException.Kind.CONFLICT);
|
||||||
|
String id = (String)m.get("client_id");
|
||||||
|
String name = Objects.toString(m.get("client_name"),"");
|
||||||
|
return new OidcClient(id, name.isBlank() ? id : name,
|
||||||
|
(List<String>)m.get("redirect_uris"),new HashSet<>(Arrays.asList(Objects.toString(m.get("scope"),"").split(" +"))),
|
||||||
|
(List<String>)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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.domain.*;
|
||||||
|
import top.ddupan.iam.login.clients.domain.OidcClient;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@ConditionalOnProperty(prefix="iam.hydra", name="enabled", havingValue="true")
|
||||||
|
public class ClientsController {
|
||||||
|
private final ClientRepository repository;
|
||||||
|
private static final org.slf4j.Logger AUDIT = org.slf4j.LoggerFactory.getLogger("iam.audit.clients");
|
||||||
|
public ClientsController(ClientRepository repository) { this.repository = repository; }
|
||||||
|
@GetMapping("/api/iam/session") Object session(CsrfToken csrf) {
|
||||||
|
return Map.of("csrf",Map.of("headerName",csrf.getHeaderName(),"token",csrf.getToken()));
|
||||||
|
}
|
||||||
|
@GetMapping("/api/iam/clients") Object list(@RequestParam(defaultValue="0") int page,
|
||||||
|
@RequestParam(defaultValue="20") int size) { return repository.list(page,size); }
|
||||||
|
@GetMapping("/api/iam/clients/{id}") OidcClient get(@PathVariable String id) { return repository.get(id); }
|
||||||
|
@PostMapping("/api/iam/clients") ResponseEntity<ClientRepository.Created> create(@RequestBody OidcClient input, Authentication auth) {
|
||||||
|
var created = repository.create(input); audit("create",input.id(),auth);
|
||||||
|
return ResponseEntity.status(201).header("Cache-Control","no-store").body(created);
|
||||||
|
}
|
||||||
|
@PutMapping("/api/iam/clients/{id}") OidcClient update(@PathVariable String id, @RequestBody OidcClient input, Authentication auth) {
|
||||||
|
if (!id.equals(input.id())) throw new IllegalArgumentException("Client ID mismatch");
|
||||||
|
var updated = repository.update(input); audit("update",id,auth); return updated;
|
||||||
|
}
|
||||||
|
@DeleteMapping("/api/iam/clients/{id}") ResponseEntity<Void> delete(@PathVariable String id, Authentication auth) {
|
||||||
|
repository.delete(id); audit("delete",id,auth); return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
private void audit(String action,String id,Authentication auth) {
|
||||||
|
AUDIT.info("client action={} client={} actor={}",action,id,auth.getName());
|
||||||
|
}
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class) ResponseEntity<?> invalid() {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error","invalid_client_request"));
|
||||||
|
}
|
||||||
|
@ExceptionHandler(ClientRepositoryException.class) ResponseEntity<?> unavailable(ClientRepositoryException ex) {
|
||||||
|
int status = switch (ex.kind()) { case NOT_FOUND -> 404; case CONFLICT -> 409; case UNAVAILABLE -> 502; };
|
||||||
|
return ResponseEntity.status(status).body(Map.of("error",ex.kind().name().toLowerCase(java.util.Locale.ROOT)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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.clients.domain.ClientRepository;
|
||||||
|
import top.ddupan.iam.login.clients.infrastructure.HydraClientRepository;
|
||||||
|
import top.ddupan.iam.login.clients.domain.OidcClient;
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods=false)
|
||||||
|
@ConditionalOnProperty(prefix="iam.hydra",name="enabled",havingValue="true")
|
||||||
|
@EnableConfigurationProperties(ClientManagementConfiguration.Access.class)
|
||||||
|
@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({OidcClient.class,ClientRepository.Created.class})
|
||||||
|
class ClientManagementConfiguration {
|
||||||
|
@ConfigurationProperties("iam.clients")
|
||||||
|
record Access(Set<String> 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 ClientRepository clientRepository(org.springframework.web.client.RestClient hydraAdminHttpClient) { return new HydraClientRepository(hydraAdminHttpClient); }
|
||||||
|
@Bean @Order(2) SecurityFilterChain clientManagement(HttpSecurity http,MfaPolicy mfa,Access access) throws Exception {
|
||||||
|
return http.securityMatcher("/api/iam/**").redirectToHttps(org.springframework.security.config.Customizer.withDefaults())
|
||||||
|
.authorizeHttpRequests(auth -> auth.anyRequest().access((authentication,request) -> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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, HydraHttpProperties.class})
|
||||||
|
@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({HydraAdminClient.Request.class,
|
||||||
|
HydraAdminClient.LogoutResponse.class, HydraAdminClient.LogoutClient.class, HydraAdminClient.Client.class, HydraAdminClient.Context.class, HydraAdminClient.Redirect.class})
|
||||||
|
class HydraConfiguration {
|
||||||
|
@Bean
|
||||||
|
org.springframework.web.client.RestClient hydraAdminHttpClient(
|
||||||
|
org.springframework.web.client.RestClient.Builder builder, HydraProperties properties,
|
||||||
|
HydraHttpProperties timeouts) {
|
||||||
|
var http = java.net.http.HttpClient.newBuilder().connectTimeout(timeouts.connectTimeout())
|
||||||
|
.followRedirects(java.net.http.HttpClient.Redirect.NEVER).build();
|
||||||
|
var factory = new org.springframework.http.client.JdkClientHttpRequestFactory(http);
|
||||||
|
factory.setReadTimeout(timeouts.readTimeout());
|
||||||
|
return builder.baseUrl(properties.adminUrl().toString()).requestFactory(factory).build();
|
||||||
|
}
|
||||||
|
@Bean HydraGateway hydraGateway(HydraProperties properties, MfaPolicy requiredMfa,
|
||||||
|
org.springframework.web.client.RestClient hydraAdminHttpClient) {
|
||||||
|
return new HydraAdminClient(properties, hydraAdminHttpClient);
|
||||||
|
}
|
||||||
|
@Bean AuthorizationPolicy authorizationPolicy(HydraProperties properties) {
|
||||||
|
return new AuthorizationPolicy(properties.clients(), properties.subjects().stream().collect(Collectors.toMap(
|
||||||
|
binding -> new User.UserId(binding.authority(), binding.directoryId()), HydraProperties.SubjectBinding::subject)));
|
||||||
|
}
|
||||||
|
@Bean AuthorizeApplication authorizeApplication(AuthorizationPolicy policy, HydraGateway hydra) {
|
||||||
|
return new AuthorizeApplication(policy, hydra);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package top.ddupan.iam.login.configuration;
|
package top.ddupan.iam.login.configuration;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -39,37 +40,56 @@ class SecurityConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@Order(2)
|
@Order(3)
|
||||||
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
|
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
|
||||||
SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords) throws Exception {
|
SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords,
|
||||||
|
ObjectProvider<WebAuthnBrowserConfigurer> webAuthn,
|
||||||
|
ObjectProvider<top.ddupan.iam.login.authorization.application.port.HydraGateway> logoutGateway) throws Exception {
|
||||||
var passwordFactor = AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
|
var passwordFactor = AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
|
||||||
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10)))
|
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10)))
|
||||||
.build();
|
.build();
|
||||||
return http.securityMatcher("/signin", "/signin/**", "/assets/**")
|
var mfa = webAuthn.getIfAvailable();
|
||||||
|
http.securityMatcher("/signin", "/signin/**", "/assets/**", "/webauthn/**", "/login/webauthn", "/oauth2/**")
|
||||||
.redirectToHttps(Customizer.withDefaults())
|
.redirectToHttps(Customizer.withDefaults())
|
||||||
.authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords)))
|
.authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords)))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> {
|
||||||
.requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll()
|
if (mfa != null) {
|
||||||
|
auth.requestMatchers("/signin/complete", "/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/**", "/oauth2/start", "/oauth2/logout").permitAll()
|
||||||
.requestMatchers("/signin/mfa").access(passwordFactor.authenticated())
|
.requestMatchers("/signin/mfa").access(passwordFactor.authenticated())
|
||||||
// No complete MFA or Hydra acceptance exists yet. Fail closed until those are implemented.
|
// Credential deletion and all unimplemented routes remain closed.
|
||||||
.anyRequest().denyAll())
|
.anyRequest().denyAll();
|
||||||
|
})
|
||||||
.formLogin(form -> form.loginPage("/signin").loginProcessingUrl("/signin/password")
|
.formLogin(form -> form.loginPage("/signin").loginProcessingUrl("/signin/password")
|
||||||
.defaultSuccessUrl("/signin/mfa", true).failureUrl("/signin?error"))
|
.defaultSuccessUrl("/signin/mfa", true).failureUrl("/signin?error"))
|
||||||
.logout(logout -> logout.logoutUrl("/signin/restart").logoutSuccessUrl("/signin"))
|
.logout(logout -> logout.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.logoutUrl() : "/signin");
|
||||||
|
}))
|
||||||
.exceptionHandling(exceptions -> exceptions
|
.exceptionHandling(exceptions -> exceptions
|
||||||
.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/signin"),
|
.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/signin"),
|
||||||
PathPatternRequestMatcher.withDefaults().matcher("/signin/**"))
|
PathPatternRequestMatcher.withDefaults().matcher("/signin/**"))
|
||||||
|
.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/signin"),
|
||||||
|
PathPatternRequestMatcher.withDefaults().matcher("/oauth2/**"))
|
||||||
.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
|
.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
|
||||||
org.springframework.security.web.util.matcher.AnyRequestMatcher.INSTANCE))
|
org.springframework.security.web.util.matcher.AnyRequestMatcher.INSTANCE))
|
||||||
.requestCache(cache -> cache.disable())
|
.requestCache(cache -> cache.disable())
|
||||||
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
|
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
top.ddupan.iam.login.authentication.interfaces.web.PageRenderer.CONTENT_SECURITY_POLICY)));
|
||||||
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")))
|
if (mfa != null) mfa.configure(http);
|
||||||
.build();
|
var chain = http.build();
|
||||||
|
return mfa == null ? chain : mfa.finish(http, chain);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@Order(3)
|
@Order(4)
|
||||||
SecurityFilterChain fallback(HttpSecurity http) throws Exception {
|
SecurityFilterChain fallback(HttpSecurity http) throws Exception {
|
||||||
return http.authorizeHttpRequests(auth -> auth
|
return http.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/error").permitAll()
|
.requestMatchers("/error").permitAll()
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package top.ddupan.iam.login.configuration;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.core.authority.FactorGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||||
|
import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter;
|
||||||
|
import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.webauthn.*;
|
||||||
|
|
||||||
|
/** Wires official filters through their public extension points; no custom authentication filter. */
|
||||||
|
final class WebAuthnBrowserConfigurer {
|
||||||
|
private final WebAuthnProperties properties;
|
||||||
|
final MfaPolicy policy;
|
||||||
|
private final SessionChallenges challenges;
|
||||||
|
|
||||||
|
WebAuthnBrowserConfigurer(WebAuthnProperties properties, MfaPolicy policy, SessionChallenges challenges) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.policy = policy;
|
||||||
|
this.challenges = challenges;
|
||||||
|
}
|
||||||
|
|
||||||
|
void configure(HttpSecurity http) throws Exception {
|
||||||
|
http.setSharedObject(UserDetailsService.class, name -> {
|
||||||
|
if (!policy.current().getName().equals(name)) throw new UsernameNotFoundException("Directory identity mismatch");
|
||||||
|
// Spring Security merges the existing password factor, retaining its original issue time.
|
||||||
|
return new User(name, "", List.of());
|
||||||
|
});
|
||||||
|
http.webAuthn(web -> web.rpId(properties.rpId()).rpName("IAM Login")
|
||||||
|
.allowedOrigins(properties.origin()).disableDefaultRegistrationPage(true)
|
||||||
|
.creationOptionsRepository(challenges.registration()));
|
||||||
|
http.exceptionHandling(exceptions -> exceptions.defaultDeniedHandlerForMissingAuthority(
|
||||||
|
new LoginUrlAuthenticationEntryPoint("/signin/mfa"), FactorGrantedAuthority.WEBAUTHN_AUTHORITY));
|
||||||
|
}
|
||||||
|
|
||||||
|
SecurityFilterChain finish(HttpSecurity http, SecurityFilterChain chain) {
|
||||||
|
var repository = challenges.authentication();
|
||||||
|
// Security 7.1 exposes these setters but does not expose the assertion repository in its DSL.
|
||||||
|
for (var filter : chain.getFilters()) {
|
||||||
|
if (filter instanceof PublicKeyCredentialRequestOptionsFilter options) {
|
||||||
|
options.setRequestOptionsRepository(repository);
|
||||||
|
}
|
||||||
|
if (filter instanceof WebAuthnAuthenticationFilter authentication) {
|
||||||
|
authentication.setRequestOptionsRepository(repository);
|
||||||
|
authentication.setSessionAuthenticationStrategy(http.getSharedObject(
|
||||||
|
org.springframework.security.web.authentication.session.SessionAuthenticationStrategy.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package top.ddupan.iam.login.configuration;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Set;
|
||||||
|
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 org.springframework.jdbc.core.JdbcOperations;
|
||||||
|
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
|
||||||
|
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
|
||||||
|
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
|
||||||
|
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
|
||||||
|
import org.springframework.security.web.webauthn.management.*;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.webauthn.*;
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@ConditionalOnProperty(prefix = "iam.webauthn", name = "enabled", havingValue = "true")
|
||||||
|
@EnableConfigurationProperties(WebAuthnProperties.class)
|
||||||
|
class WebAuthnConfiguration {
|
||||||
|
@Bean
|
||||||
|
PublicKeyCredentialUserEntityRepository credentialUsers(JdbcOperations jdbc) {
|
||||||
|
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
UserCredentialRepository credentials(JdbcOperations jdbc) {
|
||||||
|
return new JdbcUserCredentialRepository(jdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
MfaPolicy mfaPolicy(PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials) {
|
||||||
|
return new MfaPolicy(users, credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SessionChallenges challenges(MfaPolicy policy) {
|
||||||
|
return new SessionChallenges(Clock.systemUTC(), policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
WebAuthnRelyingPartyOperations relyingParty(WebAuthnProperties properties, MfaPolicy policy,
|
||||||
|
PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials,
|
||||||
|
JdbcOperations jdbc, PlatformTransactionManager transactions) {
|
||||||
|
var delegate = new Webauthn4JRelyingPartyOperations(users, credentials,
|
||||||
|
PublicKeyCredentialRpEntity.builder().id(properties.rpId()).name("IAM Login").build(),
|
||||||
|
Set.of(properties.origin()));
|
||||||
|
delegate.setCustomizeCreationOptions(options -> options.timeout(Duration.ofMinutes(5))
|
||||||
|
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
|
||||||
|
.residentKey(ResidentKeyRequirement.REQUIRED)
|
||||||
|
.userVerification(UserVerificationRequirement.REQUIRED).build()));
|
||||||
|
delegate.setCustomizeRequestOptions(options -> options.timeout(Duration.ofMinutes(5))
|
||||||
|
.userVerification(UserVerificationRequirement.REQUIRED));
|
||||||
|
return new DirectoryRelyingPartyOperations(delegate, policy, users, credentials,
|
||||||
|
new top.ddupan.iam.login.authentication.infrastructure.persistence.JdbcCredentialRegistration(
|
||||||
|
jdbc, new TransactionTemplate(transactions)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
WebAuthnBrowserConfigurer webAuthnBrowser(WebAuthnProperties properties, MfaPolicy policy,
|
||||||
|
SessionChallenges challenges) {
|
||||||
|
return new WebAuthnBrowserConfigurer(properties, policy, challenges);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Based on Spring Security 7.1.1 WebAuthn JDBC schemas (Apache-2.0).
|
||||||
|
create table user_entities
|
||||||
|
(
|
||||||
|
id varchar(1000) not null,
|
||||||
|
name varchar(100) not null,
|
||||||
|
display_name varchar(200),
|
||||||
|
primary key (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table user_credentials
|
||||||
|
(
|
||||||
|
credential_id varchar(1000) not null,
|
||||||
|
user_entity_user_id varchar(1000) not null,
|
||||||
|
public_key bytea not null,
|
||||||
|
signature_count bigint,
|
||||||
|
uv_initialized boolean,
|
||||||
|
backup_eligible boolean not null,
|
||||||
|
authenticator_transports varchar(1000),
|
||||||
|
public_key_credential_type varchar(100),
|
||||||
|
backup_state boolean not null,
|
||||||
|
attestation_object bytea,
|
||||||
|
attestation_client_data_json bytea,
|
||||||
|
created timestamp,
|
||||||
|
last_used timestamp,
|
||||||
|
label varchar(1000) not null,
|
||||||
|
primary key (credential_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index user_entities_name on user_entities(name);
|
||||||
|
create index user_credentials_owner on user_credentials(user_entity_user_id);
|
||||||
@@ -21,7 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest(properties = "spring.autoconfigure.exclude=org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration")
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@AutoConfigureMetrics
|
@AutoConfigureMetrics
|
||||||
@AutoConfigureTracing
|
@AutoConfigureTracing
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package top.ddupan.iam.login;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
import top.ddupan.iam.login.support.AdDirectoryFixture;
|
||||||
|
|
||||||
|
/** Test-only HTTPS application with simulated AD and disposable PostgreSQL. Never connects to real AD. */
|
||||||
|
public final class WebAuthnBrowserFixture {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
var directory = new AdDirectoryFixture();
|
||||||
|
var database = new PostgreSQLContainer(DockerImageName.parse(
|
||||||
|
"postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873")
|
||||||
|
.asCompatibleSubstituteFor("postgres"));
|
||||||
|
database.start();
|
||||||
|
var application = new SpringApplication(IamLoginApplication.class);
|
||||||
|
var properties = new java.util.HashMap<String, Object>(Map.ofEntries(
|
||||||
|
Map.entry("server.address", "127.0.0.1"), Map.entry("server.port", "18083"),
|
||||||
|
Map.entry("server.ssl.enabled", "true"), Map.entry("server.ssl.key-store", "classpath:ldap/fixture.p12"),
|
||||||
|
Map.entry("server.ssl.key-store-password", "fixture-only"),
|
||||||
|
Map.entry("iam.ad.enabled", "true"), Map.entry("iam.ad.domain", "example.test"),
|
||||||
|
Map.entry("iam.ad.base-dn", "dc=example,dc=test"), Map.entry("iam.ad.url", directory.url()),
|
||||||
|
Map.entry("iam.webauthn.enabled", "true"), Map.entry("iam.webauthn.rp-id", "localhost"),
|
||||||
|
Map.entry("iam.webauthn.origin", "https://localhost:18083"),
|
||||||
|
Map.entry("spring.datasource.url", database.getJdbcUrl()),
|
||||||
|
Map.entry("spring.datasource.username", database.getUsername()),
|
||||||
|
Map.entry("spring.datasource.password", database.getPassword()),
|
||||||
|
Map.entry("spring.security.user.password", "fixture-monitor-password"),
|
||||||
|
Map.entry("management.otlp.metrics.export.enabled", "false")));
|
||||||
|
var 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();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.FactorGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.webauthn.api.Bytes;
|
||||||
|
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
|
||||||
|
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
|
||||||
|
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
|
||||||
|
import top.ddupan.iam.login.authentication.domain.User;
|
||||||
|
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class SessionChallengesTests {
|
||||||
|
private final MfaPolicy policy = new MfaPolicy(new MapPublicKeyCredentialUserEntityRepository(), new MapUserCredentialRepository());
|
||||||
|
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void challengesExpireAndAreBoundToCurrentIdentity() {
|
||||||
|
var issued = Instant.now();
|
||||||
|
var request = new MockHttpServletRequest();
|
||||||
|
var response = new MockHttpServletResponse();
|
||||||
|
identify("alice", issued);
|
||||||
|
var repository = new SessionChallenges(Clock.fixed(issued, ZoneOffset.UTC), policy).authentication();
|
||||||
|
var options = PublicKeyCredentialRequestOptions.builder().rpId("localhost").challenge(Bytes.random()).build();
|
||||||
|
repository.save(request, response, options);
|
||||||
|
var later = new SessionChallenges(Clock.fixed(issued.plusSeconds(301), ZoneOffset.UTC), policy).authentication();
|
||||||
|
assertThat(later.load(request)).isNull();
|
||||||
|
repository.save(request, response, options);
|
||||||
|
identify("bob", issued);
|
||||||
|
assertThat(repository.load(request)).isNull();
|
||||||
|
identify("alice", issued);
|
||||||
|
assertThat(repository.load(request)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void challengeIsConsumedOnceAndUpstreamCleanupCannotEraseNewChallenge() {
|
||||||
|
var issued = Instant.now();
|
||||||
|
identify("alice", issued);
|
||||||
|
var request = new MockHttpServletRequest();
|
||||||
|
var response = new MockHttpServletResponse();
|
||||||
|
var repository = new SessionChallenges(Clock.systemUTC(), policy).authentication();
|
||||||
|
var first = PublicKeyCredentialRequestOptions.builder().rpId("localhost").challenge(Bytes.random()).build();
|
||||||
|
var second = PublicKeyCredentialRequestOptions.builder().rpId("localhost").challenge(Bytes.random()).build();
|
||||||
|
repository.save(request, response, first);
|
||||||
|
assertThat(repository.load(request)).isSameAs(first);
|
||||||
|
assertThat(repository.load(request)).isNull();
|
||||||
|
repository.save(request, response, second);
|
||||||
|
repository.save(request, response, null);
|
||||||
|
assertThat(repository.load(request)).isSameAs(second);
|
||||||
|
repository.save(request, response, first);
|
||||||
|
identify("alice", issued.minusSeconds(601));
|
||||||
|
assertThat(repository.load(request)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void identify(String id, Instant issued) {
|
||||||
|
var user = new User(new User.UserId("example.test", id), id, id, "", List.of());
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(UsernamePasswordAuthenticationToken.authenticated(
|
||||||
|
new DirectoryPrincipal(user), null, List.of(FactorGrantedAuthority.withAuthority("FACTOR_PASSWORD")
|
||||||
|
.issuedAt(issued).build())));
|
||||||
|
}
|
||||||
|
}
|
||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.mock.web.MockHttpSession;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.FactorGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||||
|
import org.springframework.jdbc.core.JdbcOperations;
|
||||||
|
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
import top.ddupan.iam.login.support.AdDirectoryFixture;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@SpringBootTest(properties = {"iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test",
|
||||||
|
"spring.datasource.hikari.maximum-pool-size=3", "iam.clients.admin-group-dns[0]=CN=gitea-admins,dc=example,dc=test", "iam.hydra.enabled=true", "iam.hydra.admin-url=http://127.0.0.1:14445", "iam.hydra.public-url=http://localhost:14444",
|
||||||
|
"iam.hydra.clients[0]=gitea-fixture", "iam.hydra.subjects[0].authority=example.test",
|
||||||
|
"iam.hydra.subjects[0].directory-id=00112233-4455-6677-8899-aabbccddeeff",
|
||||||
|
"iam.hydra.subjects[0].subject=human:existing-subject",
|
||||||
|
"iam.webauthn.enabled=true", "iam.webauthn.rp-id=localhost", "iam.webauthn.origin=https://localhost",
|
||||||
|
"management.otlp.metrics.export.enabled=false", "spring.security.user.password=fixture-monitor-password"})
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class WebAuthnIntegrationTests {
|
||||||
|
static class Fixtures {
|
||||||
|
static final AdDirectoryFixture DIRECTORY = new AdDirectoryFixture();
|
||||||
|
static final PostgreSQLContainer DATABASE = new PostgreSQLContainer(DockerImageName.parse(
|
||||||
|
"postgres@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873")
|
||||||
|
.asCompatibleSubstituteFor("postgres"));
|
||||||
|
static { DATABASE.start(); }
|
||||||
|
}
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void properties(DynamicPropertyRegistry r) {
|
||||||
|
r.add("iam.ad.url", () -> Fixtures.DIRECTORY.url());
|
||||||
|
r.add("spring.datasource.url", () -> Fixtures.DATABASE.getJdbcUrl());
|
||||||
|
r.add("spring.datasource.username", () -> Fixtures.DATABASE.getUsername());
|
||||||
|
r.add("spring.datasource.password", () -> Fixtures.DATABASE.getPassword());
|
||||||
|
}
|
||||||
|
@AfterAll static void close() { Fixtures.DIRECTORY.close(); Fixtures.DATABASE.stop(); }
|
||||||
|
@Autowired MockMvc mvc;
|
||||||
|
@Autowired JdbcOperations jdbc;
|
||||||
|
@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.domain.ClientRepository clients;
|
||||||
|
|
||||||
|
@Autowired org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||||
|
@Autowired javax.sql.DataSource dataSource;
|
||||||
|
|
||||||
|
@Test void registrationLockSerializesAndRollsBackOnFailure() throws Exception {
|
||||||
|
assertThat(((com.zaxxer.hikari.HikariDataSource)dataSource).getMaximumPoolSize()).isEqualTo(3);
|
||||||
|
var registrations = new top.ddupan.iam.login.authentication.infrastructure.persistence.JdbcCredentialRegistration(
|
||||||
|
jdbc, new org.springframework.transaction.support.TransactionTemplate(transactionManager));
|
||||||
|
String id = java.util.UUID.randomUUID().toString();
|
||||||
|
jdbc.update("insert into user_entities(id,name,display_name) values (?,?,?)", id,id,"before");
|
||||||
|
var locked = new java.util.concurrent.CountDownLatch(1);
|
||||||
|
var release = new java.util.concurrent.CountDownLatch(1);
|
||||||
|
var started = new java.util.concurrent.CountDownLatch(1);
|
||||||
|
try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
|
||||||
|
var first = executor.submit(() -> registrations.register(id, () -> {
|
||||||
|
jdbc.update("update user_entities set display_name='committed' where id=?",id);
|
||||||
|
locked.countDown();
|
||||||
|
try {
|
||||||
|
if (!release.await(5,java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("Test timed out");
|
||||||
|
} catch (InterruptedException ex) { Thread.currentThread().interrupt(); throw new IllegalStateException(ex); }
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
assertThat(locked.await(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();
|
||||||
|
var second = executor.submit(() -> {
|
||||||
|
started.countDown();
|
||||||
|
return registrations.register(id, () -> jdbc.queryForObject(
|
||||||
|
"select display_name from user_entities where id=?",String.class,id));
|
||||||
|
});
|
||||||
|
assertThat(started.await(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();
|
||||||
|
assertThatThrownBy(() -> second.get(200,java.util.concurrent.TimeUnit.MILLISECONDS))
|
||||||
|
.isInstanceOf(java.util.concurrent.TimeoutException.class);
|
||||||
|
release.countDown();
|
||||||
|
assertThat(first.get(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();
|
||||||
|
assertThat(second.get(5,java.util.concurrent.TimeUnit.SECONDS)).isEqualTo("committed");
|
||||||
|
} finally { release.countDown(); }
|
||||||
|
}
|
||||||
|
assertThatThrownBy(() -> registrations.register(id, () -> {
|
||||||
|
jdbc.update("update user_entities set display_name='rolled back' where id=?",id);
|
||||||
|
throw new IllegalStateException("Registration rejected");
|
||||||
|
})).isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(jdbc.queryForObject("select display_name from user_entities where id=?",String.class,id))
|
||||||
|
.isEqualTo("committed");
|
||||||
|
jdbc.update("delete from user_entities where id=?",id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clientAdministrationRequiresMfaAdminGroupAndCsrf() throws Exception {
|
||||||
|
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.HydraGateway.LogoutRequest(
|
||||||
|
"logout-challenge","human:existing-subject","sid","");
|
||||||
|
org.mockito.Mockito.when(hydra.logout("logout-challenge")).thenReturn(data);
|
||||||
|
org.mockito.Mockito.when(hydra.logoutUrl()).thenReturn("http://localhost:14444/oauth2/sessions/logout");
|
||||||
|
org.mockito.Mockito.when(hydra.acceptLogout("logout-challenge")).thenReturn("http://localhost:14444/oauth2/sessions/logout?logout_verifier=fixture");
|
||||||
|
var html=mvc.perform(get("/oauth2/logout").session(session).with(https()).param("logout_challenge","logout-challenge"))
|
||||||
|
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
|
||||||
|
var match=java.util.regex.Pattern.compile("\"binding\":\"([^\"]+)\"").matcher(html);
|
||||||
|
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(hydra).acceptLogout("logout-challenge");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void passwordOnlyCanEnrollButCannotCompleteOrDelete() throws Exception {
|
||||||
|
var session = login();
|
||||||
|
mvc.perform(post("/webauthn/register/options").session(session).with(https()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
mvc.perform(post("/webauthn/register/options").session(session).with(https()).with(csrf()))
|
||||||
|
.andExpect(status().isOk()).andExpect(jsonPath("rp.id").value("localhost"))
|
||||||
|
.andExpect(jsonPath("authenticatorSelection.userVerification").value("required"));
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from user_entities", Integer.class)).isEqualTo(1);
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from user_credentials", Integer.class)).isZero();
|
||||||
|
mvc.perform(get("/signin/complete").session(session).with(https()))
|
||||||
|
.andExpect(redirectedUrlPattern("/signin/mfa?*"));
|
||||||
|
mvc.perform(delete("/webauthn/register/unused").session(session).with(https()).with(csrf()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optionsRequireFreshPasswordEvenThoughFrameworkProcessesThemBeforeAuthorization() throws Exception {
|
||||||
|
mvc.perform(post("/webauthn/authenticate/options").with(https()).with(csrf()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
var session = login();
|
||||||
|
var context = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT");
|
||||||
|
var auth = context.getAuthentication();
|
||||||
|
context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(auth.getPrincipal(), null,
|
||||||
|
List.of(FactorGrantedAuthority.withAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)
|
||||||
|
.issuedAt(Instant.now().minusSeconds(601)).build())));
|
||||||
|
mvc.perform(post("/webauthn/register/options").session(session).with(https()).with(csrf()))
|
||||||
|
.andExpect(status().is3xxRedirection());
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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<org.springframework.security.core.GrantedAuthority>(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())
|
||||||
|
.param("username", "alice").param("password", "fixture-password"))
|
||||||
|
.andExpect(redirectedUrl("/signin/mfa"));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
private static RequestPostProcessor https() {
|
||||||
|
return request -> { request.setScheme("https"); request.setSecure(true); request.setServerPort(443); return request; };
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -25,7 +25,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
/** Real LDAPS sockets and Spring Data LDAP; AD bind/subcode semantics are simulated. */
|
/** Real LDAPS sockets and Spring Data LDAP; AD bind/subcode semantics are simulated. */
|
||||||
@SpringBootTest(properties = {"iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test",
|
@SpringBootTest(properties = {"spring.autoconfigure.exclude=org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", "iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test",
|
||||||
"management.otlp.metrics.export.enabled=false", "spring.security.user.name=fixture-monitor", "spring.security.user.password=fixture-monitor-password"})
|
"management.otlp.metrics.export.enabled=false", "spring.security.user.name=fixture-monitor", "spring.security.user.password=fixture-monitor-password"})
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@org.springframework.boot.micrometer.metrics.test.autoconfigure.AutoConfigureMetrics
|
@org.springframework.boot.micrometer.metrics.test.autoconfigure.AutoConfigureMetrics
|
||||||
|
|||||||
@@ -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",
|
||||||
|
"[email protected]", 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", "[email protected]", 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<String> scopes, List<String> audience) {
|
||||||
|
return new AuthorizationRequest("challenge", client, scopes, audience, "", null, null, "https://issuer/oauth2/auth", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> scopes) {
|
||||||
|
return new AuthorizationRequest("consent", client, scopes, List.of(), subject, digest, binding, "https://issuer/oauth2/auth", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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 {
|
||||||
|
private org.springframework.web.client.RestClient http(HttpServer server) {
|
||||||
|
var factory = new org.springframework.http.client.JdkClientHttpRequestFactory(
|
||||||
|
java.net.http.HttpClient.newBuilder().followRedirects(java.net.http.HttpClient.Redirect.NEVER).build());
|
||||||
|
return org.springframework.web.client.RestClient.builder()
|
||||||
|
.baseUrl("http://127.0.0.1:" + server.getAddress().getPort()).requestFactory(factory).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void logoutAcceptsOriginRelativeRequestsButRejectsForeignOrigins() throws Exception {
|
||||||
|
var payload = new AtomicReference<String>();
|
||||||
|
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 HydraAdminClient(new HydraProperties(true,
|
||||||
|
URI.create("http://127.0.0.1:" + server.getAddress().getPort()), URI.create("http://localhost:14444"),
|
||||||
|
Set.of(), List.of()), http(server));
|
||||||
|
payload.set("{\"challenge\":\"challenge\",\"subject\":\"subject\",\"sid\":\"session\",\"request_url\":\"/oauth2/sessions/logout\"}");
|
||||||
|
assertThat(client.logout("challenge").subject()).isEqualTo("subject");
|
||||||
|
for (String url : List.of("//attacker.example/oauth2/sessions/logout", "https://attacker.example/oauth2/sessions/logout", "/admin/clients")) {
|
||||||
|
payload.set("{\"challenge\":\"challenge\",\"request_url\":\"" + url + "\"}");
|
||||||
|
assertThatIllegalArgumentException().isThrownBy(() -> client.logout("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()), http(server));
|
||||||
|
assertThat(client.acceptLogin("challenge", "subject", "binding", Instant.now()))
|
||||||
|
.startsWith("http://localhost:14444/oauth2/auth?");
|
||||||
|
for (var target : List.of("https://attacker.example/oauth2/auth", "http://localhost:14444/admin/clients",
|
||||||
|
"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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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.clients.domain.ClientRepositoryException;
|
||||||
|
import top.ddupan.iam.login.clients.domain.OidcClient;
|
||||||
|
import top.ddupan.iam.login.clients.infrastructure.HydraClientRepository;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class HydraRepositoryIntegrationTests {
|
||||||
|
private static final String IMAGE="oryd/hydra:v26.2.0@sha256:ff67c7fb5f95074fa53374d41151713554960504b340cd3f95b09e65deaea2a9";
|
||||||
|
private static final URI ADMIN=URI.create("http://127.0.0.1:14845");
|
||||||
|
@Test void crudPersistsAcrossIssuerRestartAndDoesNotReturnSecretsOnRead() throws Exception {
|
||||||
|
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 HydraClientRepository(org.springframework.web.client.RestClient.builder().baseUrl(ADMIN.toString()).build());
|
||||||
|
var client=new OidcClient("managed-fixture","Fixture",List.of("https://rp.example/callback"),Set.of("openid","groups"),
|
||||||
|
List.of("https://rp.example/bye"),"https://rp.example/backchannel","",true);
|
||||||
|
var created=registry.create(client);
|
||||||
|
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(ClientRepositoryException.class,
|
||||||
|
ex -> assertThat(ex.kind()).isEqualTo(ClientRepositoryException.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://[email protected]/callback")) {
|
||||||
|
assertThatIllegalArgumentException().isThrownBy(() -> new OidcClient("id","Name",List.of(target),Set.of("openid"),List.of(),"","",true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,9 @@ public final class AdDirectoryFixture implements AutoCloseable {
|
|||||||
"80090308: LdapErr: DSID-0C090334, comment: AcceptSecurityContext error, data " + subcode + ", v1db1");
|
"80090308: LdapErr: DSID-0C090334, comment: AcceptSecurityContext error, data " + subcode + ", v1db1");
|
||||||
if (name.equalsIgnoreCase("[email protected]")) {
|
if (name.equalsIgnoreCase("[email protected]")) {
|
||||||
request.setRequest(new SimpleBindRequest(USER_DN, request.getRequest().getPassword().getValue()));
|
request.setRequest(new SimpleBindRequest(USER_DN, request.getRequest().getPassword().getValue()));
|
||||||
} else if (!name.equals(USER_DN)) {
|
} else if (name.equalsIgnoreCase("[email protected]")) {
|
||||||
|
request.setRequest(new SimpleBindRequest("cn=Bob," + BASE, request.getRequest().getPassword().getValue()));
|
||||||
|
} else if (!name.equals(USER_DN) && !name.equals("cn=Bob," + BASE)) {
|
||||||
throw new LDAPException(ResultCode.INVALID_CREDENTIALS, "Invalid credentials");
|
throw new LDAPException(ResultCode.INVALID_CREDENTIALS, "Invalid credentials");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +92,14 @@ public final class AdDirectoryFixture implements AutoCloseable {
|
|||||||
new com.unboundid.ldap.sdk.Attribute("mail", "[email protected]"),
|
new com.unboundid.ldap.sdk.Attribute("mail", "[email protected]"),
|
||||||
new com.unboundid.ldap.sdk.Attribute("objectGUID", GUID),
|
new com.unboundid.ldap.sdk.Attribute("objectGUID", GUID),
|
||||||
new com.unboundid.ldap.sdk.Attribute("memberOf", "CN=gitea-admins," + BASE, "CN=MixedCase," + BASE)));
|
new com.unboundid.ldap.sdk.Attribute("memberOf", "CN=gitea-admins," + BASE, "CN=MixedCase," + BASE)));
|
||||||
|
ldap.add(new Entry("cn=Bob," + BASE,
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("objectClass", "user"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("cn", "Bob"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("sAMAccountName", "bob"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("userPrincipalName", "[email protected]"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("userPassword", "fixture-password"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("displayName", "Bob"),
|
||||||
|
new com.unboundid.ldap.sdk.Attribute("objectGUID", new byte[16])));
|
||||||
} catch (Exception ex) { throw new ExceptionInInitializerError(ex); }
|
} catch (Exception ex) { throw new ExceptionInInitializerError(ex); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user