收敛 IAM 仓储与 Hydra 网关并恢复数据源自动配置

This commit is contained in:
2026-09-29 13:21:34 +00:00
parent 62b6e9db40
commit fdf2e4958b
30 changed files with 267 additions and 195 deletions
@@ -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();
});
}
}
@@ -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);
});
@@ -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();
}
@@ -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");
}
}
@@ -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");
}
}
}
@@ -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");
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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
-2
View File
@@ -1,6 +1,4 @@
spring:
autoconfigure:
exclude: org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
application:
name: iam-login
management: