diff --git a/README.MD b/README.MD index 58a5059a0..083d1aa73 100644 --- a/README.MD +++ b/README.MD @@ -482,7 +482,7 @@ These starters are designed to push auto-configuration one step further. In most I could forget to update README before releasing, so please refer to [maven central](https://repo1.maven.org/maven2/com/c4-soft/springaddons/spring-addons/) to pick latest available release ```xml - 7.3.5 + 7.3.6 @@ -518,6 +518,9 @@ I could forget to update README before releasing, so please refer to [maven cent ### 5.1. `7.x` Branch +### `7.3.6` +- Add a `com.c4-soft.springaddons.oidc.client.back-channel-logout.enabled` property to opt-in [Spring Security implementation of Back-Channel Logout](https://docs.spring.io/spring-security/reference/reactive/oauth2/login/logout.html#configure-provider-initiated-oidc-logout). `Customizer.withDefaults()` is used unless you provide one as a bean. + ### `7.3.5` - Boot `3.2.2` as transitive dependency diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcClientProperties.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcClientProperties.java index a3059813a..ba8e5d633 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcClientProperties.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcClientProperties.java @@ -12,226 +12,231 @@ import lombok.Data; /** - * Auto-configuration for an OAuth2 client (secured with session, not access token) Security(Web)FilterChain with - * @Order(Ordered.LOWEST_PRECEDENCE - 1). Typical use-cases are spring-cloud-gateway used as BFF and applications with Thymeleaf or - * another server-side rendering framework. Default configuration includes: enabled sessions, CSRF protection, "oauth2Login", "logout". - * securityMatchers must be set for this filter-chain @Bean and its dependencies to be defined. Properties defined here are a - * complement for spring.security.oauth2.client.* (which are required when enabling spring-addons client filter-chain). + * Auto-configuration for an OAuth2 client (secured with session, not access token) Security(Web)FilterChain with @Order(Ordered.LOWEST_PRECEDENCE - 1). + * Typical use-cases are spring-cloud-gateway used as BFF and applications with Thymeleaf or another server-side rendering framework. Default configuration + * includes: enabled sessions, CSRF protection, "oauth2Login", "logout". securityMatchers must be set for this filter-chain @Bean and its dependencies to be + * defined. Properties defined here are a complement for spring.security.oauth2.client.* (which are required when enabling spring-addons client + * filter-chain). * * @author Jerome Wacongne ch4mp@c4-soft.com */ @Data @ConfigurationProperties("com.c4-soft.springaddons.oidc.client") public class SpringAddonsOidcClientProperties { - public static final String POST_AUTHENTICATION_SUCCESS_URI_HEADER = "X-POST-LOGIN-SUCCESS-URI"; - public static final String POST_AUTHENTICATION_SUCCESS_URI_PARAM = "post_login_success_uri"; - public static final String POST_AUTHENTICATION_SUCCESS_URI_SESSION_ATTRIBUTE = POST_AUTHENTICATION_SUCCESS_URI_PARAM; - - public static final String POST_AUTHENTICATION_FAILURE_URI_HEADER = "X-POST-LOGIN-FAILURE-URI"; - public static final String POST_AUTHENTICATION_FAILURE_URI_PARAM = "post_login_failure_uri"; - public static final String POST_AUTHENTICATION_FAILURE_URI_SESSION_ATTRIBUTE = POST_AUTHENTICATION_FAILURE_URI_PARAM; - - public static final String POST_LOGOUT_SUCCESS_URI_HEADER = "X-POST-LOGOUT-SUCCESS-URI"; - public static final String POST_LOGOUT_SUCCESS_URI_PARAM = "post_logout_success_uri"; - - /** - * Path matchers for the routes secured with the auto-configured client filter-chain. If left empty, OAuth2 client auto-configuration is - * disabled. It should include "/login/**" and "/oauth2/**" for login process. Can be set to "/**" to intercept all requests (OAuth2 client - * only application, no REST API secured with access tokens). - */ - private String[] securityMatchers = {}; - - /** - * Fully qualified URI of the configured OAuth2 client. - */ - private URI clientUri = URI.create("/"); - - /** - * Path to the login page. Provide one only in the following cases: - * - * If left empty, the default Spring Boot configuration for OAuth2 login is applied - */ - private Optional loginPath = Optional.empty(); - - /** - * URI containing scheme, host and port where the user should be redirected after a successful login (defaults to the client URI) - */ - private Optional postLoginRedirectHost = Optional.empty(); - - /** - * Where to redirect the user after successful login - */ - private Optional postLoginRedirectPath = Optional.empty(); - - /** - * Where to redirect the user after login failure - */ - private Optional loginErrorRedirectPath = Optional.empty(); - - /** - * HTTP status for redirections in OAuth2 login and logout. You might set this to something in 2xx range (like OK, ACCEPTED, NO_CONTENT, - * ...) for single page and mobile applications to handle this redirection as it wishes (change the user-agent, clear some headers, ...). - */ - private OAuth2RedirectionProperties oauth2Redirections = new OAuth2RedirectionProperties(); - - public URI getPostLoginRedirectHost() { - return postLoginRedirectHost.orElse(clientUri); - } - - public Optional getPostLoginRedirectUri() { - if (postLoginRedirectHost.isEmpty() && postLoginRedirectPath.isEmpty()) { - return Optional.empty(); - } - final var uri = UriComponentsBuilder.fromUri(getPostLoginRedirectHost()); - postLoginRedirectPath.ifPresent(uri::path); - - return Optional.of(uri.build(Map.of())); - } - - /** - * URI containing scheme, host and port where the user should be redirected after a successful logout (defaults to the client URI) - */ - private Optional postLogoutRedirectHost = Optional.empty(); - - /** - * Path (relative to clientUri) where the user should be redirected after being logged out from authorization server(s) - */ - private Optional postLogoutRedirectPath = Optional.empty(); - - public URI getPostLogoutRedirectHost() { - return postLogoutRedirectHost.orElse(clientUri); - } - - public URI getPostLogoutRedirectUri() { - final var uri = UriComponentsBuilder.fromUri(getPostLogoutRedirectHost()); - postLogoutRedirectPath.ifPresent(uri::path); - - return uri.build(Map.of()); - } - - /** - * Map of logout properties indexed by client registration ID (must match a registration in Spring Boot OAuth2 client configuration). - * {@link OAuth2LogoutProperties} are configuration for authorization server not strictly following the - * RP-Initiated Logout standard, but exposing a logout end-point - * expecting an authorized GET request with following request params: - * - */ - private Map oauth2Logout = new HashMap<>(); - - /** - *

- * If true, AOP is used to instrument authorized client repository and keep the principalName current user has for each issuer he - * authenticates on. - *

- *

- * This is useful only if you allow a user to authenticate on more than one OpenID Provider at a time. For instance, user logs in on Google - * and on an authorization server of your own and your client sends direct queries to Google APIs (with an access token issued by Google) - * and resource servers of your own (with an access token from your authorization server). - *

- */ - private boolean multiTenancyEnabled = false; - - /** - * Whether to enable a security filter-chain and a controller (intercepting POST requests to "/backchannel_logout") to implement the client - * side of a Back-Channel Logout - */ - // private boolean backChannelLogoutEnabled = false; - - /** - * Path matchers for the routes accessible to anonymous requests - */ - private String[] permitAll = { "/login/**", "/oauth2/**" }; - - /** - * CSRF protection configuration for the auto-configured client filter-chain - */ - private Csrf csrf = Csrf.DEFAULT; - - /** - * Fine grained CORS configuration - */ - private CorsProperties[] cors = {}; - - /** - * Additional parameters to send with authorization request, mapped by client registration IDs - */ - private Map authorizationRequestParams = new HashMap<>(); - - /** - * Logout properties for OpenID Providers which do not implement the RP-Initiated Logout spec - * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ - @Data - public static class OAuth2LogoutProperties { - - /** - * URI on the authorization server where to redirect the user for logout - */ - private URI uri; - - /** - * request param name for client-id - */ - private Optional clientIdRequestParam = Optional.empty(); - - /** - * request param name for post-logout redirect URI (where the user should be redirected after his session is closed on the authorization - * server) - */ - private Optional postLogoutUriRequestParam = Optional.empty(); - - /** - * request param name for setting an ID-Token hint - */ - private Optional idTokenHintRequestParam = Optional.empty(); - } - - /** - * Request parameter - * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ - @Data - public static class RequestParam { - /** - * request parameter name - */ - private String name; - - /** - * request parameter value - */ - private String value; - } - - @Data - @ConfigurationProperties("com.c4-soft.springaddons.oidc.client.oauth2-redirections") - public static class OAuth2RedirectionProperties { - - /** - * Status for the 1st response in authorization code flow, with location to get authorization code from authorization server - */ - private HttpStatus preAuthorizationCode = HttpStatus.FOUND; - - /** - * Status for the response after authorization code, with location to the UI - */ - private HttpStatus postAuthorizationCode = HttpStatus.FOUND; - - /** - * Status for the response after BFF logout, with location to authorization server logout endpoint - */ - private HttpStatus rpInitiatedLogout = HttpStatus.FOUND; - } - - public Optional getLogoutProperties(String clientRegistrationId) { - return Optional.ofNullable(oauth2Logout.get(clientRegistrationId)); - } + public static final String POST_AUTHENTICATION_SUCCESS_URI_HEADER = "X-POST-LOGIN-SUCCESS-URI"; + public static final String POST_AUTHENTICATION_SUCCESS_URI_PARAM = "post_login_success_uri"; + public static final String POST_AUTHENTICATION_SUCCESS_URI_SESSION_ATTRIBUTE = POST_AUTHENTICATION_SUCCESS_URI_PARAM; + + public static final String POST_AUTHENTICATION_FAILURE_URI_HEADER = "X-POST-LOGIN-FAILURE-URI"; + public static final String POST_AUTHENTICATION_FAILURE_URI_PARAM = "post_login_failure_uri"; + public static final String POST_AUTHENTICATION_FAILURE_URI_SESSION_ATTRIBUTE = POST_AUTHENTICATION_FAILURE_URI_PARAM; + + public static final String POST_LOGOUT_SUCCESS_URI_HEADER = "X-POST-LOGOUT-SUCCESS-URI"; + public static final String POST_LOGOUT_SUCCESS_URI_PARAM = "post_logout_success_uri"; + + /** + * Path matchers for the routes secured with the auto-configured client filter-chain. If left empty, OAuth2 client auto-configuration is disabled. It should + * include "/login/**" and "/oauth2/**" for login process. Can be set to "/**" to intercept all requests (OAuth2 client only application, no REST API + * secured with access tokens). + */ + private String[] securityMatchers = {}; + + /** + * Fully qualified URI of the configured OAuth2 client. + */ + private URI clientUri = URI.create("/"); + + /** + * Path to the login page. Provide one only in the following cases: + *
    + *
  • you want to provide your own login @Controller
  • + *
  • you want to use port 80 or 8080 with SSL enabled (this will require you to provide with the login @Controller above)
  • + *
+ * If left empty, the default Spring Boot configuration for OAuth2 login is applied + */ + private Optional loginPath = Optional.empty(); + + /** + * URI containing scheme, host and port where the user should be redirected after a successful login (defaults to the client URI) + */ + private Optional postLoginRedirectHost = Optional.empty(); + + /** + * Where to redirect the user after successful login + */ + private Optional postLoginRedirectPath = Optional.empty(); + + /** + * Where to redirect the user after login failure + */ + private Optional loginErrorRedirectPath = Optional.empty(); + + /** + * HTTP status for redirections in OAuth2 login and logout. You might set this to something in 2xx range (like OK, ACCEPTED, NO_CONTENT, ...) for single + * page and mobile applications to handle this redirection as it wishes (change the user-agent, clear some headers, ...). + */ + private OAuth2RedirectionProperties oauth2Redirections = new OAuth2RedirectionProperties(); + + public URI getPostLoginRedirectHost() { + return postLoginRedirectHost.orElse(clientUri); + } + + public Optional getPostLoginRedirectUri() { + if (postLoginRedirectHost.isEmpty() && postLoginRedirectPath.isEmpty()) { + return Optional.empty(); + } + final var uri = UriComponentsBuilder.fromUri(getPostLoginRedirectHost()); + postLoginRedirectPath.ifPresent(uri::path); + + return Optional.of(uri.build(Map.of())); + } + + /** + * URI containing scheme, host and port where the user should be redirected after a successful logout (defaults to the client URI) + */ + private Optional postLogoutRedirectHost = Optional.empty(); + + /** + * Path (relative to clientUri) where the user should be redirected after being logged out from authorization server(s) + */ + private Optional postLogoutRedirectPath = Optional.empty(); + + public URI getPostLogoutRedirectHost() { + return postLogoutRedirectHost.orElse(clientUri); + } + + public URI getPostLogoutRedirectUri() { + final var uri = UriComponentsBuilder.fromUri(getPostLogoutRedirectHost()); + postLogoutRedirectPath.ifPresent(uri::path); + + return uri.build(Map.of()); + } + + /** + * Map of logout properties indexed by client registration ID (must match a registration in Spring Boot OAuth2 client configuration). + * {@link OAuth2LogoutProperties} are configuration for authorization server not strictly following the + * RP-Initiated Logout standard, but exposing a logout end-point expecting an + * authorized GET request with following request params: + *
    + *
  • "client-id" (required)
  • + *
  • post-logout redirect URI (optional)
  • + *
+ */ + private Map oauth2Logout = new HashMap<>(); + + /** + *

+ * If true, AOP is used to instrument authorized client repository and keep the principalName current user has for each issuer he authenticates on. + *

+ *

+ * This is useful only if you allow a user to authenticate on more than one OpenID Provider at a time. For instance, user logs in on Google and on an + * authorization server of your own and your client sends direct queries to Google APIs (with an access token issued by Google) and resource servers of your + * own (with an access token from your authorization server). + *

+ */ + private boolean multiTenancyEnabled = false; + + /** + * Whether to enable a security filter-chain and a controller (intercepting POST requests to "/backchannel_logout") to implement the client side of a + * Back-Channel Logout + */ + // private boolean backChannelLogoutEnabled = false; + + /** + * Path matchers for the routes accessible to anonymous requests + */ + private String[] permitAll = { "/login/**", "/oauth2/**" }; + + /** + * CSRF protection configuration for the auto-configured client filter-chain + */ + private Csrf csrf = Csrf.DEFAULT; + + /** + * Fine grained CORS configuration + */ + private CorsProperties[] cors = {}; + + /** + * Additional parameters to send with authorization request, mapped by client registration IDs + */ + private Map authorizationRequestParams = new HashMap<>(); + + /** + * Logout properties for OpenID Providers which do not implement the RP-Initiated Logout spec + * + * @author Jerome Wacongne ch4mp@c4-soft.com + */ + @Data + public static class OAuth2LogoutProperties { + + /** + * URI on the authorization server where to redirect the user for logout + */ + private URI uri; + + /** + * request param name for client-id + */ + private Optional clientIdRequestParam = Optional.empty(); + + /** + * request param name for post-logout redirect URI (where the user should be redirected after his session is closed on the authorization server) + */ + private Optional postLogoutUriRequestParam = Optional.empty(); + + /** + * request param name for setting an ID-Token hint + */ + private Optional idTokenHintRequestParam = Optional.empty(); + } + + private BackChannelLogoutProperties backChannelLogout = new BackChannelLogoutProperties(); + + @Data + public static class BackChannelLogoutProperties { + private boolean enabled = false; + } + + /** + * Request parameter + * + * @author Jerome Wacongne ch4mp@c4-soft.com + */ + @Data + public static class RequestParam { + /** + * request parameter name + */ + private String name; + + /** + * request parameter value + */ + private String value; + } + + @Data + @ConfigurationProperties("com.c4-soft.springaddons.oidc.client.oauth2-redirections") + public static class OAuth2RedirectionProperties { + + /** + * Status for the 1st response in authorization code flow, with location to get authorization code from authorization server + */ + private HttpStatus preAuthorizationCode = HttpStatus.FOUND; + + /** + * Status for the response after authorization code, with location to the UI + */ + private HttpStatus postAuthorizationCode = HttpStatus.FOUND; + + /** + * Status for the response after BFF logout, with location to authorization server logout endpoint + */ + private HttpStatus rpInitiatedLogout = HttpStatus.FOUND; + } + + public Optional getLogoutProperties(String clientRegistrationId) { + return Optional.ofNullable(oauth2Logout.get(clientRegistrationId)); + } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsBackChannelLogoutBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsBackChannelLogoutBeans.java deleted file mode 100644 index a6173f373..000000000 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsBackChannelLogoutBeans.java +++ /dev/null @@ -1,206 +0,0 @@ -package com.c4_soft.springaddons.security.oidc.starter.reactive.client; - -import static org.springframework.security.config.Customizer.withDefaults; - -import java.util.Collection; -import java.util.Set; -import java.util.concurrent.ConcurrentLinkedQueue; - -import org.aspectj.lang.JoinPoint; -import org.aspectj.lang.annotation.AfterReturning; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.annotation.Before; -import org.aspectj.lang.annotation.Pointcut; -import org.springframework.boot.autoconfigure.web.ServerProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; -import org.springframework.security.web.server.SecurityWebFilterChain; -import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository; -import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.WebSession; - -import lombok.RequiredArgsConstructor; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -/** - *

- * This provides with a client side implementation of the OIDC Back-Channel Logout - * specification. Keycloak conforms to this OP side of the spec. - * Auth0 could some day. - *

- *

- * Implementation is made with a security filter-chain intercepting just the "/backchannel_logout" route and a controller handling requests to that end-point. - *

- *

- * This beans are defined only if "com.c4-soft.springaddons.oidc.client.back-channel-logout-enabled" property is true. - *

- * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ -// @Conditional(IsNotServlet.class) -// @ConditionalOnProperty("com.c4-soft.springaddons.oidc.client.back-channel-logout-enabled") -// @AutoConfiguration -// @ImportAutoConfiguration(ReactiveSpringAddonsOidcBeans.class) -public class ReactiveSpringAddonsBackChannelLogoutBeans { - - private static final String BACKCHANNEL_LOGOUT_PATH = "/backchannel_logout"; - - /** - * Requests from the OP are anonymous, are not part of a session, and have no CSRF token. It contains a logout JWT which serves both to authenticate the - * request and protect against CSRF. - * - * @param http - * @param serverProperties Spring Boot server properties - * @return a security filter-chain dedicated to back-channel logout handling - * @throws Exception - */ - @Order(Ordered.HIGHEST_PRECEDENCE) - @Bean - SecurityWebFilterChain springAddonsBackChannelLogoutClientFilterChain(ServerHttpSecurity http, ServerProperties serverProperties) throws Exception { - http.securityMatcher(new PathPatternParserServerWebExchangeMatcher(BACKCHANNEL_LOGOUT_PATH)); - http.authorizeExchange(exchange -> exchange.anyExchange().permitAll()); - if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { - http.redirectToHttps(withDefaults()); - } - http.cors(cors -> cors.disable()); - http.securityContextRepository(NoOpServerSecurityContextRepository.getInstance()); - http.csrf(csrf -> csrf.disable()); - return http.build(); - } - - /** - *

- * Handles a POST request containing a JWT logout token provided as application/x-www-form-urlencoded as specified in - * Back-Channel Logout specification. - *

- *

- * This end-point will: - *

    - *
  • remove the relevant authorized client (based on issuer URI) for the relevant user (based on the subject)
  • - *
  • maybe invalidate user session: only if the removed authorized client was the last one the user had
  • - *
- * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ - @Component - @RestController - public static class BackChannelLogoutController { - // private final AbstractReactiveAuthorizedSessionRepository authorizedSessionRepository; - // private final Map issuersData = new ConcurrentHashMap(); - // private final ServerLogoutHandler logoutHandler; - // private final ReactiveClientRegistrationRepository clientRegistrationRepo; - // - // public BackChannelLogoutController( - // AbstractReactiveAuthorizedSessionRepository authorizedClientRepository, - // InMemoryReactiveClientRegistrationRepository registrationRepo, - // ServerLogoutHandler logoutHandler, - // ReactiveClientRegistrationRepository clientRegistrationRepo) { - // this.authorizedSessionRepository = authorizedClientRepository; - // this.logoutHandler = logoutHandler; - // this.clientRegistrationRepo = clientRegistrationRepo; - // StreamSupport.stream(registrationRepo.spliterator(), false) - // .filter(reg -> AuthorizationGrantType.AUTHORIZATION_CODE.equals(reg.getAuthorizationGrantType())).forEach(reg -> { - // final var issuer = reg.getProviderDetails().getIssuerUri(); - // if (!this.issuersData.containsKey(issuer)) { - // this.issuersData.put( - // issuer, - // new IssuerData( - // issuer, - // new HashSet<>(), - // NimbusReactiveJwtDecoder.withJwkSetUri(reg.getProviderDetails().getJwkSetUri()).build())); - // } - // issuersData.get(issuer).clientRegistrationIds().add(reg.getRegistrationId()); - // }); - // } - // - // @PostMapping(path = BACKCHANNEL_LOGOUT_PATH, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) - // public Mono> backChannelLogout(ServerWebExchange serverWebExchange) { - // serverWebExchange.getFormData().subscribe(body -> { - // final var tokenString = body.get("logout_token"); - // if (tokenString == null || tokenString.size() != 1) { - // throw new BadLogoutRequestException(); - // } - // issuersData.forEach((issuer, data) -> { - // data.jwtDecoder().decode(tokenString.get(0)).onErrorComplete().subscribe(jwt -> { - // final var isLogoutToken = Optional.ofNullable(jwt.getClaims().get("events")).map(Object::toString) - // .map(evt -> evt.contains("http://schemas.openid.net/event/backchannel-logout")).orElse(false); - // if (!isLogoutToken) { - // throw new BadLogoutRequestException(); - // } - // final var logoutIss = Optional.ofNullable(jwt.getIssuer()).map(URL::toString).orElse(null); - // if (!Objects.equals(issuer, logoutIss)) { - // throw new BadLogoutRequestException(); - // } - // for (var id : data.clientRegistrationIds()) { - // clientRegistrationRepo.findByRegistrationId(id).subscribe(reg -> { - // final var usernameClaim = reg.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); - // final var principalName = jwt.getClaimAsString(usernameClaim); - // authorizedSessionRepository.delete(new OAuth2AuthorizedClientId(id, principalName)).subscribe(sessionId -> { - // authorizedSessionRepository.findAuthorizedClientIdsBySessionId(sessionId).collectList().subscribe(authorizedClientIds -> { - // if (authorizedClientIds.size() == 0) { - // logoutHandler.logout(null, null); - // } - // }); - // }); - // }); - // } - // }); - // }); - // }); - // return Mono.just(ResponseEntity.ok().build()); - // } - // - // @ResponseStatus(HttpStatus.BAD_REQUEST) - // static final class BadLogoutRequestException extends RuntimeException { - // private static final long serialVersionUID = -1803794467531166681L; - // } - } - - @Aspect - @Component - @RequiredArgsConstructor - public static class ReactiveSessionRepositoryAspect implements SessionLifecycleEventNotifier { - private static final Collection listeners = new ConcurrentLinkedQueue<>(); - - @Override - public void register(ReactiveSessionListener listener) { - listeners.add(listener); - } - - @Pointcut("within(org.springframework.session.ReactiveSessionRepository+) && execution(* *.createSession(..))") - public void createSession() { - } - - @Pointcut("within(org.springframework.session.ReactiveSessionRepository+) && execution(* *.deleteById(..))") - public void deleteById() { - } - - @AfterReturning(value = "createSession()", returning = "session") - public void afterSessionCreated(Mono session) { - session.flatMap(s -> Flux.fromIterable(listeners).doOnNext(l -> l.sessionCreated(s)).then(Mono.just(s))).subscribe(); - } - - @Before(value = "deleteById()") - public void beforeDeleteById(JoinPoint jp) { - var sessionId = (String) jp.getArgs()[0]; - listeners.forEach(l -> { - l.sessionRemoved(sessionId); - }); - } - } - - // @ConditionalOnMissingBean - // @Bean - // AbstractReactiveAuthorizedSessionRepository authorizedSessionRepository(SessionLifecycleEventNotifier sessionEventNotifier) { - // return new InMemoryReactiveAuthorizedSessionRepository(sessionEventNotifier); - // } - - private static record IssuerData(String issuer, Set clientRegistrationIds, ReactiveJwtDecoder jwtDecoder) { - } -} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientBeans.java index d99c08c7c..ba39c95d7 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientBeans.java @@ -12,6 +12,7 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; @@ -113,6 +114,7 @@ public class ReactiveSpringAddonsOidcClientBeans { * was not matched) * @param httpPostProcessor post process the "http" builder just before it is returned (enables to override anything from the auto-configuration) * spring-addons client properties} + * @param oidcLogoutCustomizer a configurer for Spring Security Back-Channel Logout implementation * @return a security filter-chain scoped to specified security-matchers and adapted to OAuth2 clients * @throws Exception in case of miss-configuration */ @@ -129,7 +131,8 @@ SecurityWebFilterChain clientFilterChain( ServerLogoutSuccessHandler logoutSuccessHandler, ClientAuthorizeExchangeSpecPostProcessor authorizePostProcessor, ClientHttpSecurityPostProcessor httpPostProcessor, - ServerLogoutHandler logoutHandler) + ServerLogoutHandler logoutHandler, + Customizer oidcLogoutCustomizer) throws Exception { final var clientRoutes = Stream @@ -158,6 +161,10 @@ SecurityWebFilterChain clientFilterChain( logout.logoutSuccessHandler(logoutSuccessHandler); }); + if(addonsProperties.getClient().getBackChannelLogout().isEnabled()) { + http.oidcLogout(oidcLogoutCustomizer); + } + ReactiveConfigurationSupport.configureClient(http, serverProperties, addonsProperties.getClient(), authorizePostProcessor, httpPostProcessor); return http.build(); @@ -280,4 +287,10 @@ public SpringAddonsPreAuthorizationCodeServerRedirectStrategy(HttpStatus default } } + + @ConditionalOnMissingBean + @Bean + Customizer oidcLogoutSpec() { + return Customizer.withDefaults(); + } } \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsBackChannelLogoutBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsBackChannelLogoutBeans.java deleted file mode 100644 index 9d9289ba3..000000000 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsBackChannelLogoutBeans.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.c4_soft.springaddons.security.oidc.starter.synchronised.client; - -import org.springframework.boot.autoconfigure.web.ServerProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; - -/** - *

- * This provides with a client side implementation of the OIDC Back-Channel Logout - * specification. Keycloak conforms to this OP side of the spec. - * Auth0 could some day. - *

- *

- * Implementation is made with a security filter-chain intercepting just the "/backchannel_logout" route and a controller handling requests to that end-point. - *

- *

- * This beans are defined only if "com.c4-soft.springaddons.oidc.client.back-channel-logout-enabled" property is true. - *

- * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ -// @ConditionalOnWebApplication(type = Type.SERVLET) -// @ConditionalOnProperty("com.c4-soft.springaddons.oidc.client.back-channel-logout-enabled") -// @AutoConfiguration -// @ImportAutoConfiguration(SpringAddonsOidcBeans.class) -public class SpringAddonsBackChannelLogoutBeans { - - private static final String BACKCHANNEL_LOGOUT_PATH = "/backchannel_logout"; - - /** - * Requests from the OP are anonymous, are not part of a session, and have no CSRF token. It contains a logout JWT which serves both to authenticate the - * request and protect against CSRF. - * - * @param http - * @param serverProperties Spring Boot server properties - * @return a security filter-chain dedicated to back-channel logout handling - * @throws Exception - */ - @Order(Ordered.HIGHEST_PRECEDENCE) - @Bean - SecurityFilterChain springAddonsBackChannelLogoutClientFilterChain(HttpSecurity http, ServerProperties serverProperties) throws Exception { - http.securityMatcher(new AntPathRequestMatcher(BACKCHANNEL_LOGOUT_PATH)); - http.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests.anyRequest().permitAll()); - if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { - http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); - } - http.cors(cors -> cors.disable()); - http.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - http.csrf(csrf -> csrf.disable()); - return http.build(); - } - - /** - *

- * Handles a POST request containing a JWT logout token provided as application/x-www-form-urlencoded as specified in - * Back-Channel Logout specification. - *

- *

- * This end-point will: - *

    - *
  • remove the relevant authorized client (based on issuer URI) for the relevant user (based on the subject)
  • - *
  • maybe invalidate user session: only if the removed authorized client was the last one the user had
  • - *
- * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ - // @Component - // @RestController - // public static class BackChannelLogoutController { - // private final AuthorizedSessionRepository authorizedSessionRepository; - // private final Map jwtDecoders; - // - // public BackChannelLogoutController(AuthorizedSessionRepository authorizedClientRepository, InMemoryClientRegistrationRepository registrationRepo) { - // this.authorizedSessionRepository = authorizedClientRepository; - // this.jwtDecoders = StreamSupport.stream(registrationRepo.spliterator(), false) - // .filter(reg -> AuthorizationGrantType.AUTHORIZATION_CODE.equals(reg.getAuthorizationGrantType())) - // .map(ClientRegistration::getProviderDetails).collect( - // Collectors.toMap(provider -> provider.getIssuerUri(), provider -> NimbusJwtDecoder.withJwkSetUri(provider.getJwkSetUri()).build())); - // } - // - // @PostMapping(path = BACKCHANNEL_LOGOUT_PATH, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) - // public ResponseEntity backChannelLogout(@RequestParam MultiValueMap body) { - // final var tokenString = body.get("logout_token"); - // if (tokenString == null || tokenString.size() != 1) { - // throw new BadLogoutRequestException(); - // } - // jwtDecoders.forEach((issuer, decoder) -> { - // try { - // final var jwt = decoder.decode(tokenString.get(0)); - // final var isLogoutToken = Optional.ofNullable(jwt.getClaims().get("events")).map(Object::toString) - // .map(evt -> evt.contains("http://schemas.openid.net/event/backchannel-logout")).orElse(false); - // if (!isLogoutToken) { - // throw new BadLogoutRequestException(); - // } - // final var logoutIss = Optional.ofNullable(jwt.getIssuer()).map(URL::toString).orElse(null); - // if (!Objects.equals(issuer, logoutIss)) { - // throw new BadLogoutRequestException(); - // } - // final var logoutSub = jwt.getSubject(); - // final var sessionToInvalidate = authorizedSessionRepository.findById(new OAuth2AuthorizedClientId(logoutIss, logoutSub)); - // sessionToInvalidate.ifPresent(HttpSession::invalidate); - // } catch (JwtException e) { - // } - // }); - // return ResponseEntity.ok().build(); - // } - // - // @ResponseStatus(HttpStatus.BAD_REQUEST) - // static final class BadLogoutRequestException extends RuntimeException { - // private static final long serialVersionUID = -8703279699142477824L; - // } - // } - -} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOidcClientBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOidcClientBeans.java index feefb838f..7cfd56fda 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOidcClientBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOidcClientBeans.java @@ -13,8 +13,10 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; import org.springframework.security.web.SecurityFilterChain; @@ -100,6 +102,7 @@ public class SpringAddonsOidcClientBeans { * was not matched) * @param httpPostProcessor post process the "http" builder just before it is returned (enables to override anything from the auto-configuration) * spring-addons client properties} + * @param oidcLogoutCustomizer a configurer for Spring Security Back-Channel Logout implementation * @return a security filter-chain scoped to specified security-matchers and adapted to OAuth2 clients * @throws Exception in case of miss-configuration */ @@ -115,7 +118,8 @@ SecurityFilterChain springAddonsClientFilterChain( LogoutSuccessHandler logoutSuccessHandler, SpringAddonsOidcProperties addonsProperties, ClientExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, - ClientHttpSecurityPostProcessor httpPostProcessor) + ClientHttpSecurityPostProcessor httpPostProcessor, + Customizer> oidcLogoutCustomizer) throws Exception { // @formatter:off log.info("Applying client OAuth2 configuration for: {}", (Object[]) addonsProperties.getClient().getSecurityMatchers()); @@ -136,6 +140,10 @@ SecurityFilterChain springAddonsClientFilterChain( }); // @formatter:on + if (addonsProperties.getClient().getBackChannelLogout().isEnabled()) { + http.oidcLogout(oidcLogoutCustomizer); + } + ServletConfigurationSupport.configureClient(http, serverProperties, addonsProperties.getClient(), authorizePostProcessor, httpPostProcessor); return http.build(); @@ -234,4 +242,10 @@ AuthenticationSuccessHandler authenticationSuccessHandler(SpringAddonsOidcProper AuthenticationFailureHandler authenticationFailureHandler(SpringAddonsOidcProperties addonsProperties) { return new SpringAddonsOauth2AuthenticationFailureHandler(addonsProperties); } + + @ConditionalOnMissingBean + @Bean + Customizer> oidcLogoutCustomizer() { + return Customizer.withDefaults(); + } }