接入 Hydra 授权、客户端管理与统一注销

This commit is contained in:
2026-09-28 12:30:06 +00:00
parent 45994e3919
commit 62b6e9db40
37 changed files with 1700 additions and 23 deletions
@@ -11,6 +11,8 @@ import tools.jackson.databind.json.JsonMapper;
/** Shared HTML shell; the page context is data, never executable JavaScript. */
@Component
public class PageRenderer {
public static final String CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self'; style-src 'self'; "
+ "img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
private static final String SLOT = "__IAM_PAGE_CONTEXT__";
private final String shell;
private final JsonMapper json = JsonMapper.builder().build();
@@ -21,7 +21,10 @@ public class SignInController {
private final PageRenderer renderer;
private final ObjectProvider<MfaPolicy> policies;
public SignInController(PageRenderer renderer, ObjectProvider<MfaPolicy> policies) {
private final ObjectProvider<top.ddupan.iam.login.authorization.application.port.LogoutGateway> logout;
public SignInController(PageRenderer renderer, ObjectProvider<MfaPolicy> policies,
ObjectProvider<top.ddupan.iam.login.authorization.application.port.LogoutGateway> logout) {
this.logout=logout;
this.renderer = renderer;
this.policies = policies;
}
@@ -41,18 +44,28 @@ public class SignInController {
}
@GetMapping(value = "/signin/complete", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> complete(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
ResponseEntity<String> complete(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf,
jakarta.servlet.http.HttpServletRequest request) {
if (top.ddupan.iam.login.authorization.interfaces.web.HydraBrowserRequests.pending(request)) {
return ResponseEntity.status(303).header("Cache-Control", "no-store").location(java.net.URI.create("/oauth2/login")).build();
}
return identity(principal, csrf, "mfa-complete", "authenticate");
}
private ResponseEntity<String> identity(DirectoryPrincipal principal, CsrfToken csrf, String step, String passkey) {
var user = principal.user();
return renderer.render(Map.of("step", step, "passkey", passkey, "name", user.displayName(),
"error", "", "action", "/signin/restart", "csrf", csrf(csrf),
var page = renderer.render(Map.of("step", step, "passkey", passkey, "name", user.displayName(),
"error", "", "action", "/signin/restart", "logoutAction", logout.getIfAvailable()==null ? "" : "/signin/logout", "csrf", csrf(csrf),
"identity", Map.of("username", user.username(), "subjectId", user.id().value(),
"email", user.email(),
"groups", user.memberships().stream().map(GroupMembership::name).toList(),
"groupDns", user.memberships().stream().map(GroupMembership::externalId).toList())));
var gateway = logout.getIfAvailable();
if (gateway == null) return page;
var uri = java.net.URI.create(gateway.startUrl());
return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy",
PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'",
"form-action 'self' " + uri.getScheme() + "://" + uri.getRawAuthority())).body(page.getBody());
}
private static Map<String, String> csrf(CsrfToken token) {
@@ -0,0 +1,54 @@
package top.ddupan.iam.login.authorization.application;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import top.ddupan.iam.login.authentication.domain.User;
import top.ddupan.iam.login.authorization.domain.AuthorizationRequest;
/** Explicit first-party policy; never infers account continuity from email or username. */
public final class AuthorizationPolicy {
private static final Set<String> SCOPES = Set.of("openid", "profile", "email", "groups");
private final Set<String> clients;
private final Map<User.UserId, String> subjects;
public AuthorizationPolicy(Set<String> clients, Map<User.UserId, String> subjects) {
this.clients = clients == null ? Set.of() : Set.copyOf(clients);
this.subjects = Map.copyOf(subjects);
if (subjects.isEmpty() || subjects.values().stream().anyMatch(s -> s == null || s.isBlank())
|| subjects.values().stream().distinct().count() != subjects.size()) {
throw new IllegalArgumentException("Explicit unique subject bindings and clients are required");
}
}
public void validate(AuthorizationRequest request) {
if (!(request.loginEnabled() == null ? clients.contains(request.clientId()) : request.loginEnabled()) || !request.audience().isEmpty()
|| !request.scopes().contains("openid") || !SCOPES.containsAll(request.scopes())) {
throw new IllegalArgumentException("Authorization request is outside configured policy");
}
}
public String subject(User user) {
var subject = subjects.get(user.id());
if (subject == null) throw new IllegalArgumentException("No reviewed subject binding");
return subject;
}
public Map<String, Object> claims(User user, AuthorizationRequest request) {
validate(request);
var claims = new LinkedHashMap<String, Object>();
if (request.scopes().contains("profile")) {
claims.put("preferred_username", user.username());
claims.put("name", user.displayName());
}
if (request.scopes().contains("email") && !user.email().isBlank()) {
claims.put("email", user.email());
// AD mail is a directory attribute, not evidence of mailbox verification.
claims.put("email_verified", false);
}
if (request.scopes().contains("groups")) {
claims.put("groups", user.memberships().stream().map(User.GroupMembership::name).toList());
}
return Map.copyOf(claims);
}
}
@@ -0,0 +1,47 @@
package top.ddupan.iam.login.authorization.application;
import java.time.Instant;
import java.util.Set;
import top.ddupan.iam.login.authentication.domain.User;
import top.ddupan.iam.login.authorization.application.port.HydraGateway;
import top.ddupan.iam.login.authorization.domain.AuthorizationRequest;
/** Issuer authorization use case; independent of Servlet and Spring authentication/session state. */
public final class AuthorizeApplication {
public record AcceptedLogin(String subject, String redirect) { }
private final AuthorizationPolicy policy;
private final HydraGateway hydra;
public AuthorizeApplication(AuthorizationPolicy policy, HydraGateway hydra) {
this.policy = policy; this.hydra = hydra;
}
public AuthorizationRequest start(String challenge) {
var request = hydra.login(challenge);
policy.validate(request);
return request;
}
public String subject(User user) { return policy.subject(user); }
public AcceptedLogin login(AuthorizationRequest expected, String binding, User user, Instant authenticatedAt) {
var current = hydra.login(expected.challenge());
sameRequest(expected, current);
var subject = policy.subject(user);
if (current.skip() && !subject.equals(current.subject())
|| current.subject() != null && !current.subject().isBlank() && !current.subject().equals(subject)) {
throw new IllegalArgumentException("Issuer subject mismatch");
}
return new AcceptedLogin(subject, hydra.acceptLogin(current.challenge(), subject, binding, authenticatedAt));
}
public String consent(AuthorizationRequest expected, String binding, String subject, User user, String challenge) {
var current = hydra.consent(challenge);
sameRequest(expected, current);
if (!subject.equals(current.subject()) || !policy.subject(user).equals(current.subject())
|| !binding.equals(current.binding()) || !expected.challengeDigest().equals(current.loginChallengeDigest())) {
throw new IllegalArgumentException("Issuer/browser binding mismatch");
}
return hydra.acceptConsent(challenge, current.scopes(), policy.claims(user, current));
}
private void sameRequest(AuthorizationRequest expected, AuthorizationRequest current) {
policy.validate(current);
if (!expected.clientId().equals(current.clientId()) || !Set.copyOf(expected.scopes()).equals(Set.copyOf(current.scopes()))
|| !expected.requestUrl().equals(current.requestUrl())) throw new IllegalArgumentException("Issuer request changed");
}
}
@@ -0,0 +1,13 @@
package top.ddupan.iam.login.authorization.application.port;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import top.ddupan.iam.login.authorization.domain.AuthorizationRequest;
public interface HydraGateway {
AuthorizationRequest login(String challenge);
AuthorizationRequest consent(String challenge);
String acceptLogin(String challenge, String subject, String binding, Instant authenticatedAt);
String acceptConsent(String challenge, List<String> scopes, Map<String, Object> claims);
}
@@ -0,0 +1,8 @@
package top.ddupan.iam.login.authorization.application.port;
public interface LogoutGateway {
record Request(String challenge, String subject, String sid, String postLogoutRedirectUri) { }
Request request(String challenge);
String accept(String challenge);
String startUrl();
}
@@ -0,0 +1,24 @@
package top.ddupan.iam.login.authorization.domain;
import java.util.List;
/** Verified metadata read from the issuer's private administrative API. */
public record AuthorizationRequest(String challenge, String clientId, List<String> scopes,
List<String> audience, String subject, String loginChallengeDigest, String binding, String requestUrl,
boolean skip, Boolean loginEnabled) {
public AuthorizationRequest(String challenge, String clientId, List<String> scopes, List<String> audience,
String subject, String loginChallengeDigest, String binding, String requestUrl, boolean skip) {
this(challenge,clientId,scopes,audience,subject,loginChallengeDigest,binding,requestUrl,skip,null);
}
public AuthorizationRequest {
scopes = List.copyOf(scopes);
audience = List.copyOf(audience);
}
public String challengeDigest() { return digest(challenge); }
public static String digest(String challenge) {
try {
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(challenge.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (java.security.NoSuchAlgorithmException ex) { throw new IllegalStateException("SHA-256 unavailable", ex); }
}
}
@@ -0,0 +1,122 @@
package top.ddupan.iam.login.authorization.infrastructure.hydra;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import top.ddupan.iam.login.authorization.application.port.HydraGateway;
import top.ddupan.iam.login.authorization.domain.AuthorizationRequest;
/** Private, fixed-origin admin client. Never follows redirects or forwards upstream errors/challenges. */
public final class HydraAdminClient implements HydraGateway {
private final RestClient client;
private final URI publicUrl;
public HydraAdminClient(HydraProperties properties) {
client = restClient(properties).mutate()
.defaultStatusHandler(status -> !status.is2xxSuccessful(), (request, response) -> {
throw new IllegalArgumentException("Hydra returned a non-success status");
}).build();
publicUrl = properties.publicUrl();
}
public static RestClient restClient(HydraProperties properties) {
var http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NEVER).build();
var factory = new JdkClientHttpRequestFactory(http);
factory.setReadTimeout(Duration.ofSeconds(5));
return RestClient.builder().baseUrl(properties.adminUrl().toString()).requestFactory(factory).build();
}
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
public record Client(String client_id, Map<String,Object> metadata) { }
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
public record Context(String browser_binding, String login_challenge_digest) { }
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
public record Request(String challenge, Client client, List<String> requested_scope,
List<String> requested_access_token_audience, String subject, String login_challenge,
Context context, String request_url, boolean skip) { }
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
public record Redirect(String redirect_to) { }
@Override public AuthorizationRequest login(String challenge) { return request("login", challenge); }
@Override public AuthorizationRequest consent(String challenge) { return request("consent", challenge); }
private AuthorizationRequest request(String kind, String challenge) {
validateChallenge(challenge);
try {
var result = client.get().uri(builder -> builder.path("/admin/oauth2/auth/requests/" + kind)
.queryParam(kind + "_challenge", "{challenge}").build(challenge)).retrieve().body(Request.class);
if (result == null || result.client() == null || !challenge.equals(result.challenge())) {
throw new IllegalArgumentException("Invalid issuer response");
}
validateAuthorizationUrl(result.request_url());
var registered = client.get().uri("/admin/clients/{id}", result.client().client_id()).retrieve().body(Client.class);
if (registered == null || !result.client().client_id().equals(registered.client_id()))
throw new IllegalArgumentException("Client no longer registered");
var eligibility = registered.metadata() == null ? null : registered.metadata().get("iam_login_enabled");
Boolean enabled = eligibility == null ? null : Boolean.TRUE.equals(eligibility);
return new AuthorizationRequest(challenge, result.client().client_id(),
result.requested_scope() == null ? List.of() : result.requested_scope(),
result.requested_access_token_audience() == null ? List.of() : result.requested_access_token_audience(),
result.subject(), result.context() == null ? null : result.context().login_challenge_digest(), result.context() == null ? null : result.context().browser_binding(),
result.request_url(), result.skip(), enabled);
} catch (RuntimeException ex) {
throw new IllegalArgumentException("Hydra request unavailable");
}
}
@Override public String acceptLogin(String challenge, String subject, String binding, Instant authenticatedAt) {
return accept("login", challenge, Map.of("subject", subject, "remember", true,
"authenticated_at", authenticatedAt.toString(), "amr", List.of("pwd", "mfa"),
"context", Map.of("browser_binding", binding, "login_challenge_digest", AuthorizationRequest.digest(challenge))));
}
@Override public String acceptConsent(String challenge, List<String> scopes, Map<String, Object> claims) {
return accept("consent", challenge, Map.of("grant_scope", scopes, "grant_access_token_audience", List.of(),
"remember", false, "session", Map.of("id_token", claims)));
}
private String accept(String kind, String challenge, Object payload) {
validateChallenge(challenge);
try {
var result = client.put().uri(builder -> builder.path("/admin/oauth2/auth/requests/" + kind + "/accept")
.queryParam(kind + "_challenge", "{challenge}").build(challenge)).body(payload)
.retrieve().body(Redirect.class);
if (result == null) throw new IllegalArgumentException("Missing redirect");
var target = URI.create(result.redirect_to());
if (!publicUrl.getScheme().equals(target.getScheme()) || !publicUrl.getRawAuthority().equals(target.getRawAuthority())
|| target.getUserInfo() != null || target.getFragment() != null || !"/oauth2/auth".equals(target.getRawPath())) {
throw new IllegalArgumentException("Invalid redirect");
}
return target.toString();
} catch (RuntimeException ex) {
throw new IllegalArgumentException("Hydra acceptance unavailable");
}
}
private void validateAuthorizationUrl(String value) {
var uri = URI.create(value);
if (!publicUrl.getScheme().equals(uri.getScheme()) || !publicUrl.getRawAuthority().equals(uri.getRawAuthority())
|| uri.getUserInfo() != null || uri.getFragment() != null || !"/oauth2/auth".equals(uri.getRawPath())) {
throw new IllegalArgumentException("Unexpected authorization origin");
}
var parameters = new java.util.HashMap<String, String>();
for (var pair : uri.getRawQuery().split("&")) {
var parts = pair.split("=", 2);
var key = java.net.URLDecoder.decode(parts[0], java.nio.charset.StandardCharsets.UTF_8);
var parameter = java.net.URLDecoder.decode(parts.length == 2 ? parts[1] : "", java.nio.charset.StandardCharsets.UTF_8);
if (parameters.putIfAbsent(key, parameter) != null) throw new IllegalArgumentException("Duplicate OAuth parameter");
}
if (!"code".equals(parameters.get("response_type"))
|| parameters.containsKey("prompt") && !Set.of("login", "consent", "select_account").contains(parameters.get("prompt"))) {
throw new IllegalArgumentException("Only interactive authorization code is enabled");
}
if (parameters.containsKey("max_age") && Long.parseLong(parameters.get("max_age")) < 0) {
throw new IllegalArgumentException("Invalid max_age");
}
}
private static void validateChallenge(String challenge) {
if (challenge == null || challenge.isBlank() || challenge.length() > 8192) {
throw new IllegalArgumentException("Invalid challenge");
}
}
}
@@ -0,0 +1,67 @@
package top.ddupan.iam.login.authorization.infrastructure.hydra;
import java.net.URI;
import java.util.Objects;
import org.springframework.web.client.RestClient;
import top.ddupan.iam.login.authorization.application.port.LogoutGateway;
public final class HydraLogoutClient implements LogoutGateway {
private final RestClient http;
private final URI origin;
public HydraLogoutClient(HydraProperties properties) {
origin = properties.publicUrl();
http = HydraAdminClient.restClient(properties).mutate()
.defaultStatusHandler(status -> !status.is2xxSuccessful(), (req,res) -> { throw new IllegalArgumentException("Logout unavailable"); }).build();
}
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true)
public record Response(String challenge,String subject,String sid,String request_url, LogoutClient client) { }
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true)
public record LogoutClient(java.util.List<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");
}
}
@@ -0,0 +1,26 @@
package top.ddupan.iam.login.authorization.infrastructure.hydra;
import java.net.URI;
import java.util.List;
import java.util.Set;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("iam.hydra")
public record HydraProperties(boolean enabled, URI adminUrl, URI publicUrl, Set<String> clients,
List<SubjectBinding> subjects) {
public record SubjectBinding(String authority, String directoryId, String subject) { }
public HydraProperties {
if (enabled) {
validateUrl(adminUrl, true); validateUrl(publicUrl, false);
if (subjects == null) throw new IllegalArgumentException("Hydra policy is required");
}
}
private static void validateUrl(URI uri, boolean administrative) {
if (uri == null || uri.getHost() == null || uri.getUserInfo() != null || uri.getQuery() != null
|| uri.getFragment() != null || !(uri.getPath().isEmpty() || uri.getPath().equals("/"))
|| !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())
&& (administrative || uri.getHost().equals("localhost") || uri.getHost().equals("127.0.0.1")))) {
throw new IllegalArgumentException("Invalid Hydra origin (public HTTP is restricted to loopback)");
}
}
}
@@ -0,0 +1,62 @@
package top.ddupan.iam.login.authorization.interfaces.web;
import java.time.Instant;
import java.time.Duration;
import java.util.UUID;
import jakarta.servlet.http.HttpServletRequest;
import top.ddupan.iam.login.authorization.domain.AuthorizationRequest;
/** Short-lived issuer request binding only. Authentication state remains in Spring Security. */
public final class HydraBrowserRequests {
private static final String PENDING = HydraBrowserRequests.class.getName() + ".pending";
private static final String ACCEPTED = HydraBrowserRequests.class.getName() + ".accepted";
public record Intent(AuthorizationRequest request, String binding, Instant expiresAt) { }
public record Accepted(Intent intent, String directoryName, String subject) { }
private HydraBrowserRequests() { }
public static void start(HttpServletRequest request, AuthorizationRequest authorization) {
var session = request.getSession();
synchronized (session) {
session.removeAttribute(ACCEPTED);
session.setAttribute(PENDING, new Intent(authorization, UUID.randomUUID().toString(),
Instant.now().plus(Duration.ofMinutes(10))));
}
}
public static boolean pending(HttpServletRequest request) {
var session = request.getSession(false);
return session != null && session.getAttribute(PENDING) instanceof Intent;
}
public static Intent intent(HttpServletRequest request) {
var session = request.getSession(false);
var intent = session == null ? null : (Intent) session.getAttribute(PENDING);
if (intent == null || !Instant.now().isBefore(intent.expiresAt())) {
throw new IllegalArgumentException("No pending issuer request");
}
return intent;
}
public static Intent consume(HttpServletRequest request, String binding) {
var session = request.getSession(false);
if (session == null) throw new IllegalArgumentException("No browser session");
synchronized (session) {
var intent = intent(request);
if (!intent.binding().equals(binding)) throw new IllegalArgumentException("Browser binding mismatch");
session.removeAttribute(PENDING);
return intent;
}
}
public static void accepted(HttpServletRequest request, Accepted accepted) {
request.getSession().setAttribute(ACCEPTED, accepted);
}
public static Accepted consumeAccepted(HttpServletRequest request) {
var session = request.getSession(false);
if (session == null) throw new IllegalArgumentException("No browser session");
synchronized (session) {
var accepted = (Accepted) session.getAttribute(ACCEPTED);
session.removeAttribute(ACCEPTED);
if (accepted == null || !Instant.now().isBefore(accepted.intent().expiresAt())) {
throw new IllegalArgumentException("No accepted issuer request");
}
return accepted;
}
}
}
@@ -0,0 +1,94 @@
package top.ddupan.iam.login.authorization.interfaces.web;
import java.net.URI;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.authority.FactorGrantedAuthority;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
import top.ddupan.iam.login.authentication.interfaces.web.PageRenderer;
import top.ddupan.iam.login.authorization.application.AuthorizeApplication;
@RestController
@ConditionalOnProperty(prefix = "iam.hydra", name = "enabled", havingValue = "true")
public class HydraController {
private final AuthorizeApplication policy;
private final PageRenderer renderer;
public HydraController(AuthorizeApplication policy, PageRenderer renderer) {
this.policy = policy; this.renderer = renderer;
}
@GetMapping("/oauth2/start")
ResponseEntity<Void> start(@RequestParam("login_challenge") String challenge,
HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
var login = policy.start(challenge);
var query = org.springframework.web.util.UriComponentsBuilder.fromUriString(login.requestUrl()).build().getQueryParams();
if (query.containsKey("prompt") || query.containsKey("max_age")) {
new SecurityContextLogoutHandler().logout(request, response, authentication);
}
HydraBrowserRequests.start(request, login);
return redirect("/oauth2/login");
}
@GetMapping("/oauth2/login")
ResponseEntity<String> login(HttpServletRequest request, @AuthenticationPrincipal DirectoryPrincipal principal,
CsrfToken csrf) {
var intent = HydraBrowserRequests.intent(request);
policy.subject(principal.user());
var page = renderer.render(Map.of("step", "authorize", "name", principal.user().displayName(), "error", "",
"action", "/oauth2/login", "csrf", Map.of("name", csrf.getParameterName(), "value", csrf.getToken(),
"headerName", csrf.getHeaderName()), "binding", intent.binding(),
"client", intent.request().clientId(), "scopes", intent.request().scopes()));
var authorization = URI.create(intent.request().requestUrl());
var rawCallback = org.springframework.web.util.UriComponentsBuilder.fromUri(authorization).build()
.getQueryParams().getFirst("redirect_uri");
if (rawCallback == null) throw new IllegalArgumentException("Explicit callback is required");
var callback = URI.create(java.net.URLDecoder.decode(rawCallback, java.nio.charset.StandardCharsets.UTF_8));
var targets = formOrigin(authorization) + " " + formOrigin(callback);
return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy",
PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'", "form-action 'self' " + targets))
.body(page.getBody());
}
@PostMapping("/oauth2/login")
ResponseEntity<Void> accept(@RequestParam String binding, HttpServletRequest request,
@AuthenticationPrincipal DirectoryPrincipal principal, Authentication authentication) {
var intent = HydraBrowserRequests.consume(request, binding);
var authenticatedAt = authentication.getAuthorities().stream()
.filter(FactorGrantedAuthority.class::isInstance).map(FactorGrantedAuthority.class::cast)
.map(FactorGrantedAuthority::getIssuedAt).max(Instant::compareTo).orElseThrow();
var accepted = policy.login(intent.request(), intent.binding(), principal.user(), authenticatedAt);
HydraBrowserRequests.accepted(request, new HydraBrowserRequests.Accepted(intent, principal.getName(), accepted.subject()));
return redirect(accepted.redirect());
}
@GetMapping("/oauth2/consent")
ResponseEntity<Void> consent(@RequestParam("consent_challenge") String challenge, HttpServletRequest request,
@AuthenticationPrincipal DirectoryPrincipal principal) {
var accepted = HydraBrowserRequests.consumeAccepted(request);
if (!principal.getName().equals(accepted.directoryName())) throw new IllegalArgumentException("Browser identity changed");
return redirect(policy.consent(accepted.intent().request(), accepted.intent().binding(), accepted.subject(),
principal.user(), challenge));
}
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<String> invalid() {
return ResponseEntity.badRequest().header("Cache-Control", "no-store")
.body("授权请求无效或已过期,请从应用重新开始。");
}
private static String formOrigin(URI uri) {
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getFragment() != null
|| !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())
&& Set.of("localhost", "127.0.0.1").contains(uri.getHost()))) {
throw new IllegalArgumentException("Unexpected form destination");
}
return uri.getScheme() + "://" + uri.getRawAuthority();
}
private static ResponseEntity<Void> redirect(String target) {
return ResponseEntity.status(303).header("Cache-Control", "no-store").location(URI.create(target)).build();
}
}
@@ -0,0 +1,69 @@
package top.ddupan.iam.login.authorization.interfaces.web;
import java.net.URI;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;
import top.ddupan.iam.login.authentication.interfaces.web.PageRenderer;
import top.ddupan.iam.login.authorization.application.port.LogoutGateway;
import top.ddupan.iam.login.authorization.application.AuthorizationPolicy;
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
@RestController
@ConditionalOnProperty(prefix="iam.hydra",name="enabled",havingValue="true")
public class HydraLogoutController {
private static final String KEY = HydraLogoutController.class.getName();
private record Pending(LogoutGateway.Request request,String binding,Instant expires) { }
private final LogoutGateway hydra;
private final PageRenderer renderer;
private final AuthorizationPolicy policy;
public HydraLogoutController(LogoutGateway hydra,PageRenderer renderer,AuthorizationPolicy policy) {
this.hydra=hydra; this.renderer=renderer; this.policy=policy;
}
@GetMapping("/oauth2/logout") ResponseEntity<String> page(@RequestParam("logout_challenge") String challenge,
HttpServletRequest request,Authentication auth,CsrfToken csrf) {
var logout = hydra.request(challenge);
if (auth != null && auth.getPrincipal() instanceof DirectoryPrincipal principal && logout.subject()!=null
&& !logout.subject().isBlank() && !policy.subject(principal.user()).equals(logout.subject()))
throw new IllegalArgumentException("Different logout subject");
var pending = new Pending(logout,UUID.randomUUID().toString(),Instant.now().plusSeconds(300));
request.getSession().setAttribute(KEY,pending);
var page = renderer.render(Map.of("step","logout","name","","error","","action","/oauth2/logout",
"binding",pending.binding(),"csrf",Map.of("name",csrf.getParameterName(),"value",csrf.getToken(),"headerName",csrf.getHeaderName())));
String targets=origin(hydra.startUrl());
if (!logout.postLogoutRedirectUri().isEmpty()) targets += " " + origin(logout.postLogoutRedirectUri());
return ResponseEntity.ok().headers(page.getHeaders()).header("Content-Security-Policy",
PageRenderer.CONTENT_SECURITY_POLICY.replace("form-action 'self'", "form-action 'self' " + targets))
.body(page.getBody());
}
@PostMapping("/oauth2/logout") ResponseEntity<Void> logout(@RequestParam String binding,HttpServletRequest request,
HttpServletResponse response,Authentication authentication) {
var session=request.getSession(false);
if (session==null) throw new IllegalArgumentException("No logout session");
Pending pending;
synchronized(session) {
pending=(Pending)session.getAttribute(KEY);
if (pending==null || !pending.binding().equals(binding) || !Instant.now().isBefore(pending.expires()))
throw new IllegalArgumentException("Invalid logout binding");
session.removeAttribute(KEY);
}
var current=hydra.request(pending.request().challenge());
if (!Objects.equals(current,pending.request())) throw new IllegalArgumentException("Logout request changed");
var target=hydra.accept(current.challenge());
new SecurityContextLogoutHandler().logout(request,response,authentication);
return ResponseEntity.status(303).header("Cache-Control","no-store").location(URI.create(target)).build();
}
private static String origin(String url) { var uri=URI.create(url); return uri.getScheme()+"://"+uri.getRawAuthority(); }
@ExceptionHandler(IllegalArgumentException.class) ResponseEntity<String> invalid() {
return ResponseEntity.badRequest().header("Cache-Control","no-store").body("注销请求无效或已过期,请重新发起。");
}
}
@@ -0,0 +1,16 @@
package top.ddupan.iam.login.clients.application;
import java.util.List;
import top.ddupan.iam.login.clients.domain.OidcClient;
/** Hydra is the sole persistence authority. Secrets exist only in create/rotate responses. */
public interface ClientRegistry {
record Created(OidcClient client, String secret) {
@Override public String toString() { return "Created[client=" + client.id() + ", secret=REDACTED]"; }
}
List<OidcClient> list(int page, int size);
OidcClient get(String id);
Created create(OidcClient client);
OidcClient update(OidcClient client);
void delete(String id);
}
@@ -0,0 +1,8 @@
package top.ddupan.iam.login.clients.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; }
}
@@ -0,0 +1,38 @@
package top.ddupan.iam.login.clients.domain;
import java.net.URI;
import java.util.List;
import java.util.Set;
/** First-party confidential authorization-code client. No caller-controlled grants or metadata. */
public record OidcClient(String id, String name, List<String> redirectUris, Set<String> scopes,
List<String> postLogoutRedirectUris, String backchannelLogoutUri, String frontchannelLogoutUri,
boolean loginEnabled) {
public OidcClient {
requireId(id);
if (name == null || name.isBlank() || name.length() > 200) throw new IllegalArgumentException("Invalid name");
if (redirectUris == null || redirectUris.isEmpty() || redirectUris.size() > 20) throw new IllegalArgumentException("Invalid callbacks");
redirectUris = List.copyOf(redirectUris);
if (scopes == null || !scopes.contains("openid") || !Set.of("openid", "profile", "email", "groups").containsAll(scopes))
throw new IllegalArgumentException("Invalid scopes");
scopes = Set.copyOf(scopes);
postLogoutRedirectUris = postLogoutRedirectUris == null ? List.of() : List.copyOf(postLogoutRedirectUris);
if (postLogoutRedirectUris.size() > 20) throw new IllegalArgumentException("Too many logout redirects");
redirectUris.forEach(OidcClient::requireUri); postLogoutRedirectUris.forEach(OidcClient::requireUri);
backchannelLogoutUri = backchannelLogoutUri == null ? "" : backchannelLogoutUri;
frontchannelLogoutUri = frontchannelLogoutUri == null ? "" : frontchannelLogoutUri;
if (!backchannelLogoutUri.isEmpty()) requireUri(backchannelLogoutUri);
if (!frontchannelLogoutUri.isEmpty()) requireUri(frontchannelLogoutUri);
}
public static void requireId(String id) {
if (id == null || !id.matches("[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}")) throw new IllegalArgumentException("Invalid client ID");
}
private static void requireUri(String value) {
if (value == null || value.length() > 2048 || value.contains("*")) throw new IllegalArgumentException("Invalid URI");
var uri = URI.create(value);
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getFragment() != null
|| !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())
&& Set.of("localhost", "127.0.0.1", "[::1]").contains(uri.getHost())))
throw new IllegalArgumentException("HTTPS or loopback callback required");
}
}
@@ -0,0 +1,93 @@
package top.ddupan.iam.login.clients.infrastructure;
import java.util.*;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.web.client.RestClient;
import top.ddupan.iam.login.clients.application.*;
import top.ddupan.iam.login.clients.domain.OidcClient;
import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties;
import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraAdminClient;
public final class HydraClientRegistry implements ClientRegistry {
public static final String ENABLED = "iam_login_enabled";
private static final ParameterizedTypeReference<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()
.defaultStatusHandler(status -> !status.is2xxSuccessful(), (request, response) -> {
throw new ClientRegistryException(switch (response.getStatusCode().value()) {
case 404 -> ClientRegistryException.Kind.NOT_FOUND;
case 400, 409 -> ClientRegistryException.Kind.CONFLICT;
default -> ClientRegistryException.Kind.UNAVAILABLE;
});
}).build();
}
private <T> 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); }
}
@Override public List<OidcClient> list(int page, int size) {
if (page < 0 || size < 1 || size > 100) throw new IllegalArgumentException("Invalid pagination");
return call(() -> Objects.requireNonNull(http.get().uri(b -> b.path("/admin/clients")
.queryParam("page", page).queryParam("page_size", size).build()).retrieve().body(ARRAY))
.stream().filter(this::supported).map(this::view).toList());
}
private Map<String,Object> read(String id) {
OidcClient.requireId(id);
return Objects.requireNonNull(http.get().uri("/admin/clients/{id}", id).retrieve().body(OBJECT));
}
@Override public OidcClient get(String id) { return call(() -> view(read(id))); }
@Override public Created create(OidcClient client) {
return call(() -> {
var result = Objects.requireNonNull(http.post().uri("/admin/clients").body(payload(client))
.retrieve().body(OBJECT));
return new Created(view(result), Objects.toString(result.get("client_secret"), ""));
});
}
@Override public OidcClient update(OidcClient client) {
return call(() -> {
var previous = read(client.id());
if (!supported(previous)) throw new ClientRegistryException(ClientRegistryException.Kind.CONFLICT);
// Preserve issuer-owned fields and metadata, but never send back a stored secret/hash.
var update = new LinkedHashMap<>(previous); update.remove("client_secret");
var metadata = new LinkedHashMap<String,Object>();
if (previous.get("metadata") instanceof Map<?,?> map) map.forEach((k,v) -> metadata.put(k.toString(),v));
metadata.put(ENABLED, client.loginEnabled());
update.putAll(payload(client)); update.put("metadata", metadata);
return view(Objects.requireNonNull(http.put().uri("/admin/clients/{id}", client.id())
.body(update).retrieve().body(OBJECT)));
});
}
@Override public void delete(String id) {
OidcClient.requireId(id);
call(() -> { http.delete().uri("/admin/clients/{id}",id).retrieve().toBodilessEntity(); return null; });
}
private boolean supported(Map<String,Object> data) {
return List.of("authorization_code").equals(data.get("grant_types"))
&& "client_secret_basic".equals(data.get("token_endpoint_auth_method"));
}
private Map<String,Object> payload(OidcClient c) {
var map = new LinkedHashMap<String,Object>();
map.put("client_id",c.id()); map.put("client_name",c.name()); map.put("redirect_uris",c.redirectUris());
map.put("scope",String.join(" ",new TreeSet<>(c.scopes())));
map.put("grant_types",List.of("authorization_code")); map.put("response_types",List.of("code"));
map.put("token_endpoint_auth_method","client_secret_basic"); map.put("subject_type","public");
map.put("post_logout_redirect_uris",c.postLogoutRedirectUris());
map.put("backchannel_logout_uri",c.backchannelLogoutUri());
map.put("backchannel_logout_session_required",true);
map.put("frontchannel_logout_uri",c.frontchannelLogoutUri());
map.put("frontchannel_logout_session_required",true);
map.put("metadata",Map.of(ENABLED,c.loginEnabled())); return map;
}
@SuppressWarnings("unchecked") private OidcClient view(Map<String,Object> m) {
if (!supported(m)) throw new ClientRegistryException(ClientRegistryException.Kind.CONFLICT);
String id = (String)m.get("client_id");
String name = Objects.toString(m.get("client_name"),"");
return new OidcClient(id, name.isBlank() ? id : name,
(List<String>)m.get("redirect_uris"),new HashSet<>(Arrays.asList(Objects.toString(m.get("scope"),"").split(" +"))),
(List<String>)m.get("post_logout_redirect_uris"),(String)m.get("backchannel_logout_uri"),
(String)m.get("frontchannel_logout_uri"),m.get("metadata") instanceof Map<?,?> meta && Boolean.TRUE.equals(meta.get(ENABLED)));
}
}
@@ -0,0 +1,45 @@
package top.ddupan.iam.login.clients.interfaces.web;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;
import top.ddupan.iam.login.clients.application.*;
import top.ddupan.iam.login.clients.domain.OidcClient;
@RestController
@ConditionalOnProperty(prefix="iam.hydra", name="enabled", havingValue="true")
public class ClientsController {
private final ClientRegistry registry;
private static final org.slf4j.Logger AUDIT = org.slf4j.LoggerFactory.getLogger("iam.audit.clients");
public ClientsController(ClientRegistry registry) { this.registry = registry; }
@GetMapping("/api/iam/session") Object session(CsrfToken csrf) {
return Map.of("csrf",Map.of("headerName",csrf.getHeaderName(),"token",csrf.getToken()));
}
@GetMapping("/api/iam/clients") Object list(@RequestParam(defaultValue="0") int page,
@RequestParam(defaultValue="20") int size) { return registry.list(page,size); }
@GetMapping("/api/iam/clients/{id}") OidcClient get(@PathVariable String id) { return registry.get(id); }
@PostMapping("/api/iam/clients") ResponseEntity<ClientRegistry.Created> create(@RequestBody OidcClient input, Authentication auth) {
var created = registry.create(input); audit("create",input.id(),auth);
return ResponseEntity.status(201).header("Cache-Control","no-store").body(created);
}
@PutMapping("/api/iam/clients/{id}") OidcClient update(@PathVariable String id, @RequestBody OidcClient input, Authentication auth) {
if (!id.equals(input.id())) throw new IllegalArgumentException("Client ID mismatch");
var updated = registry.update(input); audit("update",id,auth); return updated;
}
@DeleteMapping("/api/iam/clients/{id}") ResponseEntity<Void> delete(@PathVariable String id, Authentication auth) {
registry.delete(id); audit("delete",id,auth); return ResponseEntity.noContent().build();
}
private void audit(String action,String id,Authentication auth) {
AUDIT.info("client action={} client={} actor={}",action,id,auth.getName());
}
@ExceptionHandler(IllegalArgumentException.class) ResponseEntity<?> invalid() {
return ResponseEntity.badRequest().body(Map.of("error","invalid_client_request"));
}
@ExceptionHandler(ClientRegistryException.class) ResponseEntity<?> unavailable(ClientRegistryException ex) {
int status = switch (ex.kind()) { case NOT_FOUND -> 404; case CONFLICT -> 409; case UNAVAILABLE -> 502; };
return ResponseEntity.status(status).body(Map.of("error",ex.kind().name().toLowerCase(java.util.Locale.ROOT)));
}
}
@@ -0,0 +1,51 @@
package top.ddupan.iam.login.configuration;
import java.util.Set;
import javax.naming.ldap.LdapName;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
import top.ddupan.iam.login.authentication.infrastructure.webauthn.MfaPolicy;
import top.ddupan.iam.login.authorization.infrastructure.hydra.HydraProperties;
import top.ddupan.iam.login.clients.application.ClientRegistry;
import top.ddupan.iam.login.clients.infrastructure.HydraClientRegistry;
import top.ddupan.iam.login.clients.domain.OidcClient;
@Configuration(proxyBeanMethods=false)
@ConditionalOnProperty(prefix="iam.hydra",name="enabled",havingValue="true")
@EnableConfigurationProperties(ClientManagementConfiguration.Access.class)
@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({OidcClient.class,ClientRegistry.Created.class})
class ClientManagementConfiguration {
@ConfigurationProperties("iam.clients")
record Access(Set<String> adminGroupDns) {
Access { adminGroupDns = adminGroupDns == null ? Set.of() : Set.copyOf(adminGroupDns); adminGroupDns.forEach(Access::dn); }
static LdapName dn(String value) {
try { return new LdapName(value); } catch (javax.naming.InvalidNameException ex) { throw new IllegalArgumentException("Invalid administrator group DN"); }
}
boolean allowed(DirectoryPrincipal principal) {
return principal.user().memberships().stream().anyMatch(group ->
adminGroupDns.stream().anyMatch(admin -> dn(admin).equals(dn(group.externalId()))));
}
}
@Bean ClientRegistry clientRegistry(HydraProperties properties) { return new HydraClientRegistry(properties); }
@Bean @Order(2) SecurityFilterChain clientManagement(HttpSecurity http,MfaPolicy mfa,Access access) throws Exception {
return http.securityMatcher("/api/iam/**").redirectToHttps(org.springframework.security.config.Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth.anyRequest().access((authentication,request) -> {
var current = authentication.get();
var factors = mfa.complete.authorize(authentication,request);
return new AuthorizationDecision(current != null && current.getPrincipal() instanceof DirectoryPrincipal principal
&& factors != null && factors.isGranted() && access.allowed(principal));
}))
.exceptionHandling(ex -> ex.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.requestCache(cache -> cache.disable()).logout(logout -> logout.disable()).build();
}
}
@@ -0,0 +1,34 @@
package top.ddupan.iam.login.configuration;
import java.util.stream.Collectors;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.ddupan.iam.login.authentication.domain.User;
import top.ddupan.iam.login.authentication.infrastructure.webauthn.MfaPolicy;
import top.ddupan.iam.login.authorization.application.AuthorizeApplication;
import top.ddupan.iam.login.authorization.application.AuthorizationPolicy;
import top.ddupan.iam.login.authorization.application.port.HydraGateway;
import top.ddupan.iam.login.authorization.infrastructure.hydra.*;
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "iam.hydra", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(HydraProperties.class)
@org.springframework.aot.hint.annotation.RegisterReflectionForBinding({HydraAdminClient.Request.class,
HydraLogoutClient.Response.class, HydraLogoutClient.LogoutClient.class, HydraAdminClient.Client.class, HydraAdminClient.Context.class, HydraAdminClient.Redirect.class})
class HydraConfiguration {
@Bean top.ddupan.iam.login.authorization.application.port.LogoutGateway logoutGateway(HydraProperties properties) {
return new HydraLogoutClient(properties);
}
@Bean HydraGateway hydraGateway(HydraProperties properties, MfaPolicy requiredMfa) {
return new HydraAdminClient(properties);
}
@Bean AuthorizationPolicy authorizationPolicy(HydraProperties properties) {
return new AuthorizationPolicy(properties.clients(), properties.subjects().stream().collect(Collectors.toMap(
binding -> new User.UserId(binding.authority(), binding.directoryId()), HydraProperties.SubjectBinding::subject)));
}
@Bean AuthorizeApplication authorizeApplication(AuthorizationPolicy policy, HydraGateway hydra) {
return new AuthorizeApplication(policy, hydra);
}
}
@@ -40,47 +40,56 @@ class SecurityConfiguration {
}
@Bean
@Order(2)
@Order(3)
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords,
ObjectProvider<WebAuthnBrowserConfigurer> webAuthn) throws Exception {
ObjectProvider<WebAuthnBrowserConfigurer> webAuthn,
ObjectProvider<top.ddupan.iam.login.authorization.application.port.LogoutGateway> logoutGateway) throws Exception {
var passwordFactor = AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10)))
.build();
var mfa = webAuthn.getIfAvailable();
http.securityMatcher("/signin", "/signin/**", "/assets/**", "/webauthn/**", "/login/webauthn")
http.securityMatcher("/signin", "/signin/**", "/assets/**", "/webauthn/**", "/login/webauthn", "/oauth2/**")
.redirectToHttps(Customizer.withDefaults())
.authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords)))
.authorizeHttpRequests(auth -> {
if (mfa != null) {
auth.requestMatchers("/signin/complete").access(mfa.policy.complete);
auth.requestMatchers("/signin/complete", "/oauth2/login", "/oauth2/consent").access(mfa.policy.complete);
auth.requestMatchers(org.springframework.http.HttpMethod.POST, "/webauthn/register")
.access(mfa.policy.password);
}
auth.requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll()
auth.requestMatchers("/error", "/signin", "/signin/password", "/assets/**", "/oauth2/start", "/oauth2/logout").permitAll()
.requestMatchers("/signin/mfa").access(passwordFactor.authenticated())
// Credential deletion and all unimplemented routes remain closed.
.anyRequest().denyAll();
})
.formLogin(form -> form.loginPage("/signin").loginProcessingUrl("/signin/password")
.defaultSuccessUrl("/signin/mfa", true).failureUrl("/signin?error"))
.logout(logout -> logout.logoutUrl("/signin/restart").logoutSuccessUrl("/signin"))
.logout(logout -> logout.logoutRequestMatcher(request -> PathPatternRequestMatcher.withDefaults().matcher(org.springframework.http.HttpMethod.POST,"/signin/restart").matches(request)
|| PathPatternRequestMatcher.withDefaults().matcher(org.springframework.http.HttpMethod.POST,"/signin/logout").matches(request))
.logoutSuccessHandler((request,response,authentication) -> {
var gateway = logoutGateway.getIfAvailable();
response.setStatus(303);
response.setHeader("Location",gateway!=null && PathPatternRequestMatcher.withDefaults().matcher("/signin/logout").matches(request)
? gateway.startUrl() : "/signin");
}))
.exceptionHandling(exceptions -> exceptions
.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/signin"),
PathPatternRequestMatcher.withDefaults().matcher("/signin/**"))
.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/signin"),
PathPatternRequestMatcher.withDefaults().matcher("/oauth2/**"))
.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
org.springframework.security.web.util.matcher.AnyRequestMatcher.INSTANCE))
.requestCache(cache -> cache.disable())
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")));
top.ddupan.iam.login.authentication.interfaces.web.PageRenderer.CONTENT_SECURITY_POLICY)));
if (mfa != null) mfa.configure(http);
var chain = http.build();
return mfa == null ? chain : mfa.finish(http, chain);
}
@Bean
@Order(3)
@Order(4)
SecurityFilterChain fallback(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> auth
.requestMatchers("/error").permitAll()