收敛 IAM 仓储与 Hydra 网关并恢复数据源自动配置
This commit is contained in:
@@ -53,6 +53,7 @@ npm --prefix frontend run build
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
应用启动需要配置 PostgreSQL 数据源,连接与开发容器说明见[第二因素](docs/webauthn.md)。
|
||||
测试需要可用的 Docker,生成器配置了 Grafana LGTM Testcontainer。
|
||||
测试覆盖 AD 第一因素、浏览器流程和监控集成。人类登录统一从 `/signin` 进入。
|
||||
使用 GraalVM 25 验证原生测试与编译:
|
||||
@@ -87,11 +88,12 @@ configuration → 装配上述实现
|
||||
- `authentication/infrastructure/ad`:AD bind、Spring Data LDAP 用户仓储、LDAP 实体与领域映射。
|
||||
使用同一次用户 bind 的连接,查询结束关闭,不新增服务账号,不保存用户密码。
|
||||
- `authentication/infrastructure/security`:Provider 将目录用户转换为仅含密码因素的认证结果。
|
||||
- `authentication/infrastructure/persistence`:封装注册并发锁及事务,不把表结构带入 Security 策略。
|
||||
- `authentication/infrastructure/webauthn`:凭据归属与注册策略、challenge 仓储扩展;密码学校验和 JDBC 存储交给 Spring Security。
|
||||
- `authentication/interfaces/web`:登录页面与上下文转换;不处理密码 POST、认证会话或退出。
|
||||
- `configuration`:Spring 组件装配、安全链、静态资源和 Native hints。
|
||||
- `authorization`:领域请求、应用授权用例与策略、Hydra Admin 基础设施、浏览器请求绑定分层维护;客户端注册与签发仍归 Hydra。
|
||||
- `clients`:受 MFA 与直接管理组保护的客户端 CRUD;持久化与密钥由 Hydra 持有,见[管理接口](docs/client-management.md)。
|
||||
- `clients/domain/ClientRepository`:客户端仓储契约,由 Hydra HTTP 实现;受 MFA 与直接管理组保护的客户端 CRUD;持久化与密钥由 Hydra 持有,见[管理接口](docs/client-management.md)。
|
||||
- `frontend/src`:入口、页面、表单组件和页面数据契约分别维护,只包含真实登录流程。
|
||||
|
||||
测试覆盖应用用例、AD 仓储和完整 Spring Security 过滤器链,LDAP 夹具集中在测试 `support` 包。
|
||||
|
||||
@@ -30,6 +30,7 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-restclient'
|
||||
implementation 'org.springframework.security:spring-security-webauthn'
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# OAuth2/OIDC 客户端管理
|
||||
|
||||
本服务通过受限 API 管理 Hydra 中的第一方 confidential authorization-code 客户端。
|
||||
领域层定义 `ClientRepository`,基础设施的 `HydraClientRepository` 实现远端持久化。
|
||||
API 没有管理 UI,沿用浏览器 Spring session;调用者必须完成有效的密码 + WebAuthn MFA,
|
||||
且直接属于配置的管理组。默认组列表为空,拒绝全部管理操作。
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ iam:
|
||||
enabled: true
|
||||
admin-url: http://hydra-admin.hydra.svc.cluster.local:4445
|
||||
public-url: https://hydra.ad.ddupan.top
|
||||
http:
|
||||
connect-timeout: 3s
|
||||
read-timeout: 5s
|
||||
clients: [gitea] # 仅供尚未写入管理标记的旧客户端过渡
|
||||
subjects:
|
||||
- authority: ad.example.test
|
||||
@@ -63,6 +66,9 @@ Hydra 环境配置需将 login URL 指向 `/oauth2/start`,consent URL 指向 `
|
||||
logout URL 指向 `/oauth2/logout`,默认 post-logout URL 指向本服务 `/signin`。
|
||||
本仓库的实现与测试不等于这些生产设置已变更。Admin URL 可以使用现役受 NetworkPolicy
|
||||
约束的集群内 HTTP,也可通过 loopback port-forward;不能公开管理端口。
|
||||
HTTP client 在配置层使用 Boot 提供的 `RestClient.Builder` 装配并注入,保留框架定制与观测能力。
|
||||
上述连接/读取超时有默认值且必须为正,可按部署环境覆盖;禁止跟随重定向不提供关闭开关。
|
||||
Login、Consent 和 Logout 共用 `HydraGateway` 与 Admin client。
|
||||
公共 origin 必须 HTTPS,HTTP 只接受隔离测试的 loopback。客户端请求必须显式带 redirect_uri。
|
||||
|
||||
## 客户端注册与管理边界
|
||||
|
||||
@@ -44,3 +44,10 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需
|
||||
|
||||
通过上述测试后才准备生产切换;保留现役 OIDC 上游适配器作为回退路径。应用镜像以
|
||||
不可变 digest 交给 homelab-infra,部署状态与真实人类 MFA 验收分别记录。
|
||||
|
||||
### 测试 AOT 的待排查项(2026-09-29)
|
||||
|
||||
本轮重构的 `processTestAot` 在处理测试上下文后未退出;线程栈显示 `DestroyJavaVM`
|
||||
等待测试夹具的非 daemon LDAP listener。该轮日常验证使用
|
||||
`test bootJar -x processTestAot -x compileAotTestJava -x processAotTestResources`
|
||||
执行 JVM 测试,不把该结果视为测试 AOT 或 Native 验收。阶段性原生验证前需解决夹具生命周期。
|
||||
|
||||
@@ -70,3 +70,14 @@ IAM_WEBAUTHN_FIXTURE=1 npm --prefix frontend run test:browser -- webauthn.spec.t
|
||||
测试专用启动类仅在 test classpath,不进入生产 JAR,也不提供生产调试 API。
|
||||
维护者已确认开发入口的 AD + passkey 人类路径能够工作。更多认证器兼容性和 Native 路径
|
||||
仍需独立验收,不能套用虚拟认证器结果。生产共享 PostgreSQL 的接入留在部署阶段。
|
||||
|
||||
## 数据源与注册事务
|
||||
|
||||
PostgreSQL 是应用运行依赖,通过 `spring.datasource.*` 配置,连接池参数使用
|
||||
`spring.datasource.hikari.*`。DataSource、JdbcTemplate 与事务管理器由 Boot 自动配置,
|
||||
WebAuthn 配置仅装配官方凭据仓储及认证策略;不在应用配置中排除 DataSource 自动配置。
|
||||
仅不涉及持久化的独立测试显式排除它。
|
||||
|
||||
首次注册的数据库锁由 `authentication/infrastructure/persistence/JdbcCredentialRegistration`
|
||||
封装;获得用户行锁后,在同一事务中重新检查注册策略并调用官方凭据保存逻辑。
|
||||
WebAuthn 策略集成不直接引用 SQL 或表结构。失败回滚与跨连接串行化由 PostgreSQL 集成测试覆盖。
|
||||
|
||||
+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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+5
-11
@@ -1,11 +1,10 @@
|
||||
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import top.ddupan.iam.login.authentication.infrastructure.persistence.JdbcCredentialRegistration;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.webauthn.api.*;
|
||||
import org.springframework.security.web.webauthn.management.*;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
|
||||
|
||||
/** Adds directory ownership/enrollment policy; verification and storage remain upstream implementations. */
|
||||
@@ -14,18 +13,16 @@ public final class DirectoryRelyingPartyOperations implements WebAuthnRelyingPar
|
||||
private final MfaPolicy policy;
|
||||
private final PublicKeyCredentialUserEntityRepository users;
|
||||
private final UserCredentialRepository credentials;
|
||||
private final JdbcOperations jdbc;
|
||||
private final TransactionTemplate transactions;
|
||||
private final JdbcCredentialRegistration registrations;
|
||||
|
||||
public DirectoryRelyingPartyOperations(WebAuthnRelyingPartyOperations delegate, MfaPolicy policy,
|
||||
PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials,
|
||||
JdbcOperations jdbc, TransactionTemplate transactions) {
|
||||
JdbcCredentialRegistration registrations) {
|
||||
this.delegate = delegate;
|
||||
this.policy = policy;
|
||||
this.users = users;
|
||||
this.credentials = credentials;
|
||||
this.jdbc = jdbc;
|
||||
this.transactions = transactions;
|
||||
this.registrations = registrations;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -41,10 +38,7 @@ public final class DirectoryRelyingPartyOperations implements WebAuthnRelyingPar
|
||||
var principal = policy.current();
|
||||
var owner = request.getCreationOptions().getUser();
|
||||
if (!principal.getName().equals(owner.getName())) throw new AccessDeniedException("Credential owner mismatch");
|
||||
return transactions.execute(status -> {
|
||||
// Serialize first enrollment across sessions/processes, then recheck existing factors.
|
||||
jdbc.queryForObject("select id from user_entities where id = ? for update", String.class,
|
||||
owner.getId().toBase64UrlString());
|
||||
return registrations.register(owner.getId().toBase64UrlString(), () -> {
|
||||
policy.requireEnrollment(SecurityContextHolder.getContext().getAuthentication());
|
||||
return delegate.registerCredential(request);
|
||||
});
|
||||
|
||||
+3
-3
@@ -21,9 +21,9 @@ public class SignInController {
|
||||
private final PageRenderer renderer;
|
||||
private final ObjectProvider<MfaPolicy> policies;
|
||||
|
||||
private final ObjectProvider<top.ddupan.iam.login.authorization.application.port.LogoutGateway> logout;
|
||||
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.LogoutGateway> logout) {
|
||||
ObjectProvider<top.ddupan.iam.login.authorization.application.port.HydraGateway> logout) {
|
||||
this.logout=logout;
|
||||
this.renderer = renderer;
|
||||
this.policies = policies;
|
||||
@@ -62,7 +62,7 @@ public class SignInController {
|
||||
"groupDns", user.memberships().stream().map(GroupMembership::externalId).toList())));
|
||||
var gateway = logout.getIfAvailable();
|
||||
if (gateway == null) return page;
|
||||
var uri = java.net.URI.create(gateway.startUrl());
|
||||
var uri = java.net.URI.create(gateway.logoutUrl());
|
||||
return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy",
|
||||
PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'",
|
||||
"form-action 'self' " + uri.getScheme() + "://" + uri.getRawAuthority())).body(page.getBody());
|
||||
|
||||
@@ -6,6 +6,10 @@ import java.util.Map;
|
||||
import top.ddupan.iam.login.authorization.domain.AuthorizationRequest;
|
||||
|
||||
public interface HydraGateway {
|
||||
record LogoutRequest(String challenge, String subject, String sid, String postLogoutRedirectUri) { }
|
||||
LogoutRequest logout(String challenge);
|
||||
String acceptLogout(String challenge);
|
||||
String logoutUrl();
|
||||
AuthorizationRequest login(String challenge);
|
||||
AuthorizationRequest consent(String challenge);
|
||||
String acceptLogin(String challenge, String subject, String binding, Instant authenticatedAt);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package top.ddupan.iam.login.authorization.application.port;
|
||||
|
||||
public interface LogoutGateway {
|
||||
record Request(String challenge, String subject, String sid, String postLogoutRedirectUri) { }
|
||||
Request request(String challenge);
|
||||
String accept(String challenge);
|
||||
String startUrl();
|
||||
}
|
||||
+50
-12
@@ -1,13 +1,10 @@
|
||||
package top.ddupan.iam.login.authorization.infrastructure.hydra;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import top.ddupan.iam.login.authorization.application.port.HydraGateway;
|
||||
import top.ddupan.iam.login.authorization.domain.AuthorizationRequest;
|
||||
@@ -16,20 +13,13 @@ import top.ddupan.iam.login.authorization.domain.AuthorizationRequest;
|
||||
public final class HydraAdminClient implements HydraGateway {
|
||||
private final RestClient client;
|
||||
private final URI publicUrl;
|
||||
public HydraAdminClient(HydraProperties properties) {
|
||||
client = restClient(properties).mutate()
|
||||
public HydraAdminClient(HydraProperties properties, RestClient http) {
|
||||
client = http.mutate()
|
||||
.defaultStatusHandler(status -> !status.is2xxSuccessful(), (request, response) -> {
|
||||
throw new IllegalArgumentException("Hydra returned a non-success status");
|
||||
}).build();
|
||||
publicUrl = properties.publicUrl();
|
||||
}
|
||||
public static RestClient restClient(HydraProperties properties) {
|
||||
var http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3))
|
||||
.followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
var factory = new JdkClientHttpRequestFactory(http);
|
||||
factory.setReadTimeout(Duration.ofSeconds(5));
|
||||
return RestClient.builder().baseUrl(properties.adminUrl().toString()).requestFactory(factory).build();
|
||||
}
|
||||
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Client(String client_id, Map<String,Object> metadata) { }
|
||||
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -119,4 +109,52 @@ public final class HydraAdminClient implements HydraGateway {
|
||||
throw new IllegalArgumentException("Invalid challenge");
|
||||
}
|
||||
}
|
||||
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true)
|
||||
public record LogoutResponse(String challenge,String subject,String sid,String request_url, LogoutClient client) { }
|
||||
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true)
|
||||
public record LogoutClient(java.util.List<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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
package top.ddupan.iam.login.authorization.infrastructure.hydra;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Objects;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import top.ddupan.iam.login.authorization.application.port.LogoutGateway;
|
||||
|
||||
public final class HydraLogoutClient implements LogoutGateway {
|
||||
private final RestClient http;
|
||||
private final URI origin;
|
||||
public HydraLogoutClient(HydraProperties properties) {
|
||||
origin = properties.publicUrl();
|
||||
http = HydraAdminClient.restClient(properties).mutate()
|
||||
.defaultStatusHandler(status -> !status.is2xxSuccessful(), (req,res) -> { throw new IllegalArgumentException("Logout unavailable"); }).build();
|
||||
}
|
||||
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true)
|
||||
public record Response(String challenge,String subject,String sid,String request_url, LogoutClient client) { }
|
||||
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true)
|
||||
public record LogoutClient(java.util.List<String> post_logout_redirect_uris) { }
|
||||
@Override public Request request(String challenge) {
|
||||
validate(challenge);
|
||||
try {
|
||||
var result = Objects.requireNonNull(http.get().uri(b -> b.path("/admin/oauth2/auth/requests/logout")
|
||||
.queryParam("logout_challenge","{challenge}").build(challenge)).retrieve().body(Response.class));
|
||||
if (!challenge.equals(result.challenge())) throw new IllegalArgumentException("Wrong challenge");
|
||||
// Hydra stores the original HTTP request-target, which may be origin-relative.
|
||||
validateTarget(origin.resolve(result.request_url()).toString());
|
||||
String callback = "";
|
||||
var query = URI.create(result.request_url()).getRawQuery();
|
||||
if (query != null) for (var part : query.split("&")) {
|
||||
var pair = part.split("=",2);
|
||||
if ("post_logout_redirect_uri".equals(java.net.URLDecoder.decode(pair[0],java.nio.charset.StandardCharsets.UTF_8))) {
|
||||
if (!callback.isEmpty() || pair.length!=2) throw new IllegalArgumentException("Duplicate logout redirect");
|
||||
callback=java.net.URLDecoder.decode(pair[1],java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
if (!callback.isEmpty()) {
|
||||
if (result.client()==null || result.client().post_logout_redirect_uris()==null
|
||||
|| !result.client().post_logout_redirect_uris().contains(callback)) throw new IllegalArgumentException("Unregistered logout redirect");
|
||||
var uri = URI.create(callback);
|
||||
if (uri.getHost()==null || uri.getUserInfo()!=null || uri.getFragment()!=null
|
||||
|| !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())
|
||||
&& java.util.Set.of("localhost","127.0.0.1").contains(uri.getHost())))
|
||||
throw new IllegalArgumentException("Invalid logout callback");
|
||||
}
|
||||
return new Request(challenge,result.subject(),result.sid(),callback);
|
||||
} catch (RuntimeException ex) { throw new IllegalArgumentException("Logout request unavailable"); }
|
||||
}
|
||||
@Override public String accept(String challenge) {
|
||||
validate(challenge);
|
||||
try {
|
||||
var result = Objects.requireNonNull(http.put().uri(b -> b.path("/admin/oauth2/auth/requests/logout/accept")
|
||||
.queryParam("logout_challenge","{challenge}").build(challenge)).retrieve().body(HydraAdminClient.Redirect.class));
|
||||
validateTarget(result.redirect_to()); return result.redirect_to();
|
||||
} catch (RuntimeException ex) { throw new IllegalArgumentException("Logout acceptance unavailable"); }
|
||||
}
|
||||
@Override public String startUrl() { return origin.resolve("/oauth2/sessions/logout").toString(); }
|
||||
private void validateTarget(String target) {
|
||||
var uri = URI.create(target);
|
||||
if (!origin.getScheme().equals(uri.getScheme()) || !origin.getRawAuthority().equals(uri.getRawAuthority())
|
||||
|| uri.getUserInfo()!=null || uri.getFragment()!=null || !"/oauth2/sessions/logout".equals(uri.getRawPath()))
|
||||
throw new IllegalArgumentException("Invalid logout redirect");
|
||||
}
|
||||
private void validate(String challenge) {
|
||||
if (challenge == null || challenge.isBlank() || challenge.length()>8192) throw new IllegalArgumentException("Invalid challenge");
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -14,7 +14,7 @@ import org.springframework.security.web.authentication.logout.SecurityContextLog
|
||||
import org.springframework.security.web.csrf.CsrfToken;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import top.ddupan.iam.login.authentication.interfaces.web.PageRenderer;
|
||||
import top.ddupan.iam.login.authorization.application.port.LogoutGateway;
|
||||
import top.ddupan.iam.login.authorization.application.port.HydraGateway;
|
||||
import top.ddupan.iam.login.authorization.application.AuthorizationPolicy;
|
||||
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
|
||||
|
||||
@@ -22,16 +22,16 @@ import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrin
|
||||
@ConditionalOnProperty(prefix="iam.hydra",name="enabled",havingValue="true")
|
||||
public class HydraLogoutController {
|
||||
private static final String KEY = HydraLogoutController.class.getName();
|
||||
private record Pending(LogoutGateway.Request request,String binding,Instant expires) { }
|
||||
private final LogoutGateway hydra;
|
||||
private record Pending(HydraGateway.LogoutRequest request,String binding,Instant expires) { }
|
||||
private final HydraGateway hydra;
|
||||
private final PageRenderer renderer;
|
||||
private final AuthorizationPolicy policy;
|
||||
public HydraLogoutController(LogoutGateway hydra,PageRenderer renderer,AuthorizationPolicy policy) {
|
||||
public HydraLogoutController(HydraGateway hydra,PageRenderer renderer,AuthorizationPolicy policy) {
|
||||
this.hydra=hydra; this.renderer=renderer; this.policy=policy;
|
||||
}
|
||||
@GetMapping("/oauth2/logout") ResponseEntity<String> page(@RequestParam("logout_challenge") String challenge,
|
||||
HttpServletRequest request,Authentication auth,CsrfToken csrf) {
|
||||
var logout = hydra.request(challenge);
|
||||
var logout = hydra.logout(challenge);
|
||||
if (auth != null && auth.getPrincipal() instanceof DirectoryPrincipal principal && logout.subject()!=null
|
||||
&& !logout.subject().isBlank() && !policy.subject(principal.user()).equals(logout.subject()))
|
||||
throw new IllegalArgumentException("Different logout subject");
|
||||
@@ -39,7 +39,7 @@ public class HydraLogoutController {
|
||||
request.getSession().setAttribute(KEY,pending);
|
||||
var page = renderer.render(Map.of("step","logout","name","","error","","action","/oauth2/logout",
|
||||
"binding",pending.binding(),"csrf",Map.of("name",csrf.getParameterName(),"value",csrf.getToken(),"headerName",csrf.getHeaderName())));
|
||||
String targets=origin(hydra.startUrl());
|
||||
String targets=origin(hydra.logoutUrl());
|
||||
if (!logout.postLogoutRedirectUri().isEmpty()) targets += " " + origin(logout.postLogoutRedirectUri());
|
||||
return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy",
|
||||
PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'", "form-action 'self' " + targets))
|
||||
@@ -56,9 +56,9 @@ public class HydraLogoutController {
|
||||
throw new IllegalArgumentException("Invalid logout binding");
|
||||
session.removeAttribute(KEY);
|
||||
}
|
||||
var current=hydra.request(pending.request().challenge());
|
||||
var current=hydra.logout(pending.request().challenge());
|
||||
if (!Objects.equals(current,pending.request())) throw new IllegalArgumentException("Logout request changed");
|
||||
var target=hydra.accept(current.challenge());
|
||||
var target=hydra.acceptLogout(current.challenge());
|
||||
new SecurityContextLogoutHandler().logout(request,response,authentication);
|
||||
return ResponseEntity.status(303).header("Cache-Control","no-store").location(URI.create(target)).build();
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package top.ddupan.iam.login.clients.application;
|
||||
|
||||
public final class ClientRegistryException extends RuntimeException {
|
||||
public enum Kind { NOT_FOUND, CONFLICT, UNAVAILABLE }
|
||||
private final Kind kind;
|
||||
public ClientRegistryException(Kind kind) { super("Client registry " + kind); this.kind = kind; }
|
||||
public Kind kind() { return kind; }
|
||||
}
|
||||
+3
-4
@@ -1,10 +1,9 @@
|
||||
package top.ddupan.iam.login.clients.application;
|
||||
package top.ddupan.iam.login.clients.domain;
|
||||
|
||||
import java.util.List;
|
||||
import top.ddupan.iam.login.clients.domain.OidcClient;
|
||||
|
||||
/** Hydra is the sole persistence authority. Secrets exist only in create/rotate responses. */
|
||||
public interface ClientRegistry {
|
||||
/** Client persistence contract. Secrets exist only in create responses. */
|
||||
public interface ClientRepository {
|
||||
record Created(OidcClient client, String secret) {
|
||||
@Override public String toString() { return "Created[client=" + client.id() + ", secret=REDACTED]"; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
+12
-14
@@ -3,30 +3,28 @@ package top.ddupan.iam.login.clients.infrastructure;
|
||||
import java.util.*;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import top.ddupan.iam.login.clients.application.*;
|
||||
import top.ddupan.iam.login.clients.domain.*;
|
||||
import top.ddupan.iam.login.clients.domain.OidcClient;
|
||||
import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties;
|
||||
import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraAdminClient;
|
||||
|
||||
public final class HydraClientRegistry implements ClientRegistry {
|
||||
public final class HydraClientRepository implements ClientRepository {
|
||||
public static final String ENABLED = "iam_login_enabled";
|
||||
private static final ParameterizedTypeReference<Map<String,Object>> OBJECT = new ParameterizedTypeReference<>() {};
|
||||
private static final ParameterizedTypeReference<List<Map<String,Object>>> ARRAY = new ParameterizedTypeReference<>() {};
|
||||
private final RestClient http;
|
||||
public HydraClientRegistry(HydraProperties properties) {
|
||||
http = HydraAdminClient.restClient(properties).mutate()
|
||||
public HydraClientRepository(RestClient client) {
|
||||
http = client.mutate()
|
||||
.defaultStatusHandler(status -> !status.is2xxSuccessful(), (request, response) -> {
|
||||
throw new ClientRegistryException(switch (response.getStatusCode().value()) {
|
||||
case 404 -> ClientRegistryException.Kind.NOT_FOUND;
|
||||
case 400, 409 -> ClientRegistryException.Kind.CONFLICT;
|
||||
default -> ClientRegistryException.Kind.UNAVAILABLE;
|
||||
throw new ClientRepositoryException(switch (response.getStatusCode().value()) {
|
||||
case 404 -> ClientRepositoryException.Kind.NOT_FOUND;
|
||||
case 400, 409 -> ClientRepositoryException.Kind.CONFLICT;
|
||||
default -> ClientRepositoryException.Kind.UNAVAILABLE;
|
||||
});
|
||||
}).build();
|
||||
}
|
||||
private <T> T call(java.util.function.Supplier<T> operation) {
|
||||
try { return operation.get(); }
|
||||
catch (ClientRegistryException ex) { throw ex; }
|
||||
catch (RuntimeException ex) { throw new ClientRegistryException(ClientRegistryException.Kind.UNAVAILABLE); }
|
||||
catch (ClientRepositoryException ex) { throw ex; }
|
||||
catch (RuntimeException ex) { throw new ClientRepositoryException(ClientRepositoryException.Kind.UNAVAILABLE); }
|
||||
}
|
||||
@Override public List<OidcClient> list(int page, int size) {
|
||||
if (page < 0 || size < 1 || size > 100) throw new IllegalArgumentException("Invalid pagination");
|
||||
@@ -49,7 +47,7 @@ public final class HydraClientRegistry implements ClientRegistry {
|
||||
@Override public OidcClient update(OidcClient client) {
|
||||
return call(() -> {
|
||||
var previous = read(client.id());
|
||||
if (!supported(previous)) throw new ClientRegistryException(ClientRegistryException.Kind.CONFLICT);
|
||||
if (!supported(previous)) throw new ClientRepositoryException(ClientRepositoryException.Kind.CONFLICT);
|
||||
// Preserve issuer-owned fields and metadata, but never send back a stored secret/hash.
|
||||
var update = new LinkedHashMap<>(previous); update.remove("client_secret");
|
||||
var metadata = new LinkedHashMap<String,Object>();
|
||||
@@ -82,7 +80,7 @@ public final class HydraClientRegistry implements ClientRegistry {
|
||||
map.put("metadata",Map.of(ENABLED,c.loginEnabled())); return map;
|
||||
}
|
||||
@SuppressWarnings("unchecked") private OidcClient view(Map<String,Object> m) {
|
||||
if (!supported(m)) throw new ClientRegistryException(ClientRegistryException.Kind.CONFLICT);
|
||||
if (!supported(m)) throw new ClientRepositoryException(ClientRepositoryException.Kind.CONFLICT);
|
||||
String id = (String)m.get("client_id");
|
||||
String name = Objects.toString(m.get("client_name"),"");
|
||||
return new OidcClient(id, name.isBlank() ? id : name,
|
||||
@@ -6,31 +6,31 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.csrf.CsrfToken;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import top.ddupan.iam.login.clients.application.*;
|
||||
import top.ddupan.iam.login.clients.domain.*;
|
||||
import top.ddupan.iam.login.clients.domain.OidcClient;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix="iam.hydra", name="enabled", havingValue="true")
|
||||
public class ClientsController {
|
||||
private final ClientRegistry registry;
|
||||
private final ClientRepository repository;
|
||||
private static final org.slf4j.Logger AUDIT = org.slf4j.LoggerFactory.getLogger("iam.audit.clients");
|
||||
public ClientsController(ClientRegistry registry) { this.registry = registry; }
|
||||
public ClientsController(ClientRepository repository) { this.repository = repository; }
|
||||
@GetMapping("/api/iam/session") Object session(CsrfToken csrf) {
|
||||
return Map.of("csrf",Map.of("headerName",csrf.getHeaderName(),"token",csrf.getToken()));
|
||||
}
|
||||
@GetMapping("/api/iam/clients") Object list(@RequestParam(defaultValue="0") int page,
|
||||
@RequestParam(defaultValue="20") int size) { return registry.list(page,size); }
|
||||
@GetMapping("/api/iam/clients/{id}") OidcClient get(@PathVariable String id) { return registry.get(id); }
|
||||
@PostMapping("/api/iam/clients") ResponseEntity<ClientRegistry.Created> create(@RequestBody OidcClient input, Authentication auth) {
|
||||
var created = registry.create(input); audit("create",input.id(),auth);
|
||||
@RequestParam(defaultValue="20") int size) { return repository.list(page,size); }
|
||||
@GetMapping("/api/iam/clients/{id}") OidcClient get(@PathVariable String id) { return repository.get(id); }
|
||||
@PostMapping("/api/iam/clients") ResponseEntity<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 = registry.update(input); audit("update",id,auth); return updated;
|
||||
var updated = repository.update(input); audit("update",id,auth); return updated;
|
||||
}
|
||||
@DeleteMapping("/api/iam/clients/{id}") ResponseEntity<Void> delete(@PathVariable String id, Authentication auth) {
|
||||
registry.delete(id); audit("delete",id,auth); return ResponseEntity.noContent().build();
|
||||
repository.delete(id); audit("delete",id,auth); return ResponseEntity.noContent().build();
|
||||
}
|
||||
private void audit(String action,String id,Authentication auth) {
|
||||
AUDIT.info("client action={} client={} actor={}",action,id,auth.getName());
|
||||
@@ -38,7 +38,7 @@ public class ClientsController {
|
||||
@ExceptionHandler(IllegalArgumentException.class) ResponseEntity<?> invalid() {
|
||||
return ResponseEntity.badRequest().body(Map.of("error","invalid_client_request"));
|
||||
}
|
||||
@ExceptionHandler(ClientRegistryException.class) ResponseEntity<?> unavailable(ClientRegistryException ex) {
|
||||
@ExceptionHandler(ClientRepositoryException.class) ResponseEntity<?> unavailable(ClientRepositoryException ex) {
|
||||
int status = switch (ex.kind()) { case NOT_FOUND -> 404; case CONFLICT -> 409; case UNAVAILABLE -> 502; };
|
||||
return ResponseEntity.status(status).body(Map.of("error",ex.kind().name().toLowerCase(java.util.Locale.ROOT)));
|
||||
}
|
||||
|
||||
@@ -15,15 +15,14 @@ import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
|
||||
import top.ddupan.iam.login.authentication.infrastructure.webauthn.MfaPolicy;
|
||||
import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties;
|
||||
import top.ddupan.iam.login.clients.application.ClientRegistry;
|
||||
import top.ddupan.iam.login.clients.infrastructure.HydraClientRegistry;
|
||||
import top.ddupan.iam.login.clients.domain.ClientRepository;
|
||||
import top.ddupan.iam.login.clients.infrastructure.HydraClientRepository;
|
||||
import top.ddupan.iam.login.clients.domain.OidcClient;
|
||||
|
||||
@Configuration(proxyBeanMethods=false)
|
||||
@ConditionalOnProperty(prefix="iam.hydra",name="enabled",havingValue="true")
|
||||
@EnableConfigurationProperties(ClientManagementConfiguration.Access.class)
|
||||
@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({OidcClient.class,ClientRegistry.Created.class})
|
||||
@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({OidcClient.class,ClientRepository.Created.class})
|
||||
class ClientManagementConfiguration {
|
||||
@ConfigurationProperties("iam.clients")
|
||||
record Access(Set<String> adminGroupDns) {
|
||||
@@ -36,7 +35,7 @@ class ClientManagementConfiguration {
|
||||
adminGroupDns.stream().anyMatch(admin -> dn(admin).equals(dn(group.externalId()))));
|
||||
}
|
||||
}
|
||||
@Bean ClientRegistry clientRegistry(HydraProperties properties) { return new HydraClientRegistry(properties); }
|
||||
@Bean ClientRepository clientRepository(org.springframework.web.client.RestClient hydraAdminHttpClient) { return new HydraClientRepository(hydraAdminHttpClient); }
|
||||
@Bean @Order(2) SecurityFilterChain clientManagement(HttpSecurity http,MfaPolicy mfa,Access access) throws Exception {
|
||||
return http.securityMatcher("/api/iam/**").redirectToHttps(org.springframework.security.config.Customizer.withDefaults())
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().access((authentication,request) -> {
|
||||
|
||||
@@ -14,15 +14,23 @@ import top.ddupan.iam.login.authorization.infrastructure.hydra.*;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(prefix = "iam.hydra", name = "enabled", havingValue = "true")
|
||||
@EnableConfigurationProperties(HydraProperties.class)
|
||||
@EnableConfigurationProperties({HydraProperties.class, HydraHttpProperties.class})
|
||||
@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({HydraAdminClient.Request.class,
|
||||
HydraLogoutClient.Response.class, HydraLogoutClient.LogoutClient.class, HydraAdminClient.Client.class, HydraAdminClient.Context.class, HydraAdminClient.Redirect.class})
|
||||
HydraAdminClient.LogoutResponse.class, HydraAdminClient.LogoutClient.class, HydraAdminClient.Client.class, HydraAdminClient.Context.class, HydraAdminClient.Redirect.class})
|
||||
class HydraConfiguration {
|
||||
@Bean top.ddupan.iam.login.authorization.application.port.LogoutGateway logoutGateway(HydraProperties properties) {
|
||||
return new HydraLogoutClient(properties);
|
||||
@Bean
|
||||
org.springframework.web.client.RestClient hydraAdminHttpClient(
|
||||
org.springframework.web.client.RestClient.Builder builder, HydraProperties properties,
|
||||
HydraHttpProperties timeouts) {
|
||||
var http = java.net.http.HttpClient.newBuilder().connectTimeout(timeouts.connectTimeout())
|
||||
.followRedirects(java.net.http.HttpClient.Redirect.NEVER).build();
|
||||
var factory = new org.springframework.http.client.JdkClientHttpRequestFactory(http);
|
||||
factory.setReadTimeout(timeouts.readTimeout());
|
||||
return builder.baseUrl(properties.adminUrl().toString()).requestFactory(factory).build();
|
||||
}
|
||||
@Bean HydraGateway hydraGateway(HydraProperties properties, MfaPolicy requiredMfa) {
|
||||
return new HydraAdminClient(properties);
|
||||
@Bean HydraGateway hydraGateway(HydraProperties properties, MfaPolicy requiredMfa,
|
||||
org.springframework.web.client.RestClient hydraAdminHttpClient) {
|
||||
return new HydraAdminClient(properties, hydraAdminHttpClient);
|
||||
}
|
||||
@Bean AuthorizationPolicy authorizationPolicy(HydraProperties properties) {
|
||||
return new AuthorizationPolicy(properties.clients(), properties.subjects().stream().collect(Collectors.toMap(
|
||||
|
||||
@@ -44,7 +44,7 @@ class SecurityConfiguration {
|
||||
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
|
||||
SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords,
|
||||
ObjectProvider<WebAuthnBrowserConfigurer> webAuthn,
|
||||
ObjectProvider<top.ddupan.iam.login.authorization.application.port.LogoutGateway> logoutGateway) throws Exception {
|
||||
ObjectProvider<top.ddupan.iam.login.authorization.application.port.HydraGateway> logoutGateway) throws Exception {
|
||||
var passwordFactor = AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
|
||||
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10)))
|
||||
.build();
|
||||
@@ -71,7 +71,7 @@ class SecurityConfiguration {
|
||||
var gateway = logoutGateway.getIfAvailable();
|
||||
response.setStatus(303);
|
||||
response.setHeader("Location",gateway!=null && PathPatternRequestMatcher.withDefaults().matcher("/signin/logout").matches(request)
|
||||
? gateway.startUrl() : "/signin");
|
||||
? gateway.logoutUrl() : "/signin");
|
||||
}))
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/signin"),
|
||||
|
||||
@@ -3,11 +3,8 @@ package top.ddupan.iam.login.configuration;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
import javax.sql.DataSource;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
@@ -22,13 +19,8 @@ import top.ddupan.iam.login.authentication.infrastructure.webauthn.*;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(prefix = "iam.webauthn", name = "enabled", havingValue = "true")
|
||||
@EnableConfigurationProperties({WebAuthnProperties.class, DataSourceProperties.class})
|
||||
@EnableConfigurationProperties(WebAuthnProperties.class)
|
||||
class WebAuthnConfiguration {
|
||||
@Bean
|
||||
DataSource webAuthnDataSource(DataSourceProperties properties) {
|
||||
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
PublicKeyCredentialUserEntityRepository credentialUsers(JdbcOperations jdbc) {
|
||||
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
|
||||
@@ -62,8 +54,9 @@ class WebAuthnConfiguration {
|
||||
.userVerification(UserVerificationRequirement.REQUIRED).build()));
|
||||
delegate.setCustomizeRequestOptions(options -> options.timeout(Duration.ofMinutes(5))
|
||||
.userVerification(UserVerificationRequirement.REQUIRED));
|
||||
return new DirectoryRelyingPartyOperations(delegate, policy, users, credentials, jdbc,
|
||||
new TransactionTemplate(transactions));
|
||||
return new DirectoryRelyingPartyOperations(delegate, policy, users, credentials,
|
||||
new top.ddupan.iam.login.authentication.infrastructure.persistence.JdbcCredentialRegistration(
|
||||
jdbc, new TransactionTemplate(transactions)));
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
spring:
|
||||
autoconfigure:
|
||||
exclude: org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
|
||||
application:
|
||||
name: iam-login
|
||||
management:
|
||||
|
||||
@@ -21,7 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@SpringBootTest(properties = "spring.autoconfigure.exclude=org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration")
|
||||
@AutoConfigureMockMvc
|
||||
@AutoConfigureMetrics
|
||||
@AutoConfigureTracing
|
||||
|
||||
+52
-9
@@ -25,7 +25,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@SpringBootTest(properties = {"iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test",
|
||||
"iam.clients.admin-group-dns[0]=CN=gitea-admins,dc=example,dc=test", "iam.hydra.enabled=true", "iam.hydra.admin-url=http://127.0.0.1:14445", "iam.hydra.public-url=http://localhost:14444",
|
||||
"spring.datasource.hikari.maximum-pool-size=3", "iam.clients.admin-group-dns[0]=CN=gitea-admins,dc=example,dc=test", "iam.hydra.enabled=true", "iam.hydra.admin-url=http://127.0.0.1:14445", "iam.hydra.public-url=http://localhost:14444",
|
||||
"iam.hydra.clients[0]=gitea-fixture", "iam.hydra.subjects[0].authority=example.test",
|
||||
"iam.hydra.subjects[0].directory-id=00112233-4455-6677-8899-aabbccddeeff",
|
||||
"iam.hydra.subjects[0].subject=human:existing-subject",
|
||||
@@ -55,9 +55,52 @@ class WebAuthnIntegrationTests {
|
||||
|
||||
|
||||
@org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
top.ddupan.iam.login.clients.application.ClientRegistry clients;
|
||||
@org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
top.ddupan.iam.login.authorization.application.port.LogoutGateway logout;
|
||||
top.ddupan.iam.login.clients.domain.ClientRepository clients;
|
||||
|
||||
@Autowired org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||
@Autowired javax.sql.DataSource dataSource;
|
||||
|
||||
@Test void registrationLockSerializesAndRollsBackOnFailure() throws Exception {
|
||||
assertThat(((com.zaxxer.hikari.HikariDataSource)dataSource).getMaximumPoolSize()).isEqualTo(3);
|
||||
var registrations = new top.ddupan.iam.login.authentication.infrastructure.persistence.JdbcCredentialRegistration(
|
||||
jdbc, new org.springframework.transaction.support.TransactionTemplate(transactionManager));
|
||||
String id = java.util.UUID.randomUUID().toString();
|
||||
jdbc.update("insert into user_entities(id,name,display_name) values (?,?,?)", id,id,"before");
|
||||
var locked = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var started = new java.util.concurrent.CountDownLatch(1);
|
||||
try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
var first = executor.submit(() -> registrations.register(id, () -> {
|
||||
jdbc.update("update user_entities set display_name='committed' where id=?",id);
|
||||
locked.countDown();
|
||||
try {
|
||||
if (!release.await(5,java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("Test timed out");
|
||||
} catch (InterruptedException ex) { Thread.currentThread().interrupt(); throw new IllegalStateException(ex); }
|
||||
return true;
|
||||
}));
|
||||
try {
|
||||
assertThat(locked.await(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();
|
||||
var second = executor.submit(() -> {
|
||||
started.countDown();
|
||||
return registrations.register(id, () -> jdbc.queryForObject(
|
||||
"select display_name from user_entities where id=?",String.class,id));
|
||||
});
|
||||
assertThat(started.await(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();
|
||||
assertThatThrownBy(() -> second.get(200,java.util.concurrent.TimeUnit.MILLISECONDS))
|
||||
.isInstanceOf(java.util.concurrent.TimeoutException.class);
|
||||
release.countDown();
|
||||
assertThat(first.get(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(second.get(5,java.util.concurrent.TimeUnit.SECONDS)).isEqualTo("committed");
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
assertThatThrownBy(() -> registrations.register(id, () -> {
|
||||
jdbc.update("update user_entities set display_name='rolled back' where id=?",id);
|
||||
throw new IllegalStateException("Registration rejected");
|
||||
})).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(jdbc.queryForObject("select display_name from user_entities where id=?",String.class,id))
|
||||
.isEqualTo("committed");
|
||||
jdbc.update("delete from user_entities where id=?",id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientAdministrationRequiresMfaAdminGroupAndCsrf() throws Exception {
|
||||
@@ -85,11 +128,11 @@ class WebAuthnIntegrationTests {
|
||||
@Test
|
||||
void logoutRequiresCsrfBrowserBindingAndInvalidatesLocalSession() throws Exception {
|
||||
var session=login(); secondFactor(session);
|
||||
var data=new top.ddupan.iam.login.authorization.application.port.LogoutGateway.Request(
|
||||
var data=new top.ddupan.iam.login.authorization.application.port.HydraGateway.LogoutRequest(
|
||||
"logout-challenge","human:existing-subject","sid","");
|
||||
org.mockito.Mockito.when(logout.request("logout-challenge")).thenReturn(data);
|
||||
org.mockito.Mockito.when(logout.startUrl()).thenReturn("http://localhost:14444/oauth2/sessions/logout");
|
||||
org.mockito.Mockito.when(logout.accept("logout-challenge")).thenReturn("http://localhost:14444/oauth2/sessions/logout?logout_verifier=fixture");
|
||||
org.mockito.Mockito.when(hydra.logout("logout-challenge")).thenReturn(data);
|
||||
org.mockito.Mockito.when(hydra.logoutUrl()).thenReturn("http://localhost:14444/oauth2/sessions/logout");
|
||||
org.mockito.Mockito.when(hydra.acceptLogout("logout-challenge")).thenReturn("http://localhost:14444/oauth2/sessions/logout?logout_verifier=fixture");
|
||||
var html=mvc.perform(get("/oauth2/logout").session(session).with(https()).param("logout_challenge","logout-challenge"))
|
||||
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
|
||||
var match=java.util.regex.Pattern.compile("\"binding\":\"([^\"]+)\"").matcher(html);
|
||||
@@ -100,7 +143,7 @@ class WebAuthnIntegrationTests {
|
||||
mvc.perform(post("/oauth2/logout").session(session).with(https()).with(csrf()).param("binding",binding))
|
||||
.andExpect(status().isSeeOther());
|
||||
assertThat(session.isInvalid()).isTrue();
|
||||
org.mockito.Mockito.verify(logout).accept("logout-challenge");
|
||||
org.mockito.Mockito.verify(hydra).acceptLogout("logout-challenge");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/** Real LDAPS sockets and Spring Data LDAP; AD bind/subcode semantics are simulated. */
|
||||
@SpringBootTest(properties = {"iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test",
|
||||
@SpringBootTest(properties = {"spring.autoconfigure.exclude=org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", "iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test",
|
||||
"management.otlp.metrics.export.enabled=false", "spring.security.user.name=fixture-monitor", "spring.security.user.password=fixture-monitor-password"})
|
||||
@AutoConfigureMockMvc
|
||||
@org.springframework.boot.micrometer.metrics.test.autoconfigure.AutoConfigureMetrics
|
||||
|
||||
@@ -14,6 +14,13 @@ import top.ddupan.iam.login.authorization.infrastructure.hydra.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class HydraAdminClientTests {
|
||||
private org.springframework.web.client.RestClient http(HttpServer server) {
|
||||
var factory = new org.springframework.http.client.JdkClientHttpRequestFactory(
|
||||
java.net.http.HttpClient.newBuilder().followRedirects(java.net.http.HttpClient.Redirect.NEVER).build());
|
||||
return org.springframework.web.client.RestClient.builder()
|
||||
.baseUrl("http://127.0.0.1:" + server.getAddress().getPort()).requestFactory(factory).build();
|
||||
}
|
||||
|
||||
@Test void logoutAcceptsOriginRelativeRequestsButRejectsForeignOrigins() throws Exception {
|
||||
var payload = new AtomicReference<String>();
|
||||
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
@@ -25,14 +32,14 @@ class HydraAdminClientTests {
|
||||
});
|
||||
server.start();
|
||||
try {
|
||||
var client = new HydraLogoutClient(new HydraProperties(true,
|
||||
var client = new HydraAdminClient(new HydraProperties(true,
|
||||
URI.create("http://127.0.0.1:" + server.getAddress().getPort()), URI.create("http://localhost:14444"),
|
||||
Set.of(), List.of()));
|
||||
Set.of(), List.of()), http(server));
|
||||
payload.set("{\"challenge\":\"challenge\",\"subject\":\"subject\",\"sid\":\"session\",\"request_url\":\"/oauth2/sessions/logout\"}");
|
||||
assertThat(client.request("challenge").subject()).isEqualTo("subject");
|
||||
assertThat(client.logout("challenge").subject()).isEqualTo("subject");
|
||||
for (String url : List.of("//attacker.example/oauth2/sessions/logout", "https://attacker.example/oauth2/sessions/logout", "/admin/clients")) {
|
||||
payload.set("{\"challenge\":\"challenge\",\"request_url\":\"" + url + "\"}");
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> client.request("challenge"));
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> client.logout("challenge"));
|
||||
}
|
||||
} finally { server.stop(0); }
|
||||
}
|
||||
@@ -53,7 +60,7 @@ class HydraAdminClientTests {
|
||||
try {
|
||||
var client = new HydraAdminClient(new HydraProperties(true,
|
||||
URI.create("http://127.0.0.1:" + server.getAddress().getPort()), URI.create("http://localhost:14444"),
|
||||
Set.of("gitea"), List.of()));
|
||||
Set.of("gitea"), List.of()), http(server));
|
||||
assertThat(client.acceptLogin("challenge", "subject", "binding", Instant.now()))
|
||||
.startsWith("http://localhost:14444/oauth2/auth?");
|
||||
for (var target : List.of("https://attacker.example/oauth2/auth", "http://localhost:14444/admin/clients",
|
||||
|
||||
+6
-7
@@ -9,13 +9,12 @@ import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties;
|
||||
import top.ddupan.iam.login.clients.application.ClientRegistryException;
|
||||
import top.ddupan.iam.login.clients.domain.ClientRepositoryException;
|
||||
import top.ddupan.iam.login.clients.domain.OidcClient;
|
||||
import top.ddupan.iam.login.clients.infrastructure.HydraClientRegistry;
|
||||
import top.ddupan.iam.login.clients.infrastructure.HydraClientRepository;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class HydraRegistryIntegrationTests {
|
||||
class HydraRepositoryIntegrationTests {
|
||||
private static final String IMAGE="oryd/hydra:v26.2.0@sha256:ff67c7fb5f95074fa53374d41151713554960504b340cd3f95b09e65deaea2a9";
|
||||
private static final URI ADMIN=URI.create("http://127.0.0.1:14845");
|
||||
@Test void crudPersistsAcrossIssuerRestartAndDoesNotReturnSecretsOnRead() throws Exception {
|
||||
@@ -33,7 +32,7 @@ class HydraRegistryIntegrationTests {
|
||||
.withEnv("URLS_SELF_ISSUER","http://localhost:14844/").withEnv("LOG_LEVEL","error")
|
||||
.withEnv("SECRETS_SYSTEM","fixture-only-stable-system-secret-32-characters").withCommand("serve","all","--dev")) {
|
||||
hydra.start(); ready();
|
||||
var registry=new HydraClientRegistry(new HydraProperties(true,ADMIN,URI.create("http://localhost:14844"),Set.of(),List.of()));
|
||||
var registry=new HydraClientRepository(org.springframework.web.client.RestClient.builder().baseUrl(ADMIN.toString()).build());
|
||||
var client=new OidcClient("managed-fixture","Fixture",List.of("https://rp.example/callback"),Set.of("openid","groups"),
|
||||
List.of("https://rp.example/bye"),"https://rp.example/backchannel","",true);
|
||||
var created=registry.create(client);
|
||||
@@ -57,8 +56,8 @@ class HydraRegistryIntegrationTests {
|
||||
assertThat(response.body()).contains("invalid_grant").doesNotContain("invalid_client");
|
||||
}
|
||||
registry.delete(client.id());
|
||||
assertThatThrownBy(() -> registry.get(client.id())).isInstanceOfSatisfying(ClientRegistryException.class,
|
||||
ex -> assertThat(ex.kind()).isEqualTo(ClientRegistryException.Kind.NOT_FOUND));
|
||||
assertThatThrownBy(() -> registry.get(client.id())).isInstanceOfSatisfying(ClientRepositoryException.class,
|
||||
ex -> assertThat(ex.kind()).isEqualTo(ClientRepositoryException.Kind.NOT_FOUND));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user