diff --git a/samples/pom.xml b/samples/pom.xml index a42878f42..6943a138e 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -13,7 +13,7 @@ 17 - 2022.0.4 + 2023.0.0 2.2.19 diff --git a/samples/tutorials/bff/backend/official/bff-official/src/main/resources/application.yml b/samples/tutorials/bff/backend/official/bff-official/src/main/resources/application.yml index 108acaef7..1556cd01e 100644 --- a/samples/tutorials/bff/backend/official/bff-official/src/main/resources/application.yml +++ b/samples/tutorials/bff/backend/official/bff-official/src/main/resources/application.yml @@ -84,8 +84,8 @@ logging: level: org: springframework: - security: DEBUG - boot: DEBUG + security: INFO + boot: INFO --- spring: diff --git a/samples/tutorials/bff/backend/official/greeting-api-official/src/main/resources/application.yml b/samples/tutorials/bff/backend/official/greeting-api-official/src/main/resources/application.yml index 2345125f4..89d8fa55f 100644 --- a/samples/tutorials/bff/backend/official/greeting-api-official/src/main/resources/application.yml +++ b/samples/tutorials/bff/backend/official/greeting-api-official/src/main/resources/application.yml @@ -21,7 +21,7 @@ logging: level: org: springframework: - security: DEBUG + security: INFO --- server: diff --git a/samples/tutorials/bff/backend/with-c4-soft/bff-c4/src/main/resources/application.yml b/samples/tutorials/bff/backend/with-c4-soft/bff-c4/src/main/resources/application.yml index bcd1bc2e1..28ae684d5 100644 --- a/samples/tutorials/bff/backend/with-c4-soft/bff-c4/src/main/resources/application.yml +++ b/samples/tutorials/bff/backend/with-c4-soft/bff-c4/src/main/resources/application.yml @@ -108,7 +108,8 @@ logging: level: org: springframework: - security: TRACE + security: INFO + boot: DEBUG --- spring: diff --git a/samples/tutorials/bff/backend/with-c4-soft/greeting-api-c4/src/main/resources/application.yml b/samples/tutorials/bff/backend/with-c4-soft/greeting-api-c4/src/main/resources/application.yml index e4a85fbf1..071739989 100644 --- a/samples/tutorials/bff/backend/with-c4-soft/greeting-api-c4/src/main/resources/application.yml +++ b/samples/tutorials/bff/backend/with-c4-soft/greeting-api-c4/src/main/resources/application.yml @@ -30,7 +30,7 @@ logging: org: springframework: boot: INFO - security: DEBUG + security: INFO --- server: diff --git a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/annotations/WithOpaqueToken.java b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/annotations/WithOpaqueToken.java index eed33cb29..2452ebb51 100644 --- a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/annotations/WithOpaqueToken.java +++ b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/annotations/WithOpaqueToken.java @@ -169,10 +169,8 @@ public Map getAttributes() { final var auth = c.convert(bearerString, principal).block(); return auth; }).orElseGet(() -> { - Instant iat = - Optional.ofNullable(principal.getAttribute(OAuth2TokenIntrospectionClaimNames.IAT)).map(Instant.class::cast).orElse(Instant.now()); - Instant exp = Optional.ofNullable(principal.getAttribute(OAuth2TokenIntrospectionClaimNames.EXP)).map(Instant.class::cast) - .orElse(Instant.ofEpochSecond(Instant.now().getEpochSecond() + 300)); + Instant iat = principal.getAttribute(OAuth2TokenIntrospectionClaimNames.IAT); + Instant exp = principal.getAttribute(OAuth2TokenIntrospectionClaimNames.EXP); OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, bearerString, iat, exp); return new BearerTokenAuthentication(principal, accessToken, principal.getAuthorities()); })); diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/AuthoritiesMappingPropertiesResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/AuthoritiesMappingPropertiesResolver.java new file mode 100644 index 000000000..8119366ed --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/AuthoritiesMappingPropertiesResolver.java @@ -0,0 +1,9 @@ +package com.c4_soft.springaddons.security.oidc.starter; + +import java.util.Map; + +import com.c4_soft.springaddons.security.oidc.starter.properties.SimpleAuthoritiesMappingProperties; + +public interface AuthoritiesMappingPropertiesResolver { + SimpleAuthoritiesMappingProperties[] resolve(Map claimSet); +} \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerAuthoritiesMappingPropertiesResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerAuthoritiesMappingPropertiesResolver.java new file mode 100644 index 000000000..77fbe7a11 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerAuthoritiesMappingPropertiesResolver.java @@ -0,0 +1,22 @@ +package com.c4_soft.springaddons.security.oidc.starter; + +import java.util.Map; +import java.util.Optional; + +import org.springframework.security.oauth2.jwt.JwtClaimNames; + +import com.c4_soft.springaddons.security.oidc.starter.properties.SimpleAuthoritiesMappingProperties; +import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ByIssuerAuthoritiesMappingPropertiesResolver implements AuthoritiesMappingPropertiesResolver{ + private final SpringAddonsOidcProperties properties; + + @Override + public SimpleAuthoritiesMappingProperties[] resolve(Map claimSet) { + final var iss = Optional.ofNullable(claimSet.get(JwtClaimNames.ISS)).orElse(null); + return properties.getOpProperties(iss).getAuthorities(); + } +} \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableClaimSetAuthoritiesConverter.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableClaimSetAuthoritiesConverter.java index 533ce2678..50d2c09e8 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableClaimSetAuthoritiesConverter.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableClaimSetAuthoritiesConverter.java @@ -3,12 +3,10 @@ import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.stream.Stream; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.util.StringUtils; import com.c4_soft.springaddons.security.oidc.starter.properties.SimpleAuthoritiesMappingProperties; @@ -35,11 +33,15 @@ */ @RequiredArgsConstructor public class ConfigurableClaimSetAuthoritiesConverter implements ClaimSetAuthoritiesConverter { - private final SpringAddonsOidcProperties properties; + private final AuthoritiesMappingPropertiesResolver authoritiesMappingPropertiesProvider; + + public ConfigurableClaimSetAuthoritiesConverter(SpringAddonsOidcProperties properties) { + this.authoritiesMappingPropertiesProvider = new ByIssuerAuthoritiesMappingPropertiesResolver(properties); + } @Override public Collection convert(Map source) { - final var authoritiesMappingProperties = getAuthoritiesMappingProperties(source); + final var authoritiesMappingProperties = authoritiesMappingPropertiesProvider.resolve(source); // @formatter:off return Stream.of(authoritiesMappingProperties) .flatMap(authoritiesMappingProps -> getAuthorities(source, authoritiesMappingProps)) @@ -60,11 +62,6 @@ private static String processCase(String role, SimpleAuthoritiesMappingPropertie } } - private SimpleAuthoritiesMappingProperties[] getAuthoritiesMappingProperties(Map claimSet) { - final var iss = Optional.ofNullable(claimSet.get(JwtClaimNames.ISS)).orElse(null); - return properties.getOpProperties(iss).getAuthorities(); - } - private static Stream getAuthorities(Map claims, SimpleAuthoritiesMappingProperties props) { // @formatter:off return getClaims(claims, props.getPath()) diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/AuthenticationFailureHandlerCondition.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/AuthenticationFailureHandlerCondition.java new file mode 100644 index 000000000..bce16aa23 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/AuthenticationFailureHandlerCondition.java @@ -0,0 +1,50 @@ +package com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.NoneNestedConditions; +import org.springframework.context.annotation.Conditional; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; + +public class AuthenticationFailureHandlerCondition extends AllNestedConditions { + + public AuthenticationFailureHandlerCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @Conditional(NoAuthenticationFailureHandlerCondition.class) + static class AuthenticationFailureHandlerMissingCondition {} + + @Conditional(PostLoginRedirectUriCondition.class) + static class PostLoginRedirectUriProvidedCondition {} + + static class NoAuthenticationFailureHandlerCondition extends NoneNestedConditions { + + public NoAuthenticationFailureHandlerCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnBean(AuthenticationFailureHandler.class) + static class AuthenticationFailureHandlerProvidedCondition {} + + @ConditionalOnBean(ServerAuthenticationFailureHandler.class) + static class ServerAuthenticationFailureHandlerProvidedCondition {} + } + + static class PostLoginRedirectUriCondition extends AnyNestedCondition { + + public PostLoginRedirectUriCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = "com.c4-soft.springaddons.oidc.client.post-login-redirect-host") + static class PostLoginRedirectHostCondition {} + + @ConditionalOnProperty(name = "com.c4-soft.springaddons.oidc.client.post-login-redirect-path") + static class PostLoginRedirectPathCondition {} + } + +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/AuthenticationSuccessHandlerCondition.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/AuthenticationSuccessHandlerCondition.java new file mode 100644 index 000000000..b657fb9ff --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/AuthenticationSuccessHandlerCondition.java @@ -0,0 +1,49 @@ +package com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.NoneNestedConditions; +import org.springframework.context.annotation.Conditional; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; + +public class AuthenticationSuccessHandlerCondition extends AllNestedConditions { + + public AuthenticationSuccessHandlerCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @Conditional(NoAuthenticationSuccessHandlerCondition.class) + static class AuthenticationSuccessHandlerMissingCondition {} + + @Conditional(PostLoginRedirectUriCondition.class) + static class PostLoginRedirectUriProvidedCondition {} + + static class NoAuthenticationSuccessHandlerCondition extends NoneNestedConditions { + + public NoAuthenticationSuccessHandlerCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnBean(AuthenticationSuccessHandler.class) + static class AuthenticationSuccessHandlerProvidedCondition {} + + @ConditionalOnBean(ServerAuthenticationSuccessHandler.class) + static class ServerAuthenticationSuccessHandlerProvidedCondition {} + } + + static class PostLoginRedirectUriCondition extends AnyNestedCondition { + + public PostLoginRedirectUriCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = "com.c4-soft.springaddons.oidc.client.post-login-redirect-host") + static class PostLoginRedirectHostCondition {} + + @ConditionalOnProperty(name = "com.c4-soft.springaddons.oidc.client.post-login-redirect-path") + static class PostLoginRedirectPathCondition {} + } +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/C4Oauth2ServerRedirectStrategy.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/C4Oauth2ServerRedirectStrategy.java index 6985c67c1..14a31052f 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/C4Oauth2ServerRedirectStrategy.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/C4Oauth2ServerRedirectStrategy.java @@ -23,31 +23,42 @@ */ @RequiredArgsConstructor public class C4Oauth2ServerRedirectStrategy implements ServerRedirectStrategy { - private final HttpStatus defaultStatus; - - @Override - public Mono sendRedirect(ServerWebExchange exchange, URI location) { - return Mono.fromRunnable(() -> { - ServerHttpResponse response = exchange.getResponse(); - // @formatter:off - final var status = Optional.ofNullable(exchange.getRequest().getHeaders().get("X-RESPONSE-STATUS")) - .map(List::stream) - .orElse(Stream.empty()) - .filter(StringUtils::hasLength) - .findAny() - .map(statusStr -> { - try { - final var statusCode = Integer.parseInt(statusStr); - return HttpStatus.valueOf(statusCode); - } catch(NumberFormatException e) { - return HttpStatus.valueOf(statusStr.toUpperCase()); - } - }) - .orElse(defaultStatus); - // @formatter:on - response.setStatusCode(status); - response.getHeaders().setLocation(location); - }); - } - -} \ No newline at end of file + public static final String RESPONSE_STATUS_HEADER = "X-RESPONSE-STATUS"; + public static final String RESPONSE_LOCATION_HEADER = "X-RESPONSE-LOCATION"; + + private final HttpStatus defaultStatus; + + @Override + public Mono sendRedirect(ServerWebExchange exchange, URI location) { + return Mono.fromRunnable(() -> { + ServerHttpResponse response = exchange.getResponse(); + final var status = Optional + .ofNullable(exchange.getRequest().getHeaders().get(RESPONSE_STATUS_HEADER)) + .map(List::stream) + .orElse(Stream.empty()) + .filter(StringUtils::hasLength) + .findAny() + .map(statusStr -> { + try { + final var statusCode = Integer.parseInt(statusStr); + return HttpStatus.valueOf(statusCode); + } catch (NumberFormatException e) { + return HttpStatus.valueOf(statusStr.toUpperCase()); + } + }) + .orElse(defaultStatus); + response.setStatusCode(status); + + final URI url = Optional + .ofNullable(exchange.getRequest().getHeaders().get(RESPONSE_LOCATION_HEADER)) + .map(List::stream) + .orElse(Stream.empty()) + .filter(StringUtils::hasLength) + .findAny() + .map(URI::create) + .orElse(location); + response.getHeaders().setLocation(url); + }); + } + +} 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 dc165e02d..d9cff6534 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 @@ -1,6 +1,7 @@ package com.c4_soft.springaddons.security.oidc.starter.reactive.client; import java.net.URI; +import java.util.Optional; import java.util.stream.Stream; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -11,6 +12,7 @@ import org.springframework.context.annotation.Conditional; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.Authentication; @@ -19,6 +21,7 @@ import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizationRequestResolver; import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint; import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; @@ -40,6 +43,8 @@ import com.c4_soft.springaddons.security.oidc.starter.LogoutRequestUriBuilder; import com.c4_soft.springaddons.security.oidc.starter.SpringAddonsOAuth2LogoutRequestUriBuilder; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.AuthenticationFailureHandlerCondition; +import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.AuthenticationSuccessHandlerCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.CookieCsrfCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.configuration.IsNotServlet; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.configuration.IsOidcClientCondition; @@ -80,56 +85,65 @@ @Slf4j public class ReactiveSpringAddonsOidcClientBeans { - /** - *

- * Instantiated only if "com.c4-soft.springaddons.oidc.client.security-matchers" property has at least one entry. If defined, it is with higher precedence - * than resource server one. - *

- * It defines: - *
    - *
  • If the path to login page was provided in conf, a @Controller must be provided to handle it. Otherwise Spring Boot default generated one is - * used
  • - *
  • logout (using {@link SpringAddonsServerLogoutSuccessHandler} by default)
  • - *
  • forces SSL usage if it is enabled
  • properties - *
  • CSRF protection as defined in spring-addons client properties (enabled by default in this filter-chain).
  • - *
  • allow access to unauthorized requests to path matchers listed in spring-security client "permit-all" property
  • - *
  • as usual, apply {@link ClientAuthorizeExchangeSpecPostProcessor} for access control configuration from Java conf and - * {@link ClientHttpSecurityPostProcessor} to override anything from the auto-configuration listed above
  • - *
- * - * @param http the security filter-chain builder to configure - * @param serverProperties Spring Boot standard server properties - * @param authorizationRequestResolver the authorization request resolver to use. By default {@link ServerOAuth2AuthorizationRequestResolver} (adds - * authorization request parameters defined in properties and builds absolutes callback URI) - * @param logoutSuccessHandler Defaulted to {@link SpringAddonsServerLogoutSuccessHandler} which can handle "almost" RP Initiated Logout conformant - * OPs (like Auth0 and Cognito) - * @param addonsProperties {@link SpringAddonsOAuth2ClientProperties spring-addons client properties} - * @param authorizePostProcessor post process authorization after "permit-all" configuration was applied (default is "isAuthenticated()" to - * everything that 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} - * @return a security filter-chain scoped to specified security-matchers and adapted to OAuth2 clients - * @throws Exception in case of miss-configuration - */ - @Order(Ordered.LOWEST_PRECEDENCE - 1) - @Bean - SecurityWebFilterChain clientFilterChain( - ServerHttpSecurity http, - ServerProperties serverProperties, - SpringAddonsOidcProperties addonsProperties, - ServerOAuth2AuthorizationRequestResolver authorizationRequestResolver, - ServerLogoutSuccessHandler logoutSuccessHandler, - ClientAuthorizeExchangeSpecPostProcessor authorizePostProcessor, - ClientHttpSecurityPostProcessor httpPostProcessor, - ServerLogoutHandler logoutHandler) - throws Exception { - - final var clientRoutes = Stream.of(addonsProperties.getClient().getSecurityMatchers()).map(PathPatternParserServerWebExchangeMatcher::new) - .map(ServerWebExchangeMatcher.class::cast).toList(); - log.info("Applying client OAuth2 configuration for: {}", (Object[]) addonsProperties.getClient().getSecurityMatchers()); - http.securityMatcher(new OrServerWebExchangeMatcher(clientRoutes)); - - // @formatter:off + /** + *

+ * Instantiated only if "com.c4-soft.springaddons.oidc.client.security-matchers" property has at least one entry. If defined, it is with higher precedence + * than resource server one. + *

+ * It defines: + *
    + *
  • If the path to login page was provided in conf, a @Controller must be provided to handle it. Otherwise Spring Boot default generated one is + * used
  • + *
  • logout (using {@link SpringAddonsServerLogoutSuccessHandler} by default)
  • + *
  • forces SSL usage if it is enabled
  • properties + *
  • CSRF protection as defined in spring-addons client properties (enabled by default in this filter-chain).
  • + *
  • allow access to unauthorized requests to path matchers listed in spring-security client "permit-all" property
  • + *
  • as usual, apply {@link ClientAuthorizeExchangeSpecPostProcessor} for access control configuration from Java conf and + * {@link ClientHttpSecurityPostProcessor} to override anything from the auto-configuration listed above
  • + *
+ * + * @param http the security filter-chain builder to configure + * @param serverProperties Spring Boot standard server properties + * @param authorizationRequestResolver the authorization request resolver to use. By default {@link ServerOAuth2AuthorizationRequestResolver} (adds + * authorization request parameters defined in properties and builds absolutes callback URI) + * @param preAuthorizationCodeRedirectStrategy the redirection strategy to use for authorization-code request + * @param authenticationSuccessHandler the authentication success handler to use + * @param authenticationFailureHandler the authentication failure handler to use + * @param logoutSuccessHandler Defaulted to {@link SpringAddonsServerLogoutSuccessHandler} which can handle "almost" RP Initiated Logout conformant OPs + * (like Auth0 and Cognito) + * @param addonsProperties {@link SpringAddonsOAuth2ClientProperties spring-addons client properties} + * @param authorizePostProcessor post process authorization after "permit-all" configuration was applied (default is "isAuthenticated()" to everything that + * 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} + * @return a security filter-chain scoped to specified security-matchers and adapted to OAuth2 clients + * @throws Exception in case of miss-configuration + */ + @Order(Ordered.LOWEST_PRECEDENCE - 1) + @Bean + SecurityWebFilterChain clientFilterChain( + ServerHttpSecurity http, + ServerProperties serverProperties, + SpringAddonsOidcProperties addonsProperties, + ServerOAuth2AuthorizationRequestResolver authorizationRequestResolver, + PreAuthorizationCodeServerRedirectStrategy preAuthorizationCodeRedirectStrategy, + Optional authenticationSuccessHandler, + Optional authenticationFailureHandler, + ServerLogoutSuccessHandler logoutSuccessHandler, + ClientAuthorizeExchangeSpecPostProcessor authorizePostProcessor, + ClientHttpSecurityPostProcessor httpPostProcessor, + ServerLogoutHandler logoutHandler) + throws Exception { + + final var clientRoutes = Stream + .of(addonsProperties.getClient().getSecurityMatchers()) + .map(PathPatternParserServerWebExchangeMatcher::new) + .map(ServerWebExchangeMatcher.class::cast) + .toList(); + log.info("Applying client OAuth2 configuration for: {}", (Object[]) addonsProperties.getClient().getSecurityMatchers()); + http.securityMatcher(new OrServerWebExchangeMatcher(clientRoutes)); + + // @formatter:off addonsProperties.getClient().getLoginPath().ifPresent(loginPath -> { http.exceptionHandling(exceptionHandling -> exceptionHandling .authenticationEntryPoint(new RedirectServerAuthenticationEntryPoint(UriComponentsBuilder.fromUri(addonsProperties.getClient().getClientUri()).path(loginPath).build().toString()))); @@ -137,11 +151,9 @@ SecurityWebFilterChain clientFilterChain( http.oauth2Login(oauth2 -> { oauth2.authorizationRequestResolver(authorizationRequestResolver); - addonsProperties.getClient().getPostLoginRedirectUri().ifPresent(postLoginRedirectUri -> { - oauth2.authorizationRedirectStrategy(new C4Oauth2ServerRedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPreAuthorizationCode())); - oauth2.authenticationSuccessHandler(new C4Oauth2ServerAuthenticationSuccessHandler(addonsProperties, postLoginRedirectUri)); - oauth2.authenticationFailureHandler(new C4Oauth2ServerAuthenticationFailureHandler(addonsProperties, postLoginRedirectUri)); - }); + oauth2.authorizationRedirectStrategy(preAuthorizationCodeRedirectStrategy); + authenticationSuccessHandler.ifPresent(oauth2::authenticationSuccessHandler); + authenticationFailureHandler.ifPresent(oauth2::authenticationFailureHandler); }); http.logout((logout) -> { @@ -244,12 +256,40 @@ ServerLogoutHandler logoutHandler() { new SecurityContextServerLogoutHandler()); } + @ConditionalOnMissingBean + @Bean + PreAuthorizationCodeServerRedirectStrategy preAuthorizationCodeRedirectStrategy(SpringAddonsOidcProperties addonsProperties) { + return new C4PreAuthorizationCodeServerRedirectStrategy( + addonsProperties.getClient().getOauth2Redirections().getPreAuthorizationCode()); + } + + @Conditional(AuthenticationSuccessHandlerCondition.class) + @Bean + ServerAuthenticationSuccessHandler authenticationSuccessHandler(SpringAddonsOidcProperties addonsProperties) { + return new C4Oauth2ServerAuthenticationSuccessHandler(addonsProperties); + } + + @Conditional(AuthenticationFailureHandlerCondition.class) + @Bean + ServerAuthenticationFailureHandler authenticationFailureHandler(SpringAddonsOidcProperties addonsProperties) { + return new C4Oauth2ServerAuthenticationFailureHandler(addonsProperties); + } + + static interface PreAuthorizationCodeServerRedirectStrategy extends ServerRedirectStrategy {} + + static class C4PreAuthorizationCodeServerRedirectStrategy extends C4Oauth2ServerRedirectStrategy implements PreAuthorizationCodeServerRedirectStrategy { + public C4PreAuthorizationCodeServerRedirectStrategy(HttpStatus defaultStatus) { + super(defaultStatus); + } + + } + static class C4Oauth2ServerAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { private final URI redirectUri; private final C4Oauth2ServerRedirectStrategy redirectStrategy; - public C4Oauth2ServerAuthenticationSuccessHandler(SpringAddonsOidcProperties addonsProperties, URI redirectUri) { - this.redirectUri = redirectUri; + public C4Oauth2ServerAuthenticationSuccessHandler(SpringAddonsOidcProperties addonsProperties) { + this.redirectUri = addonsProperties.getClient().getPostLoginRedirectUri().orElse(URI.create("/")); this.redirectStrategy = new C4Oauth2ServerRedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPostAuthorizationCode()); } @@ -264,8 +304,8 @@ static class C4Oauth2ServerAuthenticationFailureHandler implements ServerAuthent private final URI redirectUri; private final C4Oauth2ServerRedirectStrategy redirectStrategy; - public C4Oauth2ServerAuthenticationFailureHandler(SpringAddonsOidcProperties addonsProperties, URI redirectUri) { - this.redirectUri = redirectUri; + public C4Oauth2ServerAuthenticationFailureHandler(SpringAddonsOidcProperties addonsProperties) { + this.redirectUri = addonsProperties.getClient().getPostLoginRedirectUri().orElse(URI.create("/")); this.redirectStrategy = new C4Oauth2ServerRedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPostAuthorizationCode()); } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveIssuerStartsWithAuthenticationManagerResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveIssuerStartsWithAuthenticationManagerResolver.java new file mode 100644 index 000000000..72c28fc28 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveIssuerStartsWithAuthenticationManagerResolver.java @@ -0,0 +1,64 @@ +package com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; +import org.springframework.web.bind.annotation.ResponseStatus; + +import reactor.core.publisher.Mono; + +/** + * Dynamic multi-tenancy based on issuer prefix (for instance, trust all reams from a given Keycloak Server) + * + * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + */ +public class ReactiveIssuerStartsWithAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver { + + private final String issuerPrefix; + private final Converter> authenticationConverter; + private final Map jwtManagers = new ConcurrentHashMap<>(); + + /** + * @param issuerPrefix what access tokens iss claim must start with + * @param authenticationConverter converter from a valid {@link Jwt} to an {@link AbstractAuthenticationToken} instance + */ + public ReactiveIssuerStartsWithAuthenticationManagerResolver( + String issuerPrefix, + Converter> authenticationConverter) { + super(); + this.issuerPrefix = issuerPrefix.toString(); + this.authenticationConverter = authenticationConverter; + } + + @Override + public Mono resolve(String issuer) { + if (!jwtManagers.containsKey(issuer)) { + if (!issuer.startsWith(issuerPrefix)) { + throw new UnknownIssuerException(issuer); + } + final var decoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer).build(); + var provider = new JwtReactiveAuthenticationManager(decoder); + provider.setJwtAuthenticationConverter(authenticationConverter); + jwtManagers.put(issuer, provider::authenticate); + } + return Mono.just(jwtManagers.get(issuer)); + + } + + @ResponseStatus(HttpStatus.UNAUTHORIZED) + static class UnknownIssuerException extends RuntimeException { + private static final long serialVersionUID = 4177339081914400888L; + + public UnknownIssuerException(String issuer) { + super("Unknown issuer: %s".formatted(issuer)); + } + } +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/C4Oauth2RedirectStrategy.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/C4Oauth2RedirectStrategy.java index 3711f5692..c4c90b355 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/C4Oauth2RedirectStrategy.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/C4Oauth2RedirectStrategy.java @@ -6,7 +6,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.security.web.RedirectStrategy; -import org.springframework.util.StringUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -21,25 +20,18 @@ */ @RequiredArgsConstructor public class C4Oauth2RedirectStrategy implements RedirectStrategy { - private final HttpStatus defaultStatus; - - @Override - public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String location) throws IOException { - // @formatter:off - final var status = Optional.ofNullable(request.getHeader("X-RESPONSE-STATUS")) - .filter(StringUtils::hasLength) - .map(statusStr -> { - try { - final var statusCode = Integer.parseInt(statusStr); - return HttpStatus.valueOf(statusCode); - } catch(NumberFormatException e) { - return HttpStatus.valueOf(statusStr.toUpperCase()); - } - }) - .orElse(defaultStatus); - // @formatter:on - response.setStatus(status.value()); - response.setHeader(HttpHeaders.LOCATION, location); - } - -} \ No newline at end of file + public static final String RESPONSE_STATUS_HEADER = "X-RESPONSE-STATUS"; + public static final String RESPONSE_LOCATION_HEADER = "X-RESPONSE-LOCATION"; + + private final HttpStatus defaultStatus; + + @Override + public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException { + final var requestedStatus = request.getIntHeader(RESPONSE_STATUS_HEADER); + response.setStatus(requestedStatus > -1 ? requestedStatus : defaultStatus.value()); + + final var location = Optional.ofNullable(request.getHeader(RESPONSE_LOCATION_HEADER)).orElse(url); + response.setHeader(HttpHeaders.LOCATION, location); + } + +} 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 ccb5672e3..956e12e71 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 @@ -2,6 +2,7 @@ import java.io.IOException; import java.net.URI; +import java.util.Optional; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; @@ -13,6 +14,7 @@ import org.springframework.context.annotation.Conditional; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.Authentication; @@ -20,6 +22,7 @@ import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.web.RedirectStrategy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @@ -31,6 +34,8 @@ import com.c4_soft.springaddons.security.oidc.starter.SpringAddonsOAuth2LogoutRequestUriBuilder; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcClientProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.AuthenticationFailureHandlerCondition; +import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.AuthenticationSuccessHandlerCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.configuration.IsOidcClientCondition; import com.c4_soft.springaddons.security.oidc.starter.synchronised.ServletConfigurationSupport; import com.c4_soft.springaddons.security.oidc.starter.synchronised.SpringAddonsOidcBeans; @@ -72,63 +77,67 @@ @Slf4j public class SpringAddonsOidcClientBeans { - /** - *

- * Instantiated only if "com.c4-soft.springaddons.oidc.client.security-matchers" property has at least one entry. If defined, it is with higher precedence - * than resource server one. - *

- * It defines: - *
    - *
  • If the path to login page was provided in conf, a @Controller must be provided to handle it. Otherwise Spring Boot default generated one is used - * (be aware that it does not work when bound to 80 or 8080 with SSL enabled, so, in that case, use another port or define a login path and a controller to - * handle it)
  • - *
  • logout (using {@link SpringAddonsLogoutSuccessHandler} by default)
  • - *
  • forces SSL usage if it is enabled
  • properties - *
  • CSRF protection as defined in spring-addons client properties (enabled by default in this filter-chain).
  • - *
  • allow access to unauthorized requests to path matchers listed in spring-security client "permit-all" property
  • - *
  • as usual, apply {@link ClientExpressionInterceptUrlRegistryPostProcessor} for access control configuration from Java conf and - * {@link ClientHttpSecurityPostProcessor} to override anything from the auto-configuration listed above
  • - *
- * - * @param http the security filter-chain builder to configure - * @param serverProperties Spring Boot standard server properties - * @param authorizationRequestResolver the authorization request resolver to use. By default {@link SpringAddonsOAuth2AuthorizationRequestResolver} (adds - * authorization request parameters defined in properties and builds absolutes callback URI) - * @param logoutSuccessHandler Defaulted to {@link SpringAddonsLogoutSuccessHandler} which can handle "almost" RP Initiated Logout conformant OPs - * (like Auth0 and Cognito) - * @param addonsProperties {@link SpringAddonsOAuth2ClientProperties spring-addons client properties} - * @param authorizePostProcessor post process authorization after "permit-all" configuration was applied (default is "isAuthenticated()" to - * everything that 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} - * @return a security filter-chain scoped to specified security-matchers and adapted to OAuth2 clients - * @throws Exception in case of miss-configuration - */ - @Order(Ordered.LOWEST_PRECEDENCE - 1) - @Bean - SecurityFilterChain springAddonsClientFilterChain( - HttpSecurity http, - ServerProperties serverProperties, - OAuth2AuthorizationRequestResolver authorizationRequestResolver, - LogoutSuccessHandler logoutSuccessHandler, - SpringAddonsOidcProperties addonsProperties, - ClientExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, - ClientHttpSecurityPostProcessor httpPostProcessor) - throws Exception { - // @formatter:off + /** + *

+ * Instantiated only if "com.c4-soft.springaddons.oidc.client.security-matchers" property has at least one entry. If defined, it is with higher precedence + * than resource server one. + *

+ * It defines: + *
    + *
  • If the path to login page was provided in conf, a @Controller must be provided to handle it. Otherwise Spring Boot default generated one is used + * (be aware that it does not work when bound to 80 or 8080 with SSL enabled, so, in that case, use another port or define a login path and a controller to + * handle it)
  • + *
  • logout (using {@link SpringAddonsLogoutSuccessHandler} by default)
  • + *
  • forces SSL usage if it is enabled
  • properties + *
  • CSRF protection as defined in spring-addons client properties (enabled by default in this filter-chain).
  • + *
  • allow access to unauthorized requests to path matchers listed in spring-security client "permit-all" property
  • + *
  • as usual, apply {@link ClientExpressionInterceptUrlRegistryPostProcessor} for access control configuration from Java conf and + * {@link ClientHttpSecurityPostProcessor} to override anything from the auto-configuration listed above
  • + *
+ * + * @param http the security filter-chain builder to configure + * @param serverProperties Spring Boot standard server properties + * @param authorizationRequestResolver the authorization request resolver to use. By default {@link SpringAddonsOAuth2AuthorizationRequestResolver} (adds + * authorization request parameters defined in properties and builds absolutes callback URI) + * @param preAuthorizationCodeRedirectStrategy the redirection strategy to use for authorization-code request + * @param authenticationSuccessHandler the authentication success handler to use + * @param authenticationFailureHandler the authentication failure handler to use + * @param logoutSuccessHandler Defaulted to {@link SpringAddonsLogoutSuccessHandler} which can handle "almost" RP Initiated Logout conformant OPs (like + * Auth0 and Cognito) + * @param addonsProperties {@link SpringAddonsOAuth2ClientProperties spring-addons client properties} + * @param authorizePostProcessor post process authorization after "permit-all" configuration was applied (default is "isAuthenticated()" to everything that + * 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} + * @return a security filter-chain scoped to specified security-matchers and adapted to OAuth2 clients + * @throws Exception in case of miss-configuration + */ + @Order(Ordered.LOWEST_PRECEDENCE - 1) + @Bean + SecurityFilterChain springAddonsClientFilterChain( + HttpSecurity http, + ServerProperties serverProperties, + PreAuthorizationCodeRedirectStrategy preAuthorizationCodeRedirectStrategy, + OAuth2AuthorizationRequestResolver authorizationRequestResolver, + Optional authenticationSuccessHandler, + Optional authenticationFailureHandler, + LogoutSuccessHandler logoutSuccessHandler, + SpringAddonsOidcProperties addonsProperties, + ClientExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, + ClientHttpSecurityPostProcessor httpPostProcessor) + throws Exception { + // @formatter:off log.info("Applying client OAuth2 configuration for: {}", (Object[]) addonsProperties.getClient().getSecurityMatchers()); http.securityMatcher(addonsProperties.getClient().getSecurityMatchers()); http.oauth2Login(login -> { login.authorizationEndpoint(authorizationEndpoint -> { - authorizationEndpoint.authorizationRedirectStrategy(new C4Oauth2RedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPreAuthorizationCode())); + authorizationEndpoint.authorizationRedirectStrategy(preAuthorizationCodeRedirectStrategy); authorizationEndpoint.authorizationRequestResolver(authorizationRequestResolver); }); addonsProperties.getClient().getLoginPath().ifPresent(login::loginPage); - addonsProperties.getClient().getPostLoginRedirectUri().ifPresent(postLoginRedirectUri -> { - login.successHandler(new C4Oauth2AuthenticationSuccessHandler(addonsProperties, postLoginRedirectUri)); - login.failureHandler(new C4Oauth2AuthenticationFailureHandler(addonsProperties, postLoginRedirectUri)); - }); + authenticationSuccessHandler.ifPresent(login::successHandler); + authenticationFailureHandler.ifPresent(login::failureHandler); }); http.logout(logout -> { @@ -136,112 +145,139 @@ SecurityFilterChain springAddonsClientFilterChain( }); // @formatter:on - ServletConfigurationSupport.configureClient(http, serverProperties, addonsProperties.getClient(), authorizePostProcessor, httpPostProcessor); - - return http.build(); - } - - /** - * Use a {@link SpringAddonsOAuth2AuthorizationRequestResolver} which: - *
    - *
  • takes hostname and port from configuration properties (and works even if SSL is enabled on port 8080)
  • - *
  • spport defining additionl authorization request parameters from properties
  • - *
- * - * @param clientRegistrationRepository - * @param addonsProperties - * @return {@link SpringAddonsOAuth2AuthorizationRequestResolver} - */ - @ConditionalOnMissingBean - @Bean - OAuth2AuthorizationRequestResolver - oAuth2AuthorizationRequestResolver(InMemoryClientRegistrationRepository clientRegistrationRepository, SpringAddonsOidcProperties addonsProperties) { - return new SpringAddonsOAuth2AuthorizationRequestResolver(clientRegistrationRepository, addonsProperties.getClient()); - } - - /** - * Build logout request for RP-Initiated Logout. It works with most OIDC - * provider: those complying with the spec (Keycloak for instance), off course, but also those which are close enough to it (Auth0, Cognito, ...) - * - * @param addonsProperties {@link SpringAddonsOAuth2ClientProperties} to pick logout configuration for divergence to the standard (logout URI not provided - * in .well-known/openid-configuration and non-conform parameter names) - * @return {@link SpringAddonsOAuth2LogoutRequestUriBuilder] - */ - @ConditionalOnMissingBean - @Bean - LogoutRequestUriBuilder logoutRequestUriBuilder(SpringAddonsOidcProperties addonsProperties) { - return new SpringAddonsOAuth2LogoutRequestUriBuilder(addonsProperties.getClient()); - } - - /** - * Single tenant logout handler for OIDC provider complying to RP-Initiated - * Logout (or approximately complying to it like Auth0 or Cognito) - * - * @param logoutRequestUriBuilder delegate doing the smart job - * @param clientRegistrationRepository - * @param addonsProperties - * @return {@link SpringAddonsLogoutSuccessHandler} - */ - @ConditionalOnMissingBean - @Bean - LogoutSuccessHandler logoutSuccessHandler( - LogoutRequestUriBuilder logoutRequestUriBuilder, - ClientRegistrationRepository clientRegistrationRepository, - SpringAddonsOidcProperties addonsProperties) { - return new SpringAddonsLogoutSuccessHandler(logoutRequestUriBuilder, clientRegistrationRepository, addonsProperties); - } - - /** - * @return a Post processor for access control in Java configuration which requires users to be authenticated. It is called after "permit-all" configuration - * property was applied. - */ - @ConditionalOnMissingBean - @Bean - ClientExpressionInterceptUrlRegistryPostProcessor clientAuthorizePostProcessor() { - return registry -> registry.anyRequest().authenticated(); - } - - /** - * @return a no-op post processor - */ - @ConditionalOnMissingBean - @Bean - ClientHttpSecurityPostProcessor clientHttpPostProcessor() { - return http -> http; - } - - static class C4Oauth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler { - private final String redirectUri; - private final C4Oauth2RedirectStrategy redirectStrategy; - - public C4Oauth2AuthenticationSuccessHandler(SpringAddonsOidcProperties addonsProperties, URI redirectUri) { - this.redirectUri = redirectUri.toString(); - this.redirectStrategy = new C4Oauth2RedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPostAuthorizationCode()); - } - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) - throws IOException, - ServletException { - redirectStrategy.sendRedirect(request, response, redirectUri); - - } - } - - static class C4Oauth2AuthenticationFailureHandler implements AuthenticationFailureHandler { - private final String redirectUri; - private final C4Oauth2RedirectStrategy redirectStrategy; - - public C4Oauth2AuthenticationFailureHandler(SpringAddonsOidcProperties addonsProperties, URI redirectUri) { - this.redirectUri = redirectUri.toString(); - this.redirectStrategy = new C4Oauth2RedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPostAuthorizationCode()); - } - - @Override - public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) - throws IOException, - ServletException { - redirectStrategy.sendRedirect(request, response, redirectUri); - } - } -} \ No newline at end of file + ServletConfigurationSupport.configureClient(http, serverProperties, addonsProperties.getClient(), authorizePostProcessor, httpPostProcessor); + + return http.build(); + } + + /** + * Use a {@link SpringAddonsOAuth2AuthorizationRequestResolver} which: + *
    + *
  • takes hostname and port from configuration properties (and works even if SSL is enabled on port 8080)
  • + *
  • spport defining additionl authorization request parameters from properties
  • + *
+ * + * @param clientRegistrationRepository + * @param addonsProperties + * @return {@link SpringAddonsOAuth2AuthorizationRequestResolver} + */ + @ConditionalOnMissingBean + @Bean + OAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver( + InMemoryClientRegistrationRepository clientRegistrationRepository, + SpringAddonsOidcProperties addonsProperties) { + return new SpringAddonsOAuth2AuthorizationRequestResolver(clientRegistrationRepository, addonsProperties.getClient()); + } + + /** + * Build logout request for RP-Initiated Logout. It works with most OIDC + * provider: those complying with the spec (Keycloak for instance), off course, but also those which are close enough to it (Auth0, Cognito, ...) + * + * @param addonsProperties {@link SpringAddonsOAuth2ClientProperties} to pick logout configuration for divergence to the standard (logout URI not provided + * in .well-known/openid-configuration and non-conform parameter names) + * @return {@link SpringAddonsOAuth2LogoutRequestUriBuilder] + */ + @ConditionalOnMissingBean + @Bean + LogoutRequestUriBuilder logoutRequestUriBuilder(SpringAddonsOidcProperties addonsProperties) { + return new SpringAddonsOAuth2LogoutRequestUriBuilder(addonsProperties.getClient()); + } + + /** + * Single tenant logout handler for OIDC provider complying to RP-Initiated + * Logout (or approximately complying to it like Auth0 or Cognito) + * + * @param logoutRequestUriBuilder delegate doing the smart job + * @param clientRegistrationRepository + * @param addonsProperties + * @return {@link SpringAddonsLogoutSuccessHandler} + */ + @ConditionalOnMissingBean + @Bean + LogoutSuccessHandler logoutSuccessHandler( + LogoutRequestUriBuilder logoutRequestUriBuilder, + ClientRegistrationRepository clientRegistrationRepository, + SpringAddonsOidcProperties addonsProperties) { + return new SpringAddonsLogoutSuccessHandler(logoutRequestUriBuilder, clientRegistrationRepository, addonsProperties); + } + + /** + * @return a Post processor for access control in Java configuration which requires users to be authenticated. It is called after "permit-all" configuration + * property was applied. + */ + @ConditionalOnMissingBean + @Bean + ClientExpressionInterceptUrlRegistryPostProcessor clientAuthorizePostProcessor() { + return registry -> registry.anyRequest().authenticated(); + } + + /** + * @return a no-op post processor + */ + @ConditionalOnMissingBean + @Bean + ClientHttpSecurityPostProcessor clientHttpPostProcessor() { + return http -> http; + } + + @ConditionalOnMissingBean + @Bean + PreAuthorizationCodeRedirectStrategy authorizationCodeRedirectStrategy(SpringAddonsOidcProperties addonsProperties) { + return new C4PreAuthorizationCodeRedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPreAuthorizationCode()); + } + + static class C4PreAuthorizationCodeRedirectStrategy extends C4Oauth2RedirectStrategy implements PreAuthorizationCodeRedirectStrategy { + public C4PreAuthorizationCodeRedirectStrategy(HttpStatus defaultStatus) { + super(defaultStatus); + } + } + + @Conditional(AuthenticationSuccessHandlerCondition.class) + @Bean + AuthenticationSuccessHandler authenticationSuccessHandler(SpringAddonsOidcProperties addonsProperties) { + return new C4Oauth2AuthenticationSuccessHandler(addonsProperties); + } + + @Conditional(AuthenticationFailureHandlerCondition.class) + @Bean + AuthenticationFailureHandler authenticationFailureHandler(SpringAddonsOidcProperties addonsProperties) { + return new C4Oauth2AuthenticationFailureHandler(addonsProperties); + } + + static interface PreAuthorizationCodeRedirectStrategy extends RedirectStrategy {} + + static class C4Oauth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler { + private final String redirectUri; + private final C4Oauth2RedirectStrategy redirectStrategy; + + public C4Oauth2AuthenticationSuccessHandler(SpringAddonsOidcProperties addonsProperties) { + this.redirectUri = addonsProperties.getClient().getPostLoginRedirectUri().map(URI::toString).orElse("/"); + this.redirectStrategy = new C4Oauth2RedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPostAuthorizationCode()); + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, + ServletException { + redirectStrategy.sendRedirect(request, response, redirectUri); + + } + } + + static class C4Oauth2AuthenticationFailureHandler implements AuthenticationFailureHandler { + private final String redirectUri; + private final C4Oauth2RedirectStrategy redirectStrategy; + + public C4Oauth2AuthenticationFailureHandler(SpringAddonsOidcProperties addonsProperties) { + this.redirectUri = addonsProperties.getClient().getPostLoginRedirectUri().map(URI::toString).orElse("/"); + this.redirectStrategy = new C4Oauth2RedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPostAuthorizationCode()); + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) + throws IOException, + ServletException { + redirectStrategy.sendRedirect(request, response, redirectUri); + } + } +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/IssuerStartsWithAuthenticationManagerResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/IssuerStartsWithAuthenticationManagerResolver.java new file mode 100644 index 000000000..b71d0fb22 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/IssuerStartsWithAuthenticationManagerResolver.java @@ -0,0 +1,60 @@ +package com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationManagerResolver; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Dynamic multi-tenancy based on issuer prefix (for instance, trust all reams from a given Keycloak Server) + * + * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + */ +public class IssuerStartsWithAuthenticationManagerResolver implements AuthenticationManagerResolver { + + private final String issuerPrefix; + private final Converter authenticationConverter; + private final Map jwtManagers = new ConcurrentHashMap<>(); + + /** + * @param issuerPrefix what access tokens iss claim must start with + * @param authenticationConverter converter from a valid {@link Jwt} to an {@link AbstractAuthenticationToken} instance + */ + public IssuerStartsWithAuthenticationManagerResolver(String issuerPrefix, Converter authenticationConverter) { + super(); + this.issuerPrefix = issuerPrefix.toString(); + this.authenticationConverter = authenticationConverter; + } + + @Override + public AuthenticationManager resolve(String issuer) { + if (!jwtManagers.containsKey(issuer)) { + if (!issuer.startsWith(issuerPrefix)) { + throw new UnknownIssuerException(issuer); + } + final var decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); + var provider = new JwtAuthenticationProvider(decoder); + provider.setJwtAuthenticationConverter(authenticationConverter); + jwtManagers.put(issuer, provider::authenticate); + } + return jwtManagers.get(issuer); + + } + + @ResponseStatus(HttpStatus.UNAUTHORIZED) + static class UnknownIssuerException extends RuntimeException { + private static final long serialVersionUID = -7140122776788781704L; + + public UnknownIssuerException(String issuer) { + super("Unknown issuer: %s".formatted(issuer)); + } + } +}