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