diff --git a/README.MD b/README.MD index e40d1c39c..52b5e7430 100644 --- a/README.MD +++ b/README.MD @@ -1,26 +1,19 @@ # Ease OAuth2 / OpenID Configuration & Tests in Spring Boot 3 -## What's new in the `8.x` branch +## What's new in `8.0.0` -`8.0.0-RC1`, is out. It is designed to work with Spring Boot `3.4.0-RC1`, Security `6.4.0-RC1`, and Cloud `2024.0.0-M2`. +`8.0.0`, is out. It is designed to work with Spring Boot `3.4.0` (Security `6.4.0` and Cloud `2024.0.0`). -- [`spring-addons-starter-rest`](https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-rest) is gaining in maturity. It can now expose as `@Bean` some `RestClient` and `WebClient` instances (or builders) with the following configured using application properties: +- [`spring-addons-starter-rest`](https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-rest) now expose as `@Bean` some `RestClient` and `WebClient` instances (or builders) with the following configured using application properties: - Base URI - `Basic` or `Bearer` authorization. For the second, with a choice of using an OAuth2 client registration or forwarding the access token in the security context. - Connection & read timeouts - HTTP or SOCKS proxy, with consideration of the standard `HTTP_PROXY` and `NO_PROXY` environment variables (finer-grained configuration can be applied with custom properties) - [`spring-addons-starter-oidc`](https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-oidc) auto-configuration for `oauth2Login` is improved with: - - Working [Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) (at last :/). - - Configurable status for unauthorized requests. The default is still `302 Found` (redirect to login), but it's a snap to change it to `401 Unauthorized` (BFF for single page or mobile applications, stateful REST APIs, ...). -- `OAuthentication` now extends `AbstractOAuth2TokenAuthenticationToken`. This makes integrating with the rest of the Spring Security ecosystem easier but requires its `principal` to implement `OAuth2Token`. Migration guide: - - if using `OpenidClaimSet` directly, wrap it in an `OpenidToken`; if extending it, extend `OpenidToken` instead. - - move the token string argument from the `OAuthentication` constructor to the `principal` one (probably an `OpenidToken`) -```java -new OAuthentication<>(new OpenidClaimSet(claims), authorities, tokenString); -``` -becomes -```java -new OAuthentication<>(new OpenidToken(new OpenidClaimSet(claims), tokenString), authorities); + - Working [Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html), even with cookie-based CSRF protection and `logout+jwt` tokens (which makes it finally usable with [OAuth2 BFF](https://www.baeldung.com/spring-cloud-gateway-bff-oauth2) & [Keycloak](https://www.baeldung.com/spring-boot-keycloak)). + - Configurable status for unauthorized requests. The default is still `302 Found` (redirect to login), but it's a snap to change it to `401 Unauthorized` (like REST APIs should return, even stateful ones). +- `OAuthentication` now extends `AbstractOAuth2TokenAuthenticationToken` for a better integration with the rest of the Spring Security ecosystem. See the [migration guide](https://github.com/ch4mpy/spring-addons/tree/master/migrate-to-8.0.0.md) for details. + ``` ## [`spring-addons-starter-oidc`](https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-oidc) @@ -46,31 +39,53 @@ com: springaddons: rest: client: + # Exposes a RestClient bean named machinClient (or WebClient in a WebFlux app) machin-client: base-url: ${machin-api} authorization: oauth2: + # Authorize outgoing requests with the Bearer token in the security context (possible only in a resource server app) forward-bearer: true + # Exposes a RestClient.Builder bean named biduleClientBuilder (mind the "expose-builder: true") bidule-client: base-url: ${bidule-api} + # Expose the builder instead of an already built client (to fine tune its conf) expose-builder: true authorization: oauth2: + # Authorize outgoing requests with the Bearer token obtained using an OAuth2 client registration oauth2-registration-id: bidule-registration ``` -This exposes two beans that we can auto-wire in `@Component` or `@Configuration`, for instance to generate `@HttpExchange` implementations as follows (mind the `expose-builder: true` for `bidule-client`): +This exposes pre-configured beans that we can auto-wire in any kind of `@Component`, like `@Controller` or `@Service`, or use in `@Configuration`. For instance: ```java @Configuration public class RestConfiguration { + /** + * @param machinClient pre-configured by spring-addons-starter-rest using application properties + * @return a generated implementation of the {@link MachinApi} {@link HttpExchange @HttpExchange}, exposed as a bean named "machinApi". + */ + @Bean + MachinApi machinApi(RestClient machinClient) throws Exception { + return new RestClientHttpExchangeProxyFactoryBean<>(MachinApi.class, machinClient).getObject(); + } + /** + * @param biduleClientBuilder pre-configured using application properties + * @return a {@link RestClient} bean named "biduleClient" + */ @Bean - BiduleApi biduleApi(RestClient.Builder biduleClientBuilder) throws Exception { - return new RestClientHttpExchangeProxyFactoryBean<>(BiduleApi.class, biduleClientBuilder.build()).getObject(); + RestClient biduleClient(RestClient.Builder biduleClientBuilder) throws Exception { + // Fine-tune biduleClientBuilder configuration + return biduleClientBuilder.build(); } + /** + * @param biduleClient the bean exposed just above + * @return a generated implementation of the {@link BiduleApi} {@link HttpExchange @HttpExchange}, exposed as a bean named "biduleApi". + */ @Bean - MachinApi machinApi(RestClient machinClient) throws Exception { - return new RestClientHttpExchangeProxyFactoryBean<>(MachinApi.class, machinClient).getObject(); + BiduleApi biduleApi(RestClient biduleClient) throws Exception { + return new RestClientHttpExchangeProxyFactoryBean<>(BiduleApi.class, biduleClient).getObject(); } } ``` diff --git a/migrate-to-8.0.0.md b/migrate-to-8.0.0.md new file mode 100644 index 000000000..2ac8141d6 --- /dev/null +++ b/migrate-to-8.0.0.md @@ -0,0 +1,32 @@ +# Migrating from `7.x` to `8.x` + +## `spring-addons-starter-oidc` + +The only breaking changes are around `OAuthentication` which now extends `AbstractOAuth2TokenAuthenticationToken` for a better integration with the rest of the Spring Security ecosystem. + +If using `OpenidClaimSet` directly, wrap it in an `OpenidToken`; if extending it, extend `OpenidToken` instead. + +Move the token string argument from the `OAuthentication` constructor to the `principal` one (probably an `OpenidToken`). + +```java +new OAuthentication<>(new OpenidClaimSet(claims), authorities, tokenString); +``` +becomes +```java +new OAuthentication<>(new OpenidToken(new OpenidClaimSet(claims), tokenString), authorities); +``` + +## `spring-addons-starter-rest` + +`SpringAddonsRestClientSupport`, `SpringAddonsWebClientSupport`, and `ReactiveSpringAddonsWebClientSupport` are replaced by `ProxyFactoryBean`s: +- `RestClient` and `WebClient` bean definitions (or the definition of their builders) are registered as bart of the bean registry post processing => remove any explicit bean definition in application conf, the Boot starter does it already. +- change `@HttpExchange` service proxy bean defintions to use `RestClientHttpExchangeProxyFactoryBean` or `WebClientHttpExchangeProxyFactoryBean` + +Proxy properties are now configurable for each client => in YAML, move it down one level (copy it to each client needing proxy configuration). + +There are more configuration options available: +- a flag to expose the client builder inttead of an already built client +- force the bean name (by default, it's the camelCase transformation of the kebab-case client ID in properties, with `Builder` suffix when `expose-builder` is `true`) +- set connect and read timeouts +- expose a `WebClient` instead of the default `RestClient` in a servlet application +- set chunk-size (only applied to `RestClient`) \ No newline at end of file diff --git a/pom.xml b/pom.xml index 040cc0df2..094dde74e 100644 --- a/pom.xml +++ b/pom.xml @@ -1,12 +1,17 @@ 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.0 + com.c4-soft.springaddons spring-addons - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT pom spring-addons - Set of tools I find useful to work with Spring (mostly spring-security for OpenID) + Make Spring developpers' life easier when OAuth2 / OpenID is involved https://github.com/ch4mpy/spring-addons/ @@ -54,15 +59,17 @@ 3.13.0 3.1.2 + 3.2.5 3.4.1 3.6.3 3.0.1 3.5.3 3.3.1 + 1.7.0 3.11.0.3922 - 3.3.4 + ${project.parent.version} 0.2.0 1.6.0.Beta2 @@ -70,9 +77,9 @@ ${env.HOSTNAME} https - 2.5.0 + 2.5.0 1.4 - + paketobuildpacks/builder:tiny ${project.basedir}/bindings/ca-certificates @@ -82,14 +89,6 @@ - - org.springframework.boot - spring-boot-dependencies - ${spring-boot.version} - pom - import - - com.c4-soft.springaddons spring-addons-oauth2 @@ -111,13 +110,13 @@ spring-addons-starter-oidc-test ${project.version} - + com.c4-soft.springaddons spring-addons-starter-rest ${project.version} - + org.springdoc springdoc-openapi-starter-webflux-api @@ -142,7 +141,7 @@ - - + repository.spring.release Spring GA Repository @@ -180,7 +179,7 @@ - - + repository.spring.release Spring GA Repository @@ -221,15 +220,41 @@ + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + ${repackage.classifier} + + ${image.builder} + + + ${ca-certificates.binding}:/platform/bindings/ca-certificates:ro + + + ${java.version} + + + + + org.projectlombok + lombok + + + + + + org.sonatype.plugins nexus-staging-maven-plugin - 1.6.13 + ${nexus-staging-maven-plugin.version} org.apache.maven.plugins maven-gpg-plugin - 3.1.0 + ${maven-gpg-plugin.version} org.apache.maven.plugins @@ -296,7 +321,8 @@ - + org.apache.maven.plugins maven-javadoc-plugin @@ -353,7 +379,7 @@ spring-addons-starter-oidc-test spring-addons-starter-rest spring-addons-starter-openapi - starters + spring-addons-starter-recaptcha @@ -370,7 +396,8 @@ gpg - + --pinentry-mode loopback @@ -394,7 +421,7 @@ spring-addons-starter-oidc-test spring-addons-starter-rest spring-addons-starter-openapi - starters + spring-addons-starter-recaptcha samples diff --git a/release-notes.md b/release-notes.md index 819d7b001..366708c68 100644 --- a/release-notes.md +++ b/release-notes.md @@ -5,7 +5,7 @@ For Spring Boot 3.4.x. `spring-addons-starter-rest` provides auto-configuration for `RestClient`, `WebClient` and tooling for `@HttpExchange` proxy generation. -### `8.0.0-RC1` +### `8.0.0` - `spring-addons-starter-oidc`: - **[Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html)** support. Enabled only if an `OidcBackChannel(Server)LogoutHandler` bean is present. A default `OidcBackChannel(Server)LogoutHandler` bean is provided if `com.c4-soft.springaddons.oidc.client.back-channel-logout.enabled` property is `true` (`false` by default). - `authenticationEntryPoint` is now configurable with spring-addons for OAuth2 clients with `oauth2Login` (instead of `oauth2ResourceServer`). The default bean returns `302 redirect to login` unless another status is set with other OAuth2 responses statuses overrides in properties. Resource servers authentication entrypoint returns `401`. @@ -16,7 +16,7 @@ For Spring Boot 3.4.x. - HTTP proxy. Supports `HTTP_PROXY` & `NO_PROXY` environment variables, but finer grained custom properties can be used. - connect and read timeouts. - force usage of `WebClient` in a servlet app (`RestClient` is the default for servlets). -- Boot `3.4.0-RC1` and Security `6.4.0-RC1` as transitive dependencies (adapts to the new Back-Channel Logout configuration). +- Boot `3.4.0` (with Security `6.4.0` as transitive dependencies). ## `7.x` Branch diff --git a/samples/pom.xml b/samples/pom.xml index 93eb8b568..59d692417 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons spring-addons - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. com.c4-soft.springaddons.samples @@ -13,7 +13,7 @@ 17 - 2023.0.2 + 2024.0.0-M2 2.2.19 @@ -40,13 +40,6 @@ - - org.springframework.boot - spring-boot-dependencies - ${spring-boot.version} - pom - import - org.springframework.cloud spring-cloud-dependencies @@ -117,32 +110,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - ${spring-boot.version} - - ${repackage.classifier} - - ${image.builder} - - - ${ca-certificates.binding}:/platform/bindings/ca-certificates:ro - - - ${java.version} - - - - - org.projectlombok - lombok - - - - - - diff --git a/samples/release.properties b/samples/release.properties deleted file mode 100644 index d7087299f..000000000 --- a/samples/release.properties +++ /dev/null @@ -1,35 +0,0 @@ -#release configuration -#Thu Apr 25 14:23:36 TAHT 2024 -projectVersionPolicyId=default -project.scm.com.c4-soft.springaddons.samples.tutorials\:resource-server_with_oauthentication.empty=true -scm.branchCommitComment=@{prefix} prepare branch @{releaseLabel} -pinExternals=false -project.scm.com.c4-soft.springaddons.samples.tutorials\:resource-server_multitenant_dynamic.empty=true -project.scm.com.c4-soft.springaddons.samples.tutorials\:servlet-resource-server.empty=true -project.scm.com.c4-soft.springaddons.samples.tutorials\:resource-server_with_ui.empty=true -projectVersionPolicyConfig=${projectVersionPolicyConfig}\n -pushChanges=true -project.scm.com.c4-soft.springaddons.samples\:webmvc-jwt-default-jpa-authorities.empty=true -project.scm.com.c4-soft.springaddons.samples.tutorials\:servlet-client.empty=true -scm.rollbackCommitComment=@{prefix} rollback the release of @{releaseLabel} -remoteTagging=true -project.scm.com.c4-soft.springaddons.samples\:enum-bug-reproducer-reactive.empty=true -scm.commentPrefix=[maven-release-plugin] -releaseStrategyId=default -project.scm.com.c4-soft.springaddons.samples.tutorials\:resource-server_with_introspection.empty=true -project.scm.com.c4-soft.springaddons.samples.tutorials\:resource-server_with_specialized_oauthentication.empty=true -project.scm.com.c4-soft.springaddons.samples.tutorials\:reactive-resource-server.empty=true -project.scm.com.c4-soft.springaddons.samples\:webmvc-jwt-default.empty=true -completedPhase=scm-check-modifications -scm.url=scm\:git\:git@github.com\:ch4mpy/spring-addons.git/spring-addons-samples/webmvc-jwt-default -project.scm.com.c4-soft.springaddons.samples.tutorials\:resource-server_with_additional-header.empty=true -project.scm.com.c4-soft.springaddons.samples\:webmvc-jwt-oauthentication.empty=true -scm.developmentCommitComment=@{prefix} prepare for next development iteration -scm.tagNameFormat=@{project.artifactId}-@{project.version} -project.scm.com.c4-soft.springaddons.samples.tutorials\:reactive-client.empty=true -project.scm.com.c4-soft.springaddons.samples\:enum-bug-reproducer-servlet.empty=true -project.scm.com.c4-soft.springaddons.samples.tutorials\:tutorials.empty=true -exec.snapshotReleasePluginAllowed=false -preparationGoals=clean verify -scm.releaseCommitComment=@{prefix} prepare release @{releaseLabel} -exec.pomFileName=webmvc-jwt-default\\pom.xml diff --git a/samples/tutorials/pom.xml b/samples/tutorials/pom.xml index b5c979424..445b27c9a 100644 --- a/samples/tutorials/pom.xml +++ b/samples/tutorials/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples spring-addons-samples - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. com.c4-soft.springaddons.samples.tutorials diff --git a/samples/tutorials/reactive-client/pom.xml b/samples/tutorials/reactive-client/pom.xml index d9c03cf90..363c3409c 100644 --- a/samples/tutorials/reactive-client/pom.xml +++ b/samples/tutorials/reactive-client/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples.tutorials tutorials - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. reactive-client @@ -73,14 +73,6 @@ - - org.graalvm.buildtools - native-maven-plugin - - - org.springframework.boot - spring-boot-maven-plugin - diff --git a/samples/tutorials/reactive-resource-server/pom.xml b/samples/tutorials/reactive-resource-server/pom.xml index 342509537..66e7462ff 100644 --- a/samples/tutorials/reactive-resource-server/pom.xml +++ b/samples/tutorials/reactive-resource-server/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples.tutorials tutorials - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. reactive-resource-server @@ -58,14 +58,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - diff --git a/samples/tutorials/resource-server_multitenant_dynamic/pom.xml b/samples/tutorials/resource-server_multitenant_dynamic/pom.xml index c194947a8..878b4e45d 100644 --- a/samples/tutorials/resource-server_multitenant_dynamic/pom.xml +++ b/samples/tutorials/resource-server_multitenant_dynamic/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples.tutorials tutorials - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. resource-server_multitenant_dynamic @@ -61,14 +61,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - diff --git a/samples/tutorials/resource-server_with_additional-header/pom.xml b/samples/tutorials/resource-server_with_additional-header/pom.xml index 299c6267f..ed3f4ea41 100644 --- a/samples/tutorials/resource-server_with_additional-header/pom.xml +++ b/samples/tutorials/resource-server_with_additional-header/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples.tutorials tutorials - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. resource-server_with_additional-header @@ -58,14 +58,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - diff --git a/samples/tutorials/resource-server_with_additional-header/src/main/java/com/c4soft/springaddons/tutorials/GreetingController.java b/samples/tutorials/resource-server_with_additional-header/src/main/java/com/c4soft/springaddons/tutorials/GreetingController.java index c090b5092..71aa96314 100644 --- a/samples/tutorials/resource-server_with_additional-header/src/main/java/com/c4soft/springaddons/tutorials/GreetingController.java +++ b/samples/tutorials/resource-server_with_additional-header/src/main/java/com/c4soft/springaddons/tutorials/GreetingController.java @@ -3,21 +3,20 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; - import com.c4soft.springaddons.tutorials.SecurityConfig.MyAuth; @RestController @PreAuthorize("isAuthenticated()") public class GreetingController { - @GetMapping("/greet") - public MessageDto getGreeting(MyAuth auth) { - return new MessageDto( - "Hi %s! You are granted with: %s.".formatted( - auth.getIdClaims().getEmail(), // From ID token in X-ID-Token header - auth.getAuthorities())); // From access token in Authorization header - } + @GetMapping("/greet") + public MessageDto getGreeting(MyAuth auth) { + // email From ID token in X-ID-Token header + // Authorities from access token in Authorization header + return new MessageDto("Hi %s! You are granted with: %s.".formatted(auth.getIdToken().getEmail(), + auth.getAuthorities())); + } - static record MessageDto(String body) { - } + static record MessageDto(String body) { + } } diff --git a/samples/tutorials/resource-server_with_additional-header/src/main/java/com/c4soft/springaddons/tutorials/SecurityConfig.java b/samples/tutorials/resource-server_with_additional-header/src/main/java/com/c4soft/springaddons/tutorials/SecurityConfig.java index 1df0d37d8..bec4c9e70 100644 --- a/samples/tutorials/resource-server_with_additional-header/src/main/java/com/c4soft/springaddons/tutorials/SecurityConfig.java +++ b/samples/tutorials/resource-server_with_additional-header/src/main/java/com/c4soft/springaddons/tutorials/SecurityConfig.java @@ -4,7 +4,6 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.converter.Converter; @@ -18,86 +17,77 @@ import org.springframework.security.oauth2.jwt.JwtDecoders; import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; import com.c4_soft.springaddons.security.oidc.starter.synchronised.HttpServletRequestSupport; import com.c4_soft.springaddons.security.oidc.starter.synchronised.HttpServletRequestSupport.InvalidHeaderException; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.JwtAbstractAuthenticationTokenConverter; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.ResourceServerExpressionInterceptUrlRegistryPostProcessor; - import lombok.Data; import lombok.EqualsAndHashCode; @Configuration @EnableMethodSecurity public class SecurityConfig { - public static final String ID_TOKEN_HEADER_NAME = "X-ID-Token"; - private static final Map idTokenDecoders = new ConcurrentHashMap<>(); + public static final String ID_TOKEN_HEADER_NAME = "X-ID-Token"; + private static final Map idTokenDecoders = new ConcurrentHashMap<>(); - private JwtDecoder getJwtDecoder(Map accessClaims) { - if (accessClaims == null) { - return null; - } - final var iss = Optional.ofNullable(accessClaims.get(JwtClaimNames.ISS)).map(Object::toString).orElse(null); - if (iss == null) { - return null; - } - if (!idTokenDecoders.containsKey(iss)) { - idTokenDecoders.put(iss, JwtDecoders.fromIssuerLocation(iss)); - } - return idTokenDecoders.get(iss); - } + private JwtDecoder getJwtDecoder(Map accessClaims) { + if (accessClaims == null) { + return null; + } + final var iss = + Optional.ofNullable(accessClaims.get(JwtClaimNames.ISS)).map(Object::toString).orElse(null); + if (iss == null) { + return null; + } + if (!idTokenDecoders.containsKey(iss)) { + idTokenDecoders.put(iss, JwtDecoders.fromIssuerLocation(iss)); + } + return idTokenDecoders.get(iss); + } - @Bean - JwtAbstractAuthenticationTokenConverter - authenticationConverter(Converter, Collection> authoritiesConverter) { - return jwt -> { - try { - final var jwtDecoder = getJwtDecoder(jwt.getClaims()); - final var authorities = authoritiesConverter.convert(jwt.getClaims()); - final var idTokenString = HttpServletRequestSupport.getUniqueRequestHeader(ID_TOKEN_HEADER_NAME); - final var idToken = jwtDecoder == null ? null : jwtDecoder.decode(idTokenString); + @Bean + JwtAbstractAuthenticationTokenConverter authenticationConverter( + Converter, Collection> authoritiesConverter) { + return jwt -> { + try { + final var jwtDecoder = getJwtDecoder(jwt.getClaims()); + final var authorities = authoritiesConverter.convert(jwt.getClaims()); + final var idTokenString = + HttpServletRequestSupport.getUniqueRequestHeader(ID_TOKEN_HEADER_NAME); + final var idToken = jwtDecoder == null ? null : jwtDecoder.decode(idTokenString); - return new MyAuth( - authorities, - jwt.getTokenValue(), - new OpenidClaimSet(jwt.getClaims()), - idTokenString, - new OpenidClaimSet(idToken.getClaims())); - } catch (JwtException e) { - throw new InvalidHeaderException(ID_TOKEN_HEADER_NAME); - } - }; - } + return new MyAuth(authorities, jwt.getTokenValue(), new OpenidClaimSet(jwt.getClaims()), + idTokenString, new OpenidClaimSet(idToken.getClaims())); + } catch (JwtException e) { + throw new InvalidHeaderException(ID_TOKEN_HEADER_NAME); + } + }; + } - @Bean - ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { - // @formatter:off + @Bean + ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { + // @formatter:off return (AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry) -> registry .requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/actuator/**")).hasAuthority("OBSERVABILITY:read") .requestMatchers(new AntPathRequestMatcher("/actuator/**")).hasAuthority("OBSERVABILITY:write") .anyRequest().authenticated(); // @formatter:on - } + } - @Data - @EqualsAndHashCode(callSuper = true) - public static class MyAuth extends OAuthentication { - private static final long serialVersionUID = 1734079415899000362L; - private final String idTokenString; - private final OpenidClaimSet idClaims; + @Data + @EqualsAndHashCode(callSuper = true) + public static class MyAuth extends OAuthentication { + private static final long serialVersionUID = 1734079415899000362L; + private final OpenidToken idToken; - public MyAuth( - Collection authorities, - String accessTokenString, - OpenidClaimSet accessClaims, - String idTokenString, - OpenidClaimSet idClaims) { - super(accessClaims, authorities, accessTokenString); - this.idTokenString = idTokenString; - this.idClaims = idClaims; - } + public MyAuth(Collection authorities, String accessTokenString, + OpenidClaimSet accessClaims, String idTokenString, OpenidClaimSet idClaims) { + super(new OpenidToken(accessClaims, accessTokenString), authorities); + this.idToken = new OpenidToken(idClaims, idTokenString); + } - } -} \ No newline at end of file + } +} diff --git a/samples/tutorials/resource-server_with_introspection/pom.xml b/samples/tutorials/resource-server_with_introspection/pom.xml index c3126ac15..2d2bd41cc 100644 --- a/samples/tutorials/resource-server_with_introspection/pom.xml +++ b/samples/tutorials/resource-server_with_introspection/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples.tutorials tutorials - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. resource-server_with_introspection @@ -53,14 +53,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - diff --git a/samples/tutorials/resource-server_with_introspection/src/main/java/com/c4soft/springaddons/tutorials/WebSecurityConfig.java b/samples/tutorials/resource-server_with_introspection/src/main/java/com/c4soft/springaddons/tutorials/WebSecurityConfig.java index 22a9ffd2b..cc157ec70 100644 --- a/samples/tutorials/resource-server_with_introspection/src/main/java/com/c4soft/springaddons/tutorials/WebSecurityConfig.java +++ b/samples/tutorials/resource-server_with_introspection/src/main/java/com/c4soft/springaddons/tutorials/WebSecurityConfig.java @@ -5,7 +5,6 @@ import java.util.Collection; import java.util.List; import java.util.Map; - import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -22,46 +21,49 @@ import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; @Configuration @EnableMethodSecurity public class WebSecurityConfig { - @Bean - @Profile("oauthentication") - // This bean is optional as a default one is provided (building a - // BearerAuthenticationToken) - OpaqueTokenAuthenticationConverter - introspectionAuthenticationConverter(Converter, Collection> authoritiesConverter) { - return (String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> new OAuthentication<>( - new OpenidClaimSet(authenticatedPrincipal.getAttributes()), - authoritiesConverter.convert(authenticatedPrincipal.getAttributes()), - introspectedToken); - } + @Bean + @Profile("oauthentication") + // This bean is optional as a default one is provided (building a + // BearerAuthenticationToken) + OpaqueTokenAuthenticationConverter introspectionAuthenticationConverter( + Converter, Collection> authoritiesConverter) { + return (String introspectedToken, + OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> new OAuthentication<>( + new OpenidToken(new OpenidClaimSet(authenticatedPrincipal.getAttributes()), + introspectedToken), + authoritiesConverter.convert(authenticatedPrincipal.getAttributes())); + } - @Component - @Profile("auth0 | cognito") - public static class UserEndpointOpaqueTokenIntrospector implements OpaqueTokenIntrospector { - private final URI userinfoUri; - private final RestTemplate restClient = new RestTemplate(); + @Component + @Profile("auth0 | cognito") + public static class UserEndpointOpaqueTokenIntrospector implements OpaqueTokenIntrospector { + private final URI userinfoUri; + private final RestTemplate restClient = new RestTemplate(); - public UserEndpointOpaqueTokenIntrospector(OAuth2ResourceServerProperties oauth2Properties) throws IOException { - userinfoUri = URI.create(oauth2Properties.getOpaquetoken().getIntrospectionUri()); - } + public UserEndpointOpaqueTokenIntrospector(OAuth2ResourceServerProperties oauth2Properties) + throws IOException { + userinfoUri = URI.create(oauth2Properties.getOpaquetoken().getIntrospectionUri()); + } - @Override - @SuppressWarnings("unchecked") - public OAuth2AuthenticatedPrincipal introspect(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(token); - final var claims = new OpenidClaimSet(restClient.exchange(userinfoUri, HttpMethod.GET, new HttpEntity<>(headers), Map.class).getBody()); - // No need to map authorities there, it is done later by - // OpaqueTokenAuthenticationConverter - return new OAuth2IntrospectionAuthenticatedPrincipal(claims, List.of()); - } + @Override + @SuppressWarnings("unchecked") + public OAuth2AuthenticatedPrincipal introspect(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + final var claims = new OpenidClaimSet(restClient + .exchange(userinfoUri, HttpMethod.GET, new HttpEntity<>(headers), Map.class).getBody()); + // No need to map authorities there, it is done later by + // OpaqueTokenAuthenticationConverter + return new OAuth2IntrospectionAuthenticatedPrincipal(claims, List.of()); + } - } -} \ No newline at end of file + } +} diff --git a/samples/tutorials/resource-server_with_oauthentication/pom.xml b/samples/tutorials/resource-server_with_oauthentication/pom.xml index 2a6f0bd29..02e44d946 100644 --- a/samples/tutorials/resource-server_with_oauthentication/pom.xml +++ b/samples/tutorials/resource-server_with_oauthentication/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples.tutorials tutorials - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. resource-server_with_oauthentication @@ -56,14 +56,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - diff --git a/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/GreetingController.java b/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/GreetingController.java index 47376ba6f..95114a55b 100644 --- a/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/GreetingController.java +++ b/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/GreetingController.java @@ -3,26 +3,26 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; - import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; @RestController @PreAuthorize("isAuthenticated()") public class GreetingController { - @GetMapping("/greet") - public MessageDto getGreeting(OAuthentication auth) { - return new MessageDto( - "Hi %s! You are granted with: %s and your email is %s.".formatted(auth.getName(), auth.getAuthorities(), auth.getClaims().getEmail())); - } + @GetMapping("/greet") + public MessageDto getGreeting(OAuthentication auth) { + return new MessageDto("Hi %s! You are granted with: %s and your email is %s." + .formatted(auth.getName(), auth.getAuthorities(), auth.getClaims().getEmail())); + } - @GetMapping("/nice") - @PreAuthorize("hasAuthority('NICE')") - public MessageDto getNiceGreeting(OAuthentication auth) { - return new MessageDto("Dear %s! You are granted with: %s.".formatted(auth.getName(), auth.getAuthorities())); - } + @GetMapping("/nice") + @PreAuthorize("hasAuthority('NICE')") + public MessageDto getNiceGreeting(OAuthentication auth) { + return new MessageDto( + "Dear %s! You are granted with: %s.".formatted(auth.getName(), auth.getAuthorities())); + } - static record MessageDto(String body) { - } + static record MessageDto(String body) { + } } diff --git a/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithOAuthenticationApplication.java b/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithOAuthenticationApplication.java index 266cc88ec..fd5861b58 100644 --- a/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithOAuthenticationApplication.java +++ b/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithOAuthenticationApplication.java @@ -2,7 +2,6 @@ import java.util.Collection; import java.util.Map; - import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @@ -14,56 +13,58 @@ import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.JwtAbstractAuthenticationTokenConverter; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.ResourceServerExpressionInterceptUrlRegistryPostProcessor; - import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.security.OAuthFlow; import io.swagger.v3.oas.annotations.security.OAuthFlows; import io.swagger.v3.oas.annotations.security.OAuthScope; import io.swagger.v3.oas.annotations.security.SecurityScheme; -@SecurityScheme(name = "authorization-code", type = SecuritySchemeType.OAUTH2, flows = @OAuthFlows(authorizationCode = @OAuthFlow(authorizationUrl = "https://oidc.c4-soft.com/auth/realms/master/protocol/openid-connect/auth", tokenUrl = "https://oidc.c4-soft.com/auth/realms/master/protocol/openid-connect/token", scopes = { - @OAuthScope(name = "openid"), - @OAuthScope(name = "profile") }))) +@SecurityScheme(name = "authorization-code", type = SecuritySchemeType.OAUTH2, + flows = @OAuthFlows(authorizationCode = @OAuthFlow( + authorizationUrl = "https://oidc.c4-soft.com/auth/realms/master/protocol/openid-connect/auth", + tokenUrl = "https://oidc.c4-soft.com/auth/realms/master/protocol/openid-connect/token", + scopes = {@OAuthScope(name = "openid"), @OAuthScope(name = "profile")}))) @SpringBootApplication public class ResourceServerWithOAuthenticationApplication { - public static void main(String[] args) { - SpringApplication.run(ResourceServerWithOAuthenticationApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(ResourceServerWithOAuthenticationApplication.class, args); + } - @Configuration - @EnableMethodSecurity - public static class SecurityConfig { - @Bean - JwtAbstractAuthenticationTokenConverter authenticationConverter( - Converter, Collection> authoritiesConverter, - OpenidProviderPropertiesResolver opPropertiesResolver) { - return jwt -> { - final var opProperties = opPropertiesResolver - .resolve(jwt.getClaims()) - .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())); - final var claims = new OpenidClaimSet(jwt.getClaims(), opProperties.getUsernameClaim()); - final var authorities = authoritiesConverter.convert(jwt.getClaims()); - return new OAuthentication<>(claims, authorities, jwt.getTokenValue()); - }; - } + @Configuration + @EnableMethodSecurity + public static class SecurityConfig { + @Bean + JwtAbstractAuthenticationTokenConverter authenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return jwt -> { + final var opProperties = opPropertiesResolver.resolve(jwt.getClaims()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())); + final var accessToken = + new OpenidToken(new OpenidClaimSet(jwt.getClaims(), opProperties.getUsernameClaim()), + jwt.getTokenValue()); + final var authorities = authoritiesConverter.convert(jwt.getClaims()); + return new OAuthentication<>(accessToken, authorities); + }; + } - @Bean - ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { - // @formatter:off + @Bean + ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { + // @formatter:off return (AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry) -> registry .requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/actuator/**")).hasAuthority("OBSERVABILITY:read") .requestMatchers(new AntPathRequestMatcher("/actuator/**")).hasAuthority("OBSERVABILITY:write") .anyRequest().authenticated(); // @formatter:on - } } + } } diff --git a/samples/tutorials/resource-server_with_oauthentication/src/test/java/com/c4soft/springaddons/tutorials/GreetingControllerTest.java b/samples/tutorials/resource-server_with_oauthentication/src/test/java/com/c4soft/springaddons/tutorials/GreetingControllerTest.java index 533153208..c1e3b2441 100644 --- a/samples/tutorials/resource-server_with_oauthentication/src/test/java/com/c4soft/springaddons/tutorials/GreetingControllerTest.java +++ b/samples/tutorials/resource-server_with_oauthentication/src/test/java/com/c4soft/springaddons/tutorials/GreetingControllerTest.java @@ -3,9 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - import java.util.stream.Stream; - import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -15,13 +13,12 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.test.context.support.WithAnonymousUser; - import com.c4_soft.springaddons.security.oauth2.test.annotations.WithJwt; import com.c4_soft.springaddons.security.oauth2.test.annotations.parameterized.ParameterizedAuthentication; import com.c4_soft.springaddons.security.oauth2.test.webmvc.AutoConfigureAddonsWebmvcResourceServerSecurity; import com.c4_soft.springaddons.security.oauth2.test.webmvc.MockMvcSupport; import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; import com.c4soft.springaddons.tutorials.ResourceServerWithOAuthenticationApplication.SecurityConfig; @WebMvcTest(controllers = GreetingController.class) @@ -29,54 +26,57 @@ @Import(SecurityConfig.class) class GreetingControllerTest { - @Autowired - MockMvcSupport api; + @Autowired + MockMvcSupport api; - @Autowired - WithJwt.AuthenticationFactory jwtAuthFactory; + @Autowired + WithJwt.AuthenticationFactory jwtAuthFactory; - @ParameterizedTest - @MethodSource("auth0users") // see below for the factory - void givenUserIsAuthenticated_whenGreet_thenOk(@ParameterizedAuthentication Authentication auth) throws Exception { - @SuppressWarnings("unchecked") - final var oauth = (OAuthentication) auth; - final var actual = api.get("/greet").andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); - assertThat(actual).contains( - "Hi %s! You are granted with: %s and your email is %s.".formatted(auth.getName(), auth.getAuthorities(), oauth.getAttributes().getEmail())); - } + @ParameterizedTest + @MethodSource("auth0users") // see below for the factory + void givenUserIsAuthenticated_whenGreet_thenOk(@ParameterizedAuthentication Authentication auth) + throws Exception { + @SuppressWarnings("unchecked") + final var oauth = (OAuthentication) auth; + final var actual = + api.get("/greet").andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + assertThat(actual).contains("Hi %s! You are granted with: %s and your email is %s." + .formatted(auth.getName(), auth.getAuthorities(), oauth.getAttributes().getEmail())); + } - @Test - @WithAnonymousUser - void givenRequestIsAnonymous_whenGreet_thenUnauthorized() throws Exception { - api.get("/greet").andExpect(status().isUnauthorized()); - } + @Test + @WithAnonymousUser + void givenRequestIsAnonymous_whenGreet_thenUnauthorized() throws Exception { + api.get("/greet").andExpect(status().isUnauthorized()); + } - @Test - @WithJwt("auth0_nice.json") - void givenUserIsGrantedWithNice_whenGetNice_thenOk() throws Exception { - api.get("/nice").andExpect(status().isOk()).andExpect(jsonPath("$.body").value("Dear ch4mp! You are granted with: [USER_ROLES_EDITOR, NICE, AUTHOR].")); - } + @Test + @WithJwt("auth0_nice.json") + void givenUserIsGrantedWithNice_whenGetNice_thenOk() throws Exception { + api.get("/nice").andExpect(status().isOk()).andExpect(jsonPath("$.body") + .value("Dear ch4mp! You are granted with: [USER_ROLES_EDITOR, NICE, AUTHOR].")); + } - @Test - @WithJwt("auth0_badboy.json") - void givenUserIsNotGrantedWithNice_whenGetNice_thenForbidden() throws Exception { - api.get("/nice").andExpect(status().isForbidden()); - } + @Test + @WithJwt("auth0_badboy.json") + void givenUserIsNotGrantedWithNice_whenGetNice_thenForbidden() throws Exception { + api.get("/nice").andExpect(status().isForbidden()); + } - @Test - @WithAnonymousUser - void givenRequestIsAnonymous_whenGetNice_thenUnauthorized() throws Exception { - api.get("/nice").andExpect(status().isUnauthorized()); - } + @Test + @WithAnonymousUser + void givenRequestIsAnonymous_whenGetNice_thenUnauthorized() throws Exception { + api.get("/nice").andExpect(status().isUnauthorized()); + } - /** - * @MethodSource for @ParameterizedTest - * - * @return a stream of {@link OAuthentication OAuthentication<OpenidClaimSet>} as defined by the Authentication converter in the security - * configuration - */ - Stream auth0users() { - return jwtAuthFactory.authenticationsFrom("auth0_nice.json", "auth0_badboy.json"); - } + /** + * @MethodSource for @ParameterizedTest + * + * @return a stream of {@link OAuthentication OAuthentication<OpenidClaimSet>} as defined by + * the Authentication converter in the security configuration + */ + Stream auth0users() { + return jwtAuthFactory.authenticationsFrom("auth0_nice.json", "auth0_badboy.json"); + } } diff --git a/samples/tutorials/resource-server_with_specialized_oauthentication/pom.xml b/samples/tutorials/resource-server_with_specialized_oauthentication/pom.xml index f1f285d2f..a70a32f8d 100644 --- a/samples/tutorials/resource-server_with_specialized_oauthentication/pom.xml +++ b/samples/tutorials/resource-server_with_specialized_oauthentication/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples.tutorials tutorials - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. resource-server_with_specialized_oauthentication @@ -61,18 +61,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - - diff --git a/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ProxiesAuthentication.java b/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ProxiesAuthentication.java index 72993283b..4d58c5751 100644 --- a/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ProxiesAuthentication.java +++ b/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ProxiesAuthentication.java @@ -2,35 +2,27 @@ import java.util.Collection; import java.util.Objects; -import java.util.Optional; - import org.springframework.security.core.GrantedAuthority; - import com.c4_soft.springaddons.security.oidc.OAuthentication; - import lombok.Data; import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) -public class ProxiesAuthentication extends OAuthentication { - private static final long serialVersionUID = -6247121748050239792L; - - public ProxiesAuthentication(ProxiesClaimSet claims, Collection authorities, String tokenString) { - super(claims, authorities, tokenString); - } +public class ProxiesAuthentication extends OAuthentication { + private static final long serialVersionUID = 447991554788295331L; - @Override - public String getName() { - return Optional.ofNullable(super.getAttributes().getPreferredUsername()).orElse(super.getAttributes().getSubject()); - } + public ProxiesAuthentication(ProxiesToken claims, + Collection authorities) { + super(claims, authorities); + } - public boolean hasName(String username) { - return Objects.equals(getName(), username); - } + public boolean hasName(String username) { + return Objects.equals(getName(), username); + } - public Proxy getProxyFor(String username) { - return getAttributes().getProxyFor(username); - } + public Proxy getProxyFor(String username) { + return getAttributes().getProxyFor(username); + } } diff --git a/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ProxiesClaimSet.java b/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ProxiesClaimSet.java deleted file mode 100644 index 0d3f77bda..000000000 --- a/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ProxiesClaimSet.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.c4soft.springaddons.tutorials; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -import org.springframework.core.convert.converter.Converter; - -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - -import lombok.Data; -import lombok.EqualsAndHashCode; - -@Data -@EqualsAndHashCode(callSuper = true) -public class ProxiesClaimSet extends OpenidClaimSet { - private static final long serialVersionUID = 38784488788537111L; - - private final Map proxies; - - public ProxiesClaimSet(Map claims) { - super(claims); - this.proxies = Collections.unmodifiableMap(Optional.ofNullable(proxiesConverter.convert(this)).orElse(Map.of())); - } - - public Proxy getProxyFor(String username) { - return proxies.getOrDefault(username, new Proxy(username, getName(), List.of())); - } - - private static final Converter> proxiesConverter = claims -> { - @SuppressWarnings("unchecked") - final var proxiesClaim = (Map>) claims.get("proxies"); - if (proxiesClaim == null) { - return Map.of(); - } - return proxiesClaim.entrySet().stream().map(e -> new Proxy(e.getKey(), claims.getPreferredUsername(), e.getValue())) - .collect(Collectors.toMap(Proxy::getProxiedUsername, p -> p)); - }; -} diff --git a/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ProxiesToken.java b/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ProxiesToken.java new file mode 100644 index 000000000..e1f66e609 --- /dev/null +++ b/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ProxiesToken.java @@ -0,0 +1,42 @@ +package com.c4soft.springaddons.tutorials; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.oauth2.core.oidc.StandardClaimNames; +import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class ProxiesToken extends OpenidToken { + private static final long serialVersionUID = 2859979941152449048L; + + private final Map proxies; + + public ProxiesToken(Map claims, String tokenValue) { + super(claims, StandardClaimNames.PREFERRED_USERNAME, tokenValue); + this.proxies = Collections + .unmodifiableMap(Optional.ofNullable(proxiesConverter.convert(this)).orElse(Map.of())); + } + + public Proxy getProxyFor(String username) { + return proxies.getOrDefault(username, new Proxy(username, getName(), List.of())); + } + + private static final Converter> proxiesConverter = claims -> { + @SuppressWarnings("unchecked") + final var proxiesClaim = (Map>) claims.get("proxies"); + if (proxiesClaim == null) { + return Map.of(); + } + return proxiesClaim.entrySet().stream() + .map(e -> new Proxy(e.getKey(), claims.getPreferredUsername(), e.getValue())) + .collect(Collectors.toMap(Proxy::getProxiedUsername, p -> p)); + }; +} diff --git a/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/SecurityConfig.java b/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/SecurityConfig.java index ac815b19c..22c71b970 100644 --- a/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/SecurityConfig.java +++ b/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/SecurityConfig.java @@ -4,14 +4,12 @@ import java.util.List; import java.util.Map; import java.util.Objects; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.converter.Converter; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.core.GrantedAuthority; - import com.c4_soft.springaddons.security.oidc.spring.SpringAddonsMethodSecurityExpressionHandler; import com.c4_soft.springaddons.security.oidc.spring.SpringAddonsMethodSecurityExpressionRoot; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.JwtAbstractAuthenticationTokenConverter; @@ -20,33 +18,35 @@ @EnableMethodSecurity public class SecurityConfig { - @Bean - JwtAbstractAuthenticationTokenConverter - authenticationConverter(Converter, Collection> authoritiesConverter) { - return jwt -> { - final var claimSet = new ProxiesClaimSet(jwt.getClaims()); - return new ProxiesAuthentication(claimSet, authoritiesConverter.convert(claimSet), jwt.getTokenValue()); - }; - } - - @Bean - static MethodSecurityExpressionHandler methodSecurityExpressionHandler() { - return new SpringAddonsMethodSecurityExpressionHandler(ProxiesMethodSecurityExpressionRoot::new); - } - - static final class ProxiesMethodSecurityExpressionRoot extends SpringAddonsMethodSecurityExpressionRoot { - - public boolean is(String preferredUsername) { - return Objects.equals(preferredUsername, getAuthentication().getName()); - } - - public Proxy onBehalfOf(String proxiedUsername) { - return get(ProxiesAuthentication.class).map(a -> a.getProxyFor(proxiedUsername)) - .orElse(new Proxy(proxiedUsername, getAuthentication().getName(), List.of())); - } - - public boolean isNice() { - return hasAnyAuthority("NICE", "SUPER_COOL"); - } - } -} \ No newline at end of file + @Bean + JwtAbstractAuthenticationTokenConverter authenticationConverter( + Converter, Collection> authoritiesConverter) { + return jwt -> { + final var claimSet = new ProxiesToken(jwt.getClaims(), jwt.getTokenValue()); + return new ProxiesAuthentication(claimSet, authoritiesConverter.convert(claimSet)); + }; + } + + @Bean + static MethodSecurityExpressionHandler methodSecurityExpressionHandler() { + return new SpringAddonsMethodSecurityExpressionHandler( + ProxiesMethodSecurityExpressionRoot::new); + } + + static final class ProxiesMethodSecurityExpressionRoot + extends SpringAddonsMethodSecurityExpressionRoot { + + public boolean is(String preferredUsername) { + return Objects.equals(preferredUsername, getAuthentication().getName()); + } + + public Proxy onBehalfOf(String proxiedUsername) { + return get(ProxiesAuthentication.class).map(a -> a.getProxyFor(proxiedUsername)) + .orElse(new Proxy(proxiedUsername, getAuthentication().getName(), List.of())); + } + + public boolean isNice() { + return hasAnyAuthority("NICE", "SUPER_COOL"); + } + } +} diff --git a/samples/tutorials/resource-server_with_ui/pom.xml b/samples/tutorials/resource-server_with_ui/pom.xml index 67a937075..98911d2d4 100644 --- a/samples/tutorials/resource-server_with_ui/pom.xml +++ b/samples/tutorials/resource-server_with_ui/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples.tutorials tutorials - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. resource-server_with_ui @@ -19,10 +19,6 @@ org.springframework.boot spring-boot-starter-web - - org.springframework.cloud - spring-cloud-starter-openfeign - org.springframework.boot spring-boot-starter-oauth2-client @@ -46,10 +42,6 @@ org.springframework.boot spring-boot-starter-thymeleaf - - org.springframework.boot - spring-boot-starter-webflux - @@ -92,14 +84,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - diff --git a/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithUiApplication.java b/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithUiApplication.java index 094f8785f..a45406de2 100644 --- a/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithUiApplication.java +++ b/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithUiApplication.java @@ -2,10 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cloud.openfeign.EnableFeignClients; @SpringBootApplication -@EnableFeignClients public class ResourceServerWithUiApplication { public static void main(String[] args) { diff --git a/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/ClientBearerRequestInterceptor.java b/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/ClientBearerRequestInterceptor.java deleted file mode 100644 index c3944a173..000000000 --- a/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/ClientBearerRequestInterceptor.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.c4soft.springaddons.tutorials.ui; - -import org.springframework.http.HttpHeaders; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -import feign.RequestInterceptor; -import feign.RequestTemplate; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class ClientBearerRequestInterceptor implements RequestInterceptor { - private final OAuth2AuthorizedClientRepository authorizedClientRepo; - - @Override - public void apply(RequestTemplate template) { - final var auth = SecurityContextHolder.getContext().getAuthentication(); - if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes servletRequestAttributes) { - if (auth instanceof OAuth2AuthenticationToken oauth) { - final var authorizedClient = authorizedClientRepo - .loadAuthorizedClient(oauth.getAuthorizedClientRegistrationId(), auth, servletRequestAttributes.getRequest()); - if (authorizedClient != null) { - template.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(authorizedClient.getAccessToken().getTokenValue())); - } - } - if (auth instanceof JwtAuthenticationToken jwtAuth) { - template.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(jwtAuth.getToken().getTokenValue())); - } - } - } -} diff --git a/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/RestClientsConfig.java b/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/RestClientsConfig.java index ed3a337f0..4c876fc63 100644 --- a/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/RestClientsConfig.java +++ b/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/RestClientsConfig.java @@ -2,16 +2,15 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; - -import com.c4_soft.springaddons.rest.SpringAddonsRestClientSupport; +import org.springframework.web.client.RestClient; +import com.c4_soft.springaddons.rest.RestClientHttpExchangeProxyFactoryBean; @Configuration public class RestClientsConfig { - @Bean - GreetApi greetApi(SpringAddonsRestClientSupport restSupport) { - // binds to com.c4-soft.springaddons.rest.client.greet-api properties - return restSupport.service("greet-api", GreetApi.class); - } + @Bean + GreetApi greetApi(RestClient greetClient) throws Exception { + return new RestClientHttpExchangeProxyFactoryBean<>(GreetApi.class, greetClient).getObject(); + } } diff --git a/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/UiController.java b/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/UiController.java index 987bf870a..1ec434b82 100644 --- a/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/UiController.java +++ b/samples/tutorials/resource-server_with_ui/src/main/java/com/c4soft/springaddons/tutorials/ui/UiController.java @@ -7,7 +7,6 @@ import java.util.ArrayList; import java.util.Optional; import java.util.stream.StreamSupport; - import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -23,11 +22,9 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.view.RedirectView; import org.springframework.web.util.UriComponentsBuilder; - import com.c4_soft.springaddons.security.oidc.starter.LogoutRequestUriBuilder; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; import com.c4_soft.springaddons.security.oidc.starter.synchronised.client.MultiTenantOAuth2PrincipalSupport; - import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; @@ -41,111 +38,115 @@ @RequiredArgsConstructor @Slf4j public class UiController { - private final GreetApi api; - private final InMemoryClientRegistrationRepository clientRegistrationRepository; - private final OAuth2AuthorizedClientRepository authorizedClientRepo; - private final SpringAddonsOidcProperties addonsClientProps; - private final LogoutRequestUriBuilder logoutRequestUriBuilder; + private final GreetApi greetApi; + private final InMemoryClientRegistrationRepository clientRegistrationRepository; + private final OAuth2AuthorizedClientRepository authorizedClientRepo; + private final SpringAddonsOidcProperties addonsClientProps; + private final LogoutRequestUriBuilder logoutRequestUriBuilder; - @GetMapping("/") - public String getIndex(Model model, Authentication auth) { - model.addAttribute("isAuthenticated", auth != null && auth.isAuthenticated()); - return "index"; - } + @GetMapping("/") + public String getIndex(Model model, Authentication auth) { + model.addAttribute("isAuthenticated", auth != null && auth.isAuthenticated()); + return "index"; + } - @GetMapping("/greet") - @PreAuthorize("isAuthenticated()") - public String getGreeting(HttpServletRequest request, Authentication auth, Model model) throws URISyntaxException { - final var unauthorizedClients = new ArrayList(); - final var authorizedClients = new ArrayList(); - StreamSupport - .stream(this.clientRegistrationRepository.spliterator(), false) - .filter(registration -> AuthorizationGrantType.AUTHORIZATION_CODE.equals(registration.getAuthorizationGrantType())) - .forEach(registration -> { - final var authorizedClient = auth == null ? null : authorizedClientRepo.loadAuthorizedClient(registration.getRegistrationId(), auth, request); - if (authorizedClient == null) { - unauthorizedClients.add(new UnauthorizedClientDto(registration.getClientName(), registration.getRegistrationId())); - } else { - SecurityContextHolder - .getContext() - .setAuthentication( - MultiTenantOAuth2PrincipalSupport.getAuthentication(request.getSession(), registration.getRegistrationId()).orElse(auth)); - final var greeting = api.getGreeting(); + @GetMapping("/greet") + @PreAuthorize("isAuthenticated()") + public String getGreeting(HttpServletRequest request, Authentication auth, Model model) + throws URISyntaxException { + final var unauthorizedClients = new ArrayList(); + final var authorizedClients = new ArrayList(); + StreamSupport.stream(this.clientRegistrationRepository.spliterator(), false) + .filter(registration -> AuthorizationGrantType.AUTHORIZATION_CODE + .equals(registration.getAuthorizationGrantType())) + .forEach(registration -> { + final var authorizedClient = auth == null ? null + : authorizedClientRepo.loadAuthorizedClient(registration.getRegistrationId(), auth, + request); + if (authorizedClient == null) { + unauthorizedClients.add(new UnauthorizedClientDto(registration.getClientName(), + registration.getRegistrationId())); + } else { + SecurityContextHolder.getContext() + .setAuthentication(MultiTenantOAuth2PrincipalSupport + .getAuthentication(request.getSession(), registration.getRegistrationId()) + .orElse(auth)); + final var greeting = greetApi.getGreeting(); - authorizedClients - .add( - new AuthorizedClientDto( - registration.getClientName(), - greeting, - "/ui/logout-idp?clientRegistrationId=%s".formatted(registration.getRegistrationId()))); - } - }); - model.addAttribute("unauthorizedClients", unauthorizedClients); - model.addAttribute("authorizedClients", authorizedClients); - return "greet"; - } + authorizedClients.add(new AuthorizedClientDto(registration.getClientName(), greeting, + "/ui/logout-idp?clientRegistrationId=%s" + .formatted(registration.getRegistrationId()))); + } + }); + model.addAttribute("unauthorizedClients", unauthorizedClients); + model.addAttribute("authorizedClients", authorizedClients); + return "greet"; + } - @PostMapping("/logout-idp") - @PreAuthorize("isAuthenticated()") - public RedirectView logout( - @RequestParam("clientRegistrationId") String clientRegistrationId, - @RequestParam(name = "redirectTo", required = false) Optional redirectTo, - HttpServletRequest request, - HttpServletResponse response) { - final var postLogoutUri = UriComponentsBuilder - .fromUri(addonsClientProps.getClient().getClientUri()) - .path(redirectTo.orElse("/ui/greet")) - .encode(StandardCharsets.UTF_8) - .build() - .toUriString(); - final var authentication = MultiTenantOAuth2PrincipalSupport.getAuthentication(request.getSession(), clientRegistrationId).orElse(null); - final var authorizedClient = authorizedClientRepo.loadAuthorizedClient(clientRegistrationId, authentication, request); - final var idToken = authentication.getPrincipal() instanceof OidcUser oidcUser ? oidcUser.getIdToken().getTokenValue() : null; - String logoutUri = logoutRequestUriBuilder - .getLogoutRequestUri(authorizedClient.getClientRegistration(), idToken, Optional.of(URI.create(postLogoutUri))) - .orElse(""); + @PostMapping("/logout-idp") + @PreAuthorize("isAuthenticated()") + public RedirectView logout(@RequestParam("clientRegistrationId") String clientRegistrationId, + @RequestParam(name = "redirectTo", required = false) Optional redirectTo, + HttpServletRequest request, HttpServletResponse response) { + final var postLogoutUri = UriComponentsBuilder + .fromUri(addonsClientProps.getClient().getClientUri()).path(redirectTo.orElse("/ui/greet")) + .encode(StandardCharsets.UTF_8).build().toUriString(); + final var authentication = MultiTenantOAuth2PrincipalSupport + .getAuthentication(request.getSession(), clientRegistrationId).orElse(null); + final var authorizedClient = + authorizedClientRepo.loadAuthorizedClient(clientRegistrationId, authentication, request); + final var idToken = authentication.getPrincipal() instanceof OidcUser oidcUser + ? oidcUser.getIdToken().getTokenValue() + : null; + String logoutUri = + logoutRequestUriBuilder.getLogoutRequestUri(authorizedClient.getClientRegistration(), + idToken, Optional.of(URI.create(postLogoutUri))).orElse(""); - log.info("Remove authorized client with ID {} for {}", clientRegistrationId, authentication.getName()); - this.authorizedClientRepo.removeAuthorizedClient(clientRegistrationId, authentication, request, response); - final var authorizedClientIds = MultiTenantOAuth2PrincipalSupport.getAuthenticationsByClientRegistrationId(request.getSession()); - if (authorizedClientIds.isEmpty()) { - request.getSession().invalidate(); - } - - log.info("Redirecting {} to {} for logout", authentication.getName(), logoutUri); - return new RedirectView(logoutUri); + log.info("Remove authorized client with ID {} for {}", clientRegistrationId, + authentication.getName()); + this.authorizedClientRepo.removeAuthorizedClient(clientRegistrationId, authentication, request, + response); + final var authorizedClientIds = MultiTenantOAuth2PrincipalSupport + .getAuthenticationsByClientRegistrationId(request.getSession()); + if (authorizedClientIds.isEmpty()) { + request.getSession().invalidate(); } - @PostMapping("/bulk-logout-idps") - @PreAuthorize("isAuthenticated()") - public RedirectView bulkLogout(HttpServletRequest request) { - final var authorizedClientIds = MultiTenantOAuth2PrincipalSupport.getAuthenticationsByClientRegistrationId(request.getSession()).entrySet().iterator(); - if (authorizedClientIds.hasNext()) { - final var id = authorizedClientIds.next(); - final var builder = UriComponentsBuilder.fromPath("/ui/logout-idp"); - builder.queryParam("clientRegistrationId", id.getKey()); - builder.queryParam("redirectTo", "/ui/bulk-logout-idps"); - return new RedirectView(builder.encode(StandardCharsets.UTF_8).build().toUriString()); - } - return new RedirectView(addonsClientProps.getClient().getPostLogoutRedirectUri().toString()); + log.info("Redirecting {} to {} for logout", authentication.getName(), logoutUri); + return new RedirectView(logoutUri); + } + + @PostMapping("/bulk-logout-idps") + @PreAuthorize("isAuthenticated()") + public RedirectView bulkLogout(HttpServletRequest request) { + final var authorizedClientIds = MultiTenantOAuth2PrincipalSupport + .getAuthenticationsByClientRegistrationId(request.getSession()).entrySet().iterator(); + if (authorizedClientIds.hasNext()) { + final var id = authorizedClientIds.next(); + final var builder = UriComponentsBuilder.fromPath("/ui/logout-idp"); + builder.queryParam("clientRegistrationId", id.getKey()); + builder.queryParam("redirectTo", "/ui/bulk-logout-idps"); + return new RedirectView(builder.encode(StandardCharsets.UTF_8).build().toUriString()); } + return new RedirectView(addonsClientProps.getClient().getPostLogoutRedirectUri().toString()); + } - @Data - @NoArgsConstructor - @AllArgsConstructor - public static class AuthorizedClientDto implements Serializable { - private static final long serialVersionUID = -6623594577844506618L; + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class AuthorizedClientDto implements Serializable { + private static final long serialVersionUID = -6623594577844506618L; - private String label; - private String message; - private String logoutUri; - } + private String label; + private String message; + private String logoutUri; + } - @Data - @NoArgsConstructor - @AllArgsConstructor - public static class UnauthorizedClientDto { - private String label; - private String registrationId; - } + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class UnauthorizedClientDto { + private String label; + private String registrationId; + } } diff --git a/samples/tutorials/resource-server_with_ui/src/main/resources/application.yml b/samples/tutorials/resource-server_with_ui/src/main/resources/application.yml index 4a805c8e6..a33cf9a01 100644 --- a/samples/tutorials/resource-server_with_ui/src/main/resources/application.yml +++ b/samples/tutorials/resource-server_with_ui/src/main/resources/application.yml @@ -88,7 +88,7 @@ com: audience: demo.c4-soft.com rest: client: - greet-api: + greet-client: base-url: ${client-uri}/api authorization: oauth2: diff --git a/samples/tutorials/servlet-client/pom.xml b/samples/tutorials/servlet-client/pom.xml index 0d3481d5a..d829b6ecb 100644 --- a/samples/tutorials/servlet-client/pom.xml +++ b/samples/tutorials/servlet-client/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples.tutorials tutorials - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. servlet-client @@ -73,14 +73,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - diff --git a/samples/tutorials/servlet-resource-server/pom.xml b/samples/tutorials/servlet-resource-server/pom.xml index f78adb819..0cc25863e 100644 --- a/samples/tutorials/servlet-resource-server/pom.xml +++ b/samples/tutorials/servlet-resource-server/pom.xml @@ -4,29 +4,29 @@ com.c4-soft.springaddons.samples.tutorials tutorials - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. servlet-resource-server servlet-resource-server - - - org.springframework.boot - spring-boot-starter-actuator - - - org.springframework.boot - spring-boot-starter-oauth2-resource-server - - - org.springframework.boot - spring-boot-starter-web - - - com.jayway.jsonpath - json-path - + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-web + + + com.jayway.jsonpath + json-path + org.springframework.boot @@ -62,14 +62,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - diff --git a/samples/webflux-introspecting-default/pom.xml b/samples/webflux-introspecting-default/pom.xml index 8df66d6bf..735342064 100644 --- a/samples/webflux-introspecting-default/pom.xml +++ b/samples/webflux-introspecting-default/pom.xml @@ -3,7 +3,7 @@ com.c4-soft.springaddons.samples spring-addons-samples - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. @@ -54,14 +54,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - \ No newline at end of file diff --git a/samples/webflux-introspecting-oauthentication/pom.xml b/samples/webflux-introspecting-oauthentication/pom.xml index b5fc01825..f5849e764 100644 --- a/samples/webflux-introspecting-oauthentication/pom.xml +++ b/samples/webflux-introspecting-oauthentication/pom.xml @@ -3,7 +3,7 @@ com.c4-soft.springaddons.samples spring-addons-samples - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. @@ -56,14 +56,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - \ No newline at end of file diff --git a/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/GreetingController.java b/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/GreetingController.java index 320b50378..9a5c3524f 100644 --- a/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/GreetingController.java +++ b/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/GreetingController.java @@ -1,42 +1,41 @@ package com.c4_soft.springaddons.samples.webflux_jwtauthenticationtoken; import java.util.Map; - import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - +import com.c4_soft.springaddons.security.oidc.OpenidToken; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; @RestController @RequiredArgsConstructor public class GreetingController { - private final MessageService messageService; - - @GetMapping("/greet") - public Mono> greet(OAuthentication auth) { - return messageService.greet(auth).map(ResponseEntity::ok); - } - - @GetMapping("/secured-route") - public Mono> securedRoute() { - return messageService.getSecret().map(ResponseEntity::ok); - } - - @GetMapping("/secured-method") - @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") - public Mono> securedMethod() { - return messageService.getSecret().map(ResponseEntity::ok); - } - - @GetMapping("/claims") - public Mono>> getClaims(@AuthenticationPrincipal OpenidClaimSet claims) { - return Mono.just(claims).map(ResponseEntity::ok); - } + private final MessageService messageService; + + @GetMapping("/greet") + public Mono> greet(OAuthentication auth) { + return messageService.greet(auth).map(ResponseEntity::ok); + } + + @GetMapping("/secured-route") + public Mono> securedRoute() { + return messageService.getSecret().map(ResponseEntity::ok); + } + + @GetMapping("/secured-method") + @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") + public Mono> securedMethod() { + return messageService.getSecret().map(ResponseEntity::ok); + } + + @GetMapping("/claims") + public Mono>> getClaims( + @AuthenticationPrincipal OpenidClaimSet claims) { + return Mono.just(claims).map(ResponseEntity::ok); + } } diff --git a/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/MessageService.java b/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/MessageService.java index 2ed7632b6..9f373a776 100644 --- a/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/MessageService.java +++ b/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/MessageService.java @@ -1,13 +1,15 @@ /* * Copyright 2019 Jérôme Wacongne * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webflux_jwtauthenticationtoken; @@ -15,27 +17,27 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; - import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - +import com.c4_soft.springaddons.security.oidc.OpenidToken; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; @Service @RequiredArgsConstructor public class MessageService { - private final SecretRepo fooRepo; - - @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") - public Mono getSecret() { - return fooRepo.findSecretByUsername(SecurityContextHolder.getContext().getAuthentication().getName()); - } - - @PreAuthorize("isAuthenticated()") - public Mono greet(OAuthentication who) { - final String msg = String.format("Hello %s! You are granted with %s.", who.getName(), who.getAuthorities()); - return Mono.just(msg); - } - -} \ No newline at end of file + private final SecretRepo fooRepo; + + @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") + public Mono getSecret() { + return fooRepo + .findSecretByUsername(SecurityContextHolder.getContext().getAuthentication().getName()); + } + + @PreAuthorize("isAuthenticated()") + public Mono greet(OAuthentication who) { + final String msg = + String.format("Hello %s! You are granted with %s.", who.getName(), who.getAuthorities()); + return Mono.just(msg); + } + +} diff --git a/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/SecurityConfig.java b/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/SecurityConfig.java index 1f75d69a4..e28d03cb8 100644 --- a/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/SecurityConfig.java +++ b/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/SecurityConfig.java @@ -2,7 +2,6 @@ import java.util.Collection; import java.util.Map; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.converter.Converter; @@ -11,43 +10,41 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenAuthenticationConverter; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ResourceServerAuthorizeExchangeSpecPostProcessor; - import reactor.core.publisher.Mono; @EnableReactiveMethodSecurity() @Configuration public class SecurityConfig { - @Bean - ReactiveOpaqueTokenAuthenticationConverter authenticationConverter( - Converter, Collection> authoritiesConverter, - OpenidProviderPropertiesResolver opPropertiesResolver) { - return (String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> Mono - .just( - new OAuthentication<>( - new OpenidClaimSet( - authenticatedPrincipal.getAttributes(), - opPropertiesResolver - .resolve(authenticatedPrincipal.getAttributes()) - .orElseThrow(() -> new NotAConfiguredOpenidProviderException(authenticatedPrincipal.getAttributes())) - .getUsernameClaim()), - authoritiesConverter.convert(authenticatedPrincipal.getAttributes()), - introspectedToken)); - } - - @Bean - ResourceServerAuthorizeExchangeSpecPostProcessor authorizeExchangeSpecPostProcessor() { - // @formatter:off + @Bean + ReactiveOpaqueTokenAuthenticationConverter authenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return (String introspectedToken, + OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> Mono.just(new OAuthentication<>( + new OpenidToken( + new OpenidClaimSet(authenticatedPrincipal.getAttributes(), + opPropertiesResolver.resolve(authenticatedPrincipal.getAttributes()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException( + authenticatedPrincipal.getAttributes())) + .getUsernameClaim()), + introspectedToken), + authoritiesConverter.convert(authenticatedPrincipal.getAttributes()))); + } + + @Bean + ResourceServerAuthorizeExchangeSpecPostProcessor authorizeExchangeSpecPostProcessor() { + // @formatter:off return (ServerHttpSecurity.AuthorizeExchangeSpec spec) -> spec .pathMatchers("/secured-route").hasRole("AUTHORIZED_PERSONNEL") .anyExchange().authenticated(); // @formatter:on - } + } } diff --git a/samples/webflux-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/GreetingControllerAnnotatedTest.java b/samples/webflux-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/GreetingControllerAnnotatedTest.java index 7c92f1e79..42637cb9c 100644 --- a/samples/webflux-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/GreetingControllerAnnotatedTest.java +++ b/samples/webflux-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/GreetingControllerAnnotatedTest.java @@ -1,21 +1,21 @@ /* * Copyright 2019 Jérôme Wacongne. * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webflux_jwtauthenticationtoken; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; - import java.util.stream.Stream; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -26,14 +26,12 @@ import org.springframework.context.annotation.Import; import org.springframework.security.core.Authentication; import org.springframework.security.test.context.support.WithAnonymousUser; - import com.c4_soft.springaddons.security.oauth2.test.annotations.WithOpaqueToken; import com.c4_soft.springaddons.security.oauth2.test.annotations.parameterized.ParameterizedAuthentication; import com.c4_soft.springaddons.security.oauth2.test.webflux.AutoConfigureAddonsWebfluxResourceServerSecurity; import com.c4_soft.springaddons.security.oauth2.test.webflux.WebTestClientSupport; import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - +import com.c4_soft.springaddons.security.oidc.OpenidToken; import reactor.core.publisher.Mono; /** @@ -43,68 +41,73 @@ */ @WebFluxTest(GreetingController.class) // Use WebFluxTest or WebMvcTest -@AutoConfigureAddonsWebfluxResourceServerSecurity // If your web-security depends on it, setup spring-addons security -@Import({ SecurityConfig.class }) // Import your web-security configuration +@AutoConfigureAddonsWebfluxResourceServerSecurity // If your web-security depends on it, setup + // spring-addons security +@Import({SecurityConfig.class}) // Import your web-security configuration class GreetingControllerAnnotatedTest { - // Mock controller injected dependencies - @MockBean - private MessageService messageService; - - @Autowired - WebTestClientSupport api; - - @Autowired - WithOpaqueToken.AuthenticationFactory authFactory; - - @BeforeEach - public void setUp() { - when(messageService.greet(any())).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") - final OAuthentication auth = invocation.getArgument(0, OAuthentication.class); - return Mono.just(String.format("Hello %s! You are granted with %s.", auth.getAttributes().getPreferredUsername(), auth.getAuthorities())); - }); - when(messageService.getSecret()).thenReturn(Mono.just("Secret message")); - } - - @Test - @WithAnonymousUser - void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception { - api.get("https://localhost/greet").expectStatus().isUnauthorized(); - } - - @ParameterizedTest - @MethodSource("identities") - void givenUserIsCh4mpy_whenGetGreet_thenOk(@ParameterizedAuthentication Authentication auth) throws Exception { - api.get("https://localhost/greet").expectBody(String.class) - .isEqualTo("Hello %s! You are granted with %s.".formatted(auth.getName(), auth.getAuthorities())); - } - - Stream identities() { - return authFactory.authenticationsFrom("ch4mp.json", "tonton-pirate.json"); - } - - @Test - @WithOpaqueToken("tonton-pirate.json") - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() throws Exception { - api.get("https://localhost/secured-route").expectStatus().isForbidden(); - } - - @Test - @WithOpaqueToken("ch4mp.json") - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception { - api.get("https://localhost/secured-route").expectStatus().isOk(); - } - - @Test - @WithOpaqueToken("tonton-pirate.json") - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenForbidden() throws Exception { - api.get("https://localhost/secured-method").expectStatus().isForbidden(); - } - - @Test - @WithOpaqueToken("ch4mp.json") - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenOk() throws Exception { - api.get("https://localhost/secured-method").expectStatus().isOk(); - } + // Mock controller injected dependencies + @MockBean + private MessageService messageService; + + @Autowired + WebTestClientSupport api; + + @Autowired + WithOpaqueToken.AuthenticationFactory authFactory; + + @BeforeEach + public void setUp() { + when(messageService.greet(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + final OAuthentication auth = invocation.getArgument(0, OAuthentication.class); + return Mono.just(String.format("Hello %s! You are granted with %s.", + auth.getAttributes().getPreferredUsername(), auth.getAuthorities())); + }); + when(messageService.getSecret()).thenReturn(Mono.just("Secret message")); + } + + @Test + @WithAnonymousUser + void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception { + api.get("https://localhost/greet").expectStatus().isUnauthorized(); + } + + @ParameterizedTest + @MethodSource("identities") + void givenUserIsCh4mpy_whenGetGreet_thenOk(@ParameterizedAuthentication Authentication auth) + throws Exception { + api.get("https://localhost/greet").expectBody(String.class).isEqualTo( + "Hello %s! You are granted with %s.".formatted(auth.getName(), auth.getAuthorities())); + } + + Stream identities() { + return authFactory.authenticationsFrom("ch4mp.json", "tonton-pirate.json"); + } + + @Test + @WithOpaqueToken("tonton-pirate.json") + void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() + throws Exception { + api.get("https://localhost/secured-route").expectStatus().isForbidden(); + } + + @Test + @WithOpaqueToken("ch4mp.json") + void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception { + api.get("https://localhost/secured-route").expectStatus().isOk(); + } + + @Test + @WithOpaqueToken("tonton-pirate.json") + void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenForbidden() + throws Exception { + api.get("https://localhost/secured-method").expectStatus().isForbidden(); + } + + @Test + @WithOpaqueToken("ch4mp.json") + void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenOk() throws Exception { + api.get("https://localhost/secured-method").expectStatus().isOk(); + } } diff --git a/samples/webflux-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/MessageServiceTests.java b/samples/webflux-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/MessageServiceTests.java index 1c133d412..f538863f9 100644 --- a/samples/webflux-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/MessageServiceTests.java +++ b/samples/webflux-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/MessageServiceTests.java @@ -1,13 +1,15 @@ /* * Copyright 2019 Jérôme Wacongne. * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webflux_jwtauthenticationtoken; @@ -15,9 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; - import java.util.stream.Stream; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -27,13 +27,11 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.core.Authentication; import org.springframework.security.test.context.support.WithAnonymousUser; - import com.c4_soft.springaddons.security.oauth2.test.annotations.WithOpaqueToken; import com.c4_soft.springaddons.security.oauth2.test.annotations.parameterized.ParameterizedAuthentication; import com.c4_soft.springaddons.security.oauth2.test.webflux.AddonsWebfluxComponentTest; import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - +import com.c4_soft.springaddons.security.oidc.OpenidToken; import reactor.core.publisher.Mono; /** @@ -43,60 +41,62 @@ */ @AddonsWebfluxComponentTest -@SpringBootTest(classes = { SecurityConfig.class, MessageService.class }) +@SpringBootTest(classes = {SecurityConfig.class, MessageService.class}) class MessageServiceTests { - // auto-wire tested component - @Autowired - private MessageService messageService; - - @Autowired - WithOpaqueToken.AuthenticationFactory authFactory; - - // mock dependencies - @MockBean - SecretRepo secretRepo; - - @BeforeEach - public void setUp() { - when(secretRepo.findSecretByUsername(anyString())).thenReturn(Mono.just("incredible")); - } - - @Test - @WithAnonymousUser - void givenRequestIsAnonymous_whenGetSecret_thenThrows() { - // call tested components methods directly (do not use MockMvc nor WebTestClient) - assertThrows(Exception.class, () -> messageService.getSecret().block()); - } - - @Test - @WithAnonymousUser - void givenRequestIsAnonymous_whenGetGreet_thenThrows() { - assertThrows(Exception.class, () -> messageService.greet(null).block()); - } - - @Test - @WithOpaqueToken("tonton-pirate.json") - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecret_thenThrows() { - assertThrows(Exception.class, () -> messageService.getSecret().block()); - } - - @Test - @WithOpaqueToken("ch4mp.json") - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecret_thenReturnsSecret() { - assertThat(messageService.getSecret().block()).isEqualTo("incredible"); - } - - @SuppressWarnings("unchecked") - @ParameterizedTest - @MethodSource("identities") - void givenUserIsAuthenticated_whenGetGreet_thenReturnsGreeting(@ParameterizedAuthentication Authentication auth) { - final var oauth = (OAuthentication) auth; - - assertThat(messageService.greet(oauth).block()).isEqualTo("Hello %s! You are granted with %s.".formatted(auth.getName(), auth.getAuthorities())); - } - - Stream identities() { - return authFactory.authenticationsFrom("ch4mp.json", "tonton-pirate.json"); - } + // auto-wire tested component + @Autowired + private MessageService messageService; + + @Autowired + WithOpaqueToken.AuthenticationFactory authFactory; + + // mock dependencies + @MockBean + SecretRepo secretRepo; + + @BeforeEach + public void setUp() { + when(secretRepo.findSecretByUsername(anyString())).thenReturn(Mono.just("incredible")); + } + + @Test + @WithAnonymousUser + void givenRequestIsAnonymous_whenGetSecret_thenThrows() { + // call tested components methods directly (do not use MockMvc nor WebTestClient) + assertThrows(Exception.class, () -> messageService.getSecret().block()); + } + + @Test + @WithAnonymousUser + void givenRequestIsAnonymous_whenGetGreet_thenThrows() { + assertThrows(Exception.class, () -> messageService.greet(null).block()); + } + + @Test + @WithOpaqueToken("tonton-pirate.json") + void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecret_thenThrows() { + assertThrows(Exception.class, () -> messageService.getSecret().block()); + } + + @Test + @WithOpaqueToken("ch4mp.json") + void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecret_thenReturnsSecret() { + assertThat(messageService.getSecret().block()).isEqualTo("incredible"); + } + + @SuppressWarnings("unchecked") + @ParameterizedTest + @MethodSource("identities") + void givenUserIsAuthenticated_whenGetGreet_thenReturnsGreeting( + @ParameterizedAuthentication Authentication auth) { + final var oauth = (OAuthentication) auth; + + assertThat(messageService.greet(oauth).block()).isEqualTo( + "Hello %s! You are granted with %s.".formatted(auth.getName(), auth.getAuthorities())); + } + + Stream identities() { + return authFactory.authenticationsFrom("ch4mp.json", "tonton-pirate.json"); + } } diff --git a/samples/webflux-jwt-default/pom.xml b/samples/webflux-jwt-default/pom.xml index 3daf64701..1aff4ddb7 100644 --- a/samples/webflux-jwt-default/pom.xml +++ b/samples/webflux-jwt-default/pom.xml @@ -3,7 +3,7 @@ com.c4-soft.springaddons.samples spring-addons-samples - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. @@ -56,14 +56,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - \ No newline at end of file diff --git a/samples/webflux-jwt-oauthentication/pom.xml b/samples/webflux-jwt-oauthentication/pom.xml index dc6239ef8..2796a2584 100644 --- a/samples/webflux-jwt-oauthentication/pom.xml +++ b/samples/webflux-jwt-oauthentication/pom.xml @@ -3,7 +3,7 @@ com.c4-soft.springaddons.samples spring-addons-samples - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. @@ -54,14 +54,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - \ No newline at end of file diff --git a/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/GreetingController.java b/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/GreetingController.java index 0e0935960..3b63e78e4 100644 --- a/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/GreetingController.java +++ b/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/GreetingController.java @@ -1,42 +1,41 @@ package com.c4_soft.springaddons.samples.webflux_oidcauthentication; import java.util.Map; - import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - +import com.c4_soft.springaddons.security.oidc.OpenidToken; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; @RestController @RequiredArgsConstructor public class GreetingController { - private final MessageService messageService; - - @GetMapping("/greet") - public Mono> greet(OAuthentication auth) { - return messageService.greet(auth).map(ResponseEntity::ok); - } - - @GetMapping("/secured-route") - public Mono> securedRoute() { - return messageService.getSecret().map(ResponseEntity::ok); - } - - @GetMapping("/secured-method") - @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") - public Mono> securedMethod() { - return messageService.getSecret().map(ResponseEntity::ok); - } - - @GetMapping("/claims") - public Mono>> getClaims(@AuthenticationPrincipal OpenidClaimSet claims) { - return Mono.just(claims).map(ResponseEntity::ok); - } + private final MessageService messageService; + + @GetMapping("/greet") + public Mono> greet(OAuthentication auth) { + return messageService.greet(auth).map(ResponseEntity::ok); + } + + @GetMapping("/secured-route") + public Mono> securedRoute() { + return messageService.getSecret().map(ResponseEntity::ok); + } + + @GetMapping("/secured-method") + @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") + public Mono> securedMethod() { + return messageService.getSecret().map(ResponseEntity::ok); + } + + @GetMapping("/claims") + public Mono>> getClaims( + @AuthenticationPrincipal OpenidClaimSet claims) { + return Mono.just(claims).map(ResponseEntity::ok); + } } diff --git a/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/MessageService.java b/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/MessageService.java index 91a13ab18..fa21157a2 100644 --- a/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/MessageService.java +++ b/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/MessageService.java @@ -1,13 +1,15 @@ /* * Copyright 2019 Jérôme Wacongne * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webflux_oidcauthentication; @@ -15,27 +17,27 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; - import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - +import com.c4_soft.springaddons.security.oidc.OpenidToken; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; @Service @RequiredArgsConstructor public class MessageService { - private final SecretRepo fooRepo; - - @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") - public Mono getSecret() { - return fooRepo.findSecretByUsername(SecurityContextHolder.getContext().getAuthentication().getName()); - } - - @PreAuthorize("isAuthenticated()") - public Mono greet(OAuthentication who) { - final String msg = String.format("Hello %s! You are granted with %s.", who.getName(), who.getAuthorities()); - return Mono.just(msg); - } - -} \ No newline at end of file + private final SecretRepo fooRepo; + + @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") + public Mono getSecret() { + return fooRepo + .findSecretByUsername(SecurityContextHolder.getContext().getAuthentication().getName()); + } + + @PreAuthorize("isAuthenticated()") + public Mono greet(OAuthentication who) { + final String msg = + String.format("Hello %s! You are granted with %s.", who.getName(), who.getAuthorities()); + return Mono.just(msg); + } + +} diff --git a/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/SecurityConfig.java b/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/SecurityConfig.java index 17ac7fed0..f164ad7d0 100644 --- a/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/SecurityConfig.java +++ b/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/SecurityConfig.java @@ -2,51 +2,48 @@ import java.util.Collection; import java.util.Map; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.converter.Converter; import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.GrantedAuthority; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ReactiveJwtAbstractAuthenticationTokenConverter; import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ResourceServerAuthorizeExchangeSpecPostProcessor; - import reactor.core.publisher.Mono; @EnableReactiveMethodSecurity() @Configuration public class SecurityConfig { - @Bean - ReactiveJwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter( - Converter, Collection> authoritiesConverter, - OpenidProviderPropertiesResolver opPropertiesResolver) { - return jwt -> Mono - .just( - new OAuthentication<>( - new OpenidClaimSet( - jwt.getClaims(), - opPropertiesResolver - .resolve(jwt.getClaims()) - .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())) - .getUsernameClaim()), - authoritiesConverter.convert(jwt.getClaims()), - jwt.getTokenValue())); - } - - @Bean - ResourceServerAuthorizeExchangeSpecPostProcessor authorizeExchangeSpecPostProcessor() { - // @formatter:off + @Bean + ReactiveJwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return jwt -> Mono + .just( + new OAuthentication<>( + new OpenidToken(new OpenidClaimSet(jwt.getClaims(), + opPropertiesResolver.resolve(jwt.getClaims()) + .orElseThrow( + () -> new NotAConfiguredOpenidProviderException(jwt.getClaims())) + .getUsernameClaim()), + jwt.getTokenValue()), + authoritiesConverter.convert(jwt.getClaims()))); + } + + @Bean + ResourceServerAuthorizeExchangeSpecPostProcessor authorizeExchangeSpecPostProcessor() { + // @formatter:off return (ServerHttpSecurity.AuthorizeExchangeSpec spec) -> spec .pathMatchers("/secured-route").hasRole("AUTHORIZED_PERSONNEL") .anyExchange().authenticated(); // @formatter:on - } + } } diff --git a/samples/webflux-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/MessageServiceTests.java b/samples/webflux-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/MessageServiceTests.java index 8029d2a35..757fda895 100644 --- a/samples/webflux-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/MessageServiceTests.java +++ b/samples/webflux-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/MessageServiceTests.java @@ -1,13 +1,15 @@ /* * Copyright 2019 Jérôme Wacongne. * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webflux_oidcauthentication; @@ -15,9 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; - import java.util.stream.Stream; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -32,13 +32,12 @@ import org.springframework.context.annotation.Import; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.Authentication; - import com.c4_soft.springaddons.security.oauth2.test.AuthenticationFactoriesTestConf; import com.c4_soft.springaddons.security.oauth2.test.annotations.WithJwt; import com.c4_soft.springaddons.security.oauth2.test.annotations.parameterized.ParameterizedAuthentication; import com.c4_soft.springaddons.security.oauth2.test.webflux.AddonsWebfluxTestConf; import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; import com.c4_soft.springaddons.security.oidc.starter.reactive.ReactiveSpringAddonsOidcBeans; import com.c4_soft.springaddons.security.oidc.starter.reactive.client.ReactiveSpringAddonsOAuth2AuthorizedClientBeans; @@ -47,7 +46,6 @@ import com.c4_soft.springaddons.security.oidc.starter.synchronised.client.SpringAddonsOAuth2AuthorizedClientBeans; import com.c4_soft.springaddons.security.oidc.starter.synchronised.client.SpringAddonsOidcClientWithLoginBeans; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.SpringAddonsOidcResourceServerBeans; - import reactor.core.publisher.Mono; /** @@ -57,71 +55,70 @@ */ // Import security configuration and test component -@EnableAutoConfiguration( - exclude = { - ReactiveSpringAddonsOidcResourceServerBeans.class, - ReactiveSpringAddonsOAuth2AuthorizedClientBeans.class, - ReactiveSpringAddonsOidcClientWithLoginBeans.class, - SpringAddonsOidcResourceServerBeans.class, - SpringAddonsOAuth2AuthorizedClientBeans.class, - SpringAddonsOidcClientWithLoginBeans.class }) -@SpringBootTest(classes = { WebfluxJwtOauthentication.class, MessageService.class }) -@Import({ AddonsWebfluxTestConf.class }) -@ImportAutoConfiguration({ SpringAddonsOidcProperties.class, ReactiveSpringAddonsOidcBeans.class, AuthenticationFactoriesTestConf.class }) +@EnableAutoConfiguration(exclude = {ReactiveSpringAddonsOidcResourceServerBeans.class, + ReactiveSpringAddonsOAuth2AuthorizedClientBeans.class, + ReactiveSpringAddonsOidcClientWithLoginBeans.class, SpringAddonsOidcResourceServerBeans.class, + SpringAddonsOAuth2AuthorizedClientBeans.class, SpringAddonsOidcClientWithLoginBeans.class}) +@SpringBootTest(classes = {WebfluxJwtOauthentication.class, MessageService.class}) +@Import({AddonsWebfluxTestConf.class}) +@ImportAutoConfiguration({SpringAddonsOidcProperties.class, ReactiveSpringAddonsOidcBeans.class, + AuthenticationFactoriesTestConf.class}) @TestInstance(Lifecycle.PER_CLASS) class MessageServiceTests { - // auto-wire tested component - @Autowired - private MessageService messageService; - - @Autowired - WithJwt.AuthenticationFactory authFactory; - - // mock dependencies - @MockBean - SecretRepo secretRepo; - - @BeforeEach - public void setUp() { - when(secretRepo.findSecretByUsername(anyString())).thenReturn(Mono.just("incredible")); - } - - @Test() - void givenRequestIsAnonymous_whenGetSecret_thenThrows() { - // call tested components methods directly (do not use MockMvc nor WebTestClient) - assertThrows(Exception.class, () -> messageService.getSecret().block()); - } - - @Test() - void givenRequestIsAnonymous_whenGetGreet_thenThrows() { - assertThrows(Exception.class, () -> messageService.greet(null).block()); - } - - /*--------------*/ - /* @WithMockJwt */ - /*--------------*/ - @Test - @WithJwt("tonton-pirate.json") - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecret_thenThrows() { - assertThrows(Exception.class, () -> messageService.getSecret().block()); - } - - @Test - @WithJwt("ch4mp.json") - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecret_thenReturnsSecret() { - assertThat(messageService.getSecret().block()).isEqualTo("incredible"); - } - - @ParameterizedTest - @MethodSource("auth0users") - void givenUserIsAuthenticated_whenGetGreet_thenReturnsGreeting(@ParameterizedAuthentication Authentication auth) { - @SuppressWarnings("unchecked") - final var jwtAuth = (OAuthentication) auth; - assertThat(messageService.greet(jwtAuth).block()).isEqualTo("Hello %s! You are granted with %s.".formatted(auth.getName(), auth.getAuthorities())); - } - - Stream auth0users() { - return authFactory.authenticationsFrom("ch4mp.json", "tonton-pirate.json"); - } + // auto-wire tested component + @Autowired + private MessageService messageService; + + @Autowired + WithJwt.AuthenticationFactory authFactory; + + // mock dependencies + @MockBean + SecretRepo secretRepo; + + @BeforeEach + public void setUp() { + when(secretRepo.findSecretByUsername(anyString())).thenReturn(Mono.just("incredible")); + } + + @Test() + void givenRequestIsAnonymous_whenGetSecret_thenThrows() { + // call tested components methods directly (do not use MockMvc nor WebTestClient) + assertThrows(Exception.class, () -> messageService.getSecret().block()); + } + + @Test() + void givenRequestIsAnonymous_whenGetGreet_thenThrows() { + assertThrows(Exception.class, () -> messageService.greet(null).block()); + } + + /*--------------*/ + /* @WithMockJwt */ + /*--------------*/ + @Test + @WithJwt("tonton-pirate.json") + void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecret_thenThrows() { + assertThrows(Exception.class, () -> messageService.getSecret().block()); + } + + @Test + @WithJwt("ch4mp.json") + void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecret_thenReturnsSecret() { + assertThat(messageService.getSecret().block()).isEqualTo("incredible"); + } + + @ParameterizedTest + @MethodSource("auth0users") + void givenUserIsAuthenticated_whenGetGreet_thenReturnsGreeting( + @ParameterizedAuthentication Authentication auth) { + @SuppressWarnings("unchecked") + final var jwtAuth = (OAuthentication) auth; + assertThat(messageService.greet(jwtAuth).block()).isEqualTo( + "Hello %s! You are granted with %s.".formatted(auth.getName(), auth.getAuthorities())); + } + + Stream auth0users() { + return authFactory.authenticationsFrom("ch4mp.json", "tonton-pirate.json"); + } } diff --git a/samples/webmvc-introspecting-default/pom.xml b/samples/webmvc-introspecting-default/pom.xml index cbf79aed4..2dc6611f4 100644 --- a/samples/webmvc-introspecting-default/pom.xml +++ b/samples/webmvc-introspecting-default/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples spring-addons-samples - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. @@ -79,14 +79,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - \ No newline at end of file diff --git a/samples/webmvc-introspecting-oauthentication/pom.xml b/samples/webmvc-introspecting-oauthentication/pom.xml index 3fc921132..a2d147cc9 100644 --- a/samples/webmvc-introspecting-oauthentication/pom.xml +++ b/samples/webmvc-introspecting-oauthentication/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples spring-addons-samples - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. @@ -83,14 +83,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - \ No newline at end of file diff --git a/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingController.java b/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingController.java index 9fb3bf3e1..f6595b31d 100644 --- a/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingController.java +++ b/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingController.java @@ -1,40 +1,38 @@ package com.c4_soft.springaddons.samples.webmvc_oidcauthentication; import java.util.Map; - import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - +import com.c4_soft.springaddons.security.oidc.OpenidToken; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor public class GreetingController { - private final MessageService messageService; - - @GetMapping("/greet") - public String greet(OAuthentication auth) { - return messageService.greet(auth); - } - - @GetMapping("/secured-route") - public String securedRoute() { - return messageService.getSecret(); - } - - @GetMapping("/secured-method") - @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") - public String securedMethod() { - return messageService.getSecret(); - } - - @GetMapping("/claims") - public Map getClaims(@AuthenticationPrincipal OpenidClaimSet token) { - return token; - } + private final MessageService messageService; + + @GetMapping("/greet") + public String greet(OAuthentication auth) { + return messageService.greet(auth); + } + + @GetMapping("/secured-route") + public String securedRoute() { + return messageService.getSecret(); + } + + @GetMapping("/secured-method") + @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") + public String securedMethod() { + return messageService.getSecret(); + } + + @GetMapping("/claims") + public Map getClaims(@AuthenticationPrincipal OpenidClaimSet token) { + return token; + } } diff --git a/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageService.java b/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageService.java index dddd9bb4e..7dc681f31 100644 --- a/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageService.java +++ b/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageService.java @@ -1,13 +1,15 @@ /* * Copyright 2019 Jérôme Wacongne * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webmvc_oidcauthentication; @@ -15,25 +17,24 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; - import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - +import com.c4_soft.springaddons.security.oidc.OpenidToken; import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor public class MessageService { - private final SecretRepo fooRepo; + private final SecretRepo fooRepo; - @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") - public String getSecret() { - return fooRepo.findSecretByUsername(SecurityContextHolder.getContext().getAuthentication().getName()); - } + @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") + public String getSecret() { + return fooRepo + .findSecretByUsername(SecurityContextHolder.getContext().getAuthentication().getName()); + } - @PreAuthorize("isAuthenticated()") - public String greet(OAuthentication who) { - return String.format("Hello %s! You are granted with %s.", who.getName(), who.getAuthorities()); - } + @PreAuthorize("isAuthenticated()") + public String greet(OAuthentication who) { + return String.format("Hello %s! You are granted with %s.", who.getName(), who.getAuthorities()); + } -} \ No newline at end of file +} diff --git a/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java b/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java index 59b4b82c6..9779990b1 100644 --- a/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java +++ b/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java @@ -2,7 +2,6 @@ import java.util.Collection; import java.util.Map; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.converter.Converter; @@ -13,9 +12,9 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.ResourceServerExpressionInterceptUrlRegistryPostProcessor; @@ -23,27 +22,28 @@ @Configuration @EnableMethodSecurity public class SecurityConfig { - @Bean - OpaqueTokenAuthenticationConverter opaqueTokenAuthenticationConverter( - Converter, Collection> authoritiesConverter, - OpenidProviderPropertiesResolver opPropertiesResolver) { - return (String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> new OAuthentication<>( - new OpenidClaimSet( - authenticatedPrincipal.getAttributes(), - opPropertiesResolver - .resolve(authenticatedPrincipal.getAttributes()) - .orElseThrow(() -> new NotAConfiguredOpenidProviderException(authenticatedPrincipal.getAttributes())) - .getUsernameClaim()), - authoritiesConverter.convert(authenticatedPrincipal.getAttributes()), - introspectedToken); - }; + @Bean + OpaqueTokenAuthenticationConverter opaqueTokenAuthenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return (String introspectedToken, + OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> new OAuthentication<>( + new OpenidToken( + new OpenidClaimSet(authenticatedPrincipal.getAttributes(), + opPropertiesResolver.resolve(authenticatedPrincipal.getAttributes()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException( + authenticatedPrincipal.getAttributes())) + .getUsernameClaim()), + introspectedToken), + authoritiesConverter.convert(authenticatedPrincipal.getAttributes())); + }; - @Bean - ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { - // @formatter:off + @Bean + ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { + // @formatter:off return (AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry) -> registry .requestMatchers(new AntPathRequestMatcher("/secured-route")).hasRole("AUTHORIZED_PERSONNEL") .anyRequest().authenticated(); // @formatter:on - } + } } diff --git a/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerAnnotatedTest.java b/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerAnnotatedTest.java index ea7c968ee..81801b7aa 100644 --- a/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerAnnotatedTest.java +++ b/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerAnnotatedTest.java @@ -1,13 +1,15 @@ /* * Copyright 2019 Jérôme Wacongne. * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webmvc_oidcauthentication; @@ -15,9 +17,7 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - import java.util.stream.Stream; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -28,78 +28,82 @@ import org.springframework.context.annotation.Import; import org.springframework.security.core.Authentication; import org.springframework.security.test.context.support.WithAnonymousUser; - import com.c4_soft.springaddons.security.oauth2.test.annotations.WithOpaqueToken; import com.c4_soft.springaddons.security.oauth2.test.annotations.parameterized.ParameterizedAuthentication; import com.c4_soft.springaddons.security.oauth2.test.webmvc.AutoConfigureAddonsWebmvcResourceServerSecurity; import com.c4_soft.springaddons.security.oauth2.test.webmvc.MockMvcSupport; import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; /** * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ @WebMvcTest(GreetingController.class) @AutoConfigureAddonsWebmvcResourceServerSecurity -@Import({ SecurityConfig.class }) +@Import({SecurityConfig.class}) class GreetingControllerAnnotatedTest { - @MockBean - private MessageService messageService; - - @Autowired - MockMvcSupport api; - - @Autowired - WithOpaqueToken.AuthenticationFactory authFactory; - - @SuppressWarnings("unchecked") - @BeforeEach - public void setUp() { - when(messageService.greet(any(OAuthentication.class))).thenAnswer(invocation -> { - final OAuthentication auth = invocation.getArgument(0, OAuthentication.class); - return String.format("Hello %s! You are granted with %s.", auth.getName(), auth.getAuthorities()); - }); - when(messageService.getSecret()).thenReturn("Secret message"); - } - - @Test - @WithAnonymousUser - void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception { - api.get("/greet").andExpect(status().isUnauthorized()); - } - - @ParameterizedTest - @MethodSource("claimSets") - void givenUserIsAuthenticated_whenGetGreet_thenOk(@ParameterizedAuthentication Authentication auth) throws Exception { - api.get("/greet").andExpect(content().string("Hello %s! You are granted with %s.".formatted(auth.getName(), auth.getAuthorities()))); - } - - Stream claimSets() { - return authFactory.authenticationsFrom("ch4mp.json", "tonton-pirate.json"); - } - - @Test - @WithOpaqueToken("tonton-pirate.json") - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() throws Exception { - api.get("/secured-route").andExpect(status().isForbidden()); - } - - @Test - @WithOpaqueToken("ch4mp.json") - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception { - api.get("/secured-route").andExpect(status().isOk()); - } - - @Test - @WithOpaqueToken("tonton-pirate.json") - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenForbidden() throws Exception { - api.get("/secured-method").andExpect(status().isForbidden()); - } - - @Test - @WithOpaqueToken("ch4mp.json") - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenOk() throws Exception { - api.get("/secured-method").andExpect(status().isOk()); - } + @MockBean + private MessageService messageService; + + @Autowired + MockMvcSupport api; + + @Autowired + WithOpaqueToken.AuthenticationFactory authFactory; + + @SuppressWarnings("unchecked") + @BeforeEach + public void setUp() { + when(messageService.greet(any(OAuthentication.class))).thenAnswer(invocation -> { + final OAuthentication auth = invocation.getArgument(0, OAuthentication.class); + return String.format("Hello %s! You are granted with %s.", auth.getName(), + auth.getAuthorities()); + }); + when(messageService.getSecret()).thenReturn("Secret message"); + } + + @Test + @WithAnonymousUser + void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception { + api.get("/greet").andExpect(status().isUnauthorized()); + } + + @ParameterizedTest + @MethodSource("claimSets") + void givenUserIsAuthenticated_whenGetGreet_thenOk( + @ParameterizedAuthentication Authentication auth) throws Exception { + api.get("/greet").andExpect(content().string( + "Hello %s! You are granted with %s.".formatted(auth.getName(), auth.getAuthorities()))); + } + + Stream claimSets() { + return authFactory.authenticationsFrom("ch4mp.json", "tonton-pirate.json"); + } + + @Test + @WithOpaqueToken("tonton-pirate.json") + void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() + throws Exception { + api.get("/secured-route").andExpect(status().isForbidden()); + } + + @Test + @WithOpaqueToken("ch4mp.json") + void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception { + api.get("/secured-route").andExpect(status().isOk()); + } + + @Test + @WithOpaqueToken("tonton-pirate.json") + void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenForbidden() + throws Exception { + api.get("/secured-method").andExpect(status().isForbidden()); + } + + @Test + @WithOpaqueToken("ch4mp.json") + void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenOk() throws Exception { + api.get("/secured-method").andExpect(status().isOk()); + } } diff --git a/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerFluentApiTest.java b/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerFluentApiTest.java deleted file mode 100644 index d865204dc..000000000 --- a/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerFluentApiTest.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2019 Jérôme Wacongne. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - */ -package com.c4_soft.springaddons.samples.webmvc_oidcauthentication; - -import static com.c4_soft.springaddons.security.oauth2.test.webmvc.OidcIdAuthenticationTokenRequestPostProcessor.mockOidcId; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; - -import com.c4_soft.springaddons.security.oauth2.test.webmvc.AutoConfigureAddonsWebmvcResourceServerSecurity; -import com.c4_soft.springaddons.security.oauth2.test.webmvc.MockMvcSupport; -import com.c4_soft.springaddons.security.oauth2.test.webmvc.OidcIdAuthenticationTokenRequestPostProcessor; -import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - -/** - * @author Jérôme Wacongne <ch4mp@c4-soft.com> - */ -@WebMvcTest(GreetingController.class) -@AutoConfigureAddonsWebmvcResourceServerSecurity -@Import({ SecurityConfig.class }) -class GreetingControllerFluentApiTest { - - @MockBean - private MessageService messageService; - - @Autowired - MockMvcSupport api; - - @BeforeEach - public void setUp() { - when(messageService.greet(any())).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") - final OAuthentication auth = invocation.getArgument(0, OAuthentication.class); - return String.format("Hello %s! You are granted with %s.", auth.getName(), auth.getAuthorities()); - }); - when(messageService.getSecret()).thenReturn("Secret message"); - } - - @Test - void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception { - api.with(anonymous()).get("/greet").andExpect(status().isUnauthorized()); - } - - @Test - void givenUserIsAuthenticated_whenGetGreet_thenOk() throws Exception { - api.with(mockOidcId().token(token -> token.subject("user"))).get("/greet").andExpect(content().string("Hello user! You are granted with [].")); - } - - @Test - void givenUserIsCh4mpy_whenGetGreet_thenOk() throws Exception { - api.with(ch4mpy()).get("/greet").andExpect(content().string("Hello Ch4mpy! You are granted with [ROLE_AUTHORIZED_PERSONNEL].")); - } - - @Test - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() throws Exception { - api.with(mockOidcId()).get("/secured-route").andExpect(status().isForbidden()); - } - - @Test - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenForbidden() throws Exception { - api.with(mockOidcId()).get("/secured-method").andExpect(status().isForbidden()); - } - - @Test - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception { - api.with(ch4mpy()).get("/secured-route").andExpect(status().isOk()); - } - - @Test - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenOk() throws Exception { - api.with(ch4mpy()).get("/secured-method").andExpect(status().isOk()); - } - - private OidcIdAuthenticationTokenRequestPostProcessor ch4mpy() { - return mockOidcId().token(token -> token.subject("Ch4mpy")).authorities("ROLE_AUTHORIZED_PERSONNEL"); - } -} diff --git a/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageServiceTests.java b/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageServiceTests.java index 1f829cf38..dda99e27e 100644 --- a/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageServiceTests.java +++ b/samples/webmvc-introspecting-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageServiceTests.java @@ -1,13 +1,15 @@ /* * Copyright 2019 Jérôme Wacongne. * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webmvc_oidcauthentication; @@ -15,7 +17,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,11 +24,10 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.test.context.support.WithAnonymousUser; - import com.c4_soft.springaddons.security.oauth2.test.annotations.WithOpaqueToken; import com.c4_soft.springaddons.security.oauth2.test.webmvc.AddonsWebmvcComponentTest; import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; /** *

Unit-test a secured service or repository which has injected dependencies

@@ -37,53 +37,55 @@ // Import security configuration and test component @AddonsWebmvcComponentTest -@SpringBootTest(classes = { SecurityConfig.class, MessageService.class }) +@SpringBootTest(classes = {SecurityConfig.class, MessageService.class}) class MessageServiceTests { - // auto-wire tested component - @Autowired - private MessageService messageService; + // auto-wire tested component + @Autowired + private MessageService messageService; - // mock dependencies - @MockBean - SecretRepo secretRepo; + // mock dependencies + @MockBean + SecretRepo secretRepo; - @BeforeEach - public void setUp() { - when(secretRepo.findSecretByUsername(anyString())).thenReturn("incredible"); - } + @BeforeEach + public void setUp() { + when(secretRepo.findSecretByUsername(anyString())).thenReturn("incredible"); + } - @Test - @WithAnonymousUser - void givenRequestIsAnonymous_whenGetSecret_thenThrows() { - // call tested components methods directly (do not use MockMvc nor WebTestClient) - assertThrows(Exception.class, () -> messageService.getSecret()); - } + @Test + @WithAnonymousUser + void givenRequestIsAnonymous_whenGetSecret_thenThrows() { + // call tested components methods directly (do not use MockMvc nor WebTestClient) + assertThrows(Exception.class, () -> messageService.getSecret()); + } - @Test - @WithAnonymousUser - void givenRequestIsAnonymous_whenGetGreet_thenThrows() { - assertThrows(Exception.class, () -> messageService.greet(null)); - } + @Test + @WithAnonymousUser + void givenRequestIsAnonymous_whenGetGreet_thenThrows() { + assertThrows(Exception.class, () -> messageService.greet(null)); + } - @Test - @WithOpaqueToken("tonton-pirate.json") - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecret_thenThrows() { - assertThrows(Exception.class, () -> messageService.getSecret()); - } + @Test + @WithOpaqueToken("tonton-pirate.json") + void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecret_thenThrows() { + assertThrows(Exception.class, () -> messageService.getSecret()); + } - @Test - @WithOpaqueToken("ch4mp.json") - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecret_thenReturnsSecret() { - assertThat(messageService.getSecret()).isEqualTo("incredible"); - } + @Test + @WithOpaqueToken("ch4mp.json") + void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecret_thenReturnsSecret() { + assertThat(messageService.getSecret()).isEqualTo("incredible"); + } - @SuppressWarnings("unchecked") - @Test - @WithOpaqueToken("ch4mp.json") - void givenUserIsAuthenticated_whenGetGreet_thenReturnsGreeting() { - final var auth = (OAuthentication) SecurityContextHolder.getContext().getAuthentication(); + @SuppressWarnings("unchecked") + @Test + @WithOpaqueToken("ch4mp.json") + void givenUserIsAuthenticated_whenGetGreet_thenReturnsGreeting() { + final var auth = + (OAuthentication) SecurityContextHolder.getContext().getAuthentication(); - assertThat(messageService.greet(auth)).isEqualTo("Hello ch4mp! You are granted with [NICE, AUTHOR, ROLE_AUTHORIZED_PERSONNEL]."); - } + assertThat(messageService.greet(auth)) + .isEqualTo("Hello ch4mp! You are granted with [NICE, AUTHOR, ROLE_AUTHORIZED_PERSONNEL]."); + } } diff --git a/samples/webmvc-jwt-default-jpa-authorities/pom.xml b/samples/webmvc-jwt-default-jpa-authorities/pom.xml index d9376dcb7..07cfb1d01 100644 --- a/samples/webmvc-jwt-default-jpa-authorities/pom.xml +++ b/samples/webmvc-jwt-default-jpa-authorities/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples spring-addons-samples - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. @@ -98,14 +98,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - \ No newline at end of file diff --git a/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/PersistedGrantedAuthoritiesRetriever.java b/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/PersistedGrantedAuthoritiesRetriever.java index f5cd364fc..89c0825ce 100644 --- a/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/PersistedGrantedAuthoritiesRetriever.java +++ b/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/PersistedGrantedAuthoritiesRetriever.java @@ -25,7 +25,7 @@ import com.c4_soft.springaddons.security.oidc.starter.ClaimSetAuthoritiesConverter; /** - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public class PersistedGrantedAuthoritiesRetriever implements ClaimSetAuthoritiesConverter { diff --git a/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthority.java b/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthority.java index e76cb7b5d..e8dc53f47 100644 --- a/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthority.java +++ b/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthority.java @@ -18,7 +18,7 @@ import lombok.Data; /** - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ @Entity @Data diff --git a/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthorityId.java b/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthorityId.java index 66be25053..6e53eef31 100644 --- a/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthorityId.java +++ b/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthorityId.java @@ -19,7 +19,7 @@ import lombok.Data; /** - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ @Embeddable @Data diff --git a/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthorityRepository.java b/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthorityRepository.java index a5dd977fa..da0b81832 100644 --- a/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthorityRepository.java +++ b/samples/webmvc-jwt-default-jpa-authorities/src/main/java/com/c4_soft/springaddons/samples/webmvc_jwtauthenticationtoken_jpa_authorities/UserAuthorityRepository.java @@ -17,7 +17,7 @@ import org.springframework.data.jpa.repository.JpaRepository; /** - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public interface UserAuthorityRepository extends JpaRepository { diff --git a/samples/webmvc-jwt-default/pom.xml b/samples/webmvc-jwt-default/pom.xml index 3ea2940d7..86d051702 100644 --- a/samples/webmvc-jwt-default/pom.xml +++ b/samples/webmvc-jwt-default/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples spring-addons-samples - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. @@ -83,14 +83,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - \ No newline at end of file diff --git a/samples/webmvc-jwt-oauthentication/pom.xml b/samples/webmvc-jwt-oauthentication/pom.xml index a1c9f5370..2f4258c65 100644 --- a/samples/webmvc-jwt-oauthentication/pom.xml +++ b/samples/webmvc-jwt-oauthentication/pom.xml @@ -4,7 +4,7 @@ com.c4-soft.springaddons.samples spring-addons-samples - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. @@ -83,14 +83,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - org.graalvm.buildtools - native-maven-plugin - \ No newline at end of file diff --git a/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingController.java b/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingController.java index 9fb3bf3e1..f6595b31d 100644 --- a/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingController.java +++ b/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingController.java @@ -1,40 +1,38 @@ package com.c4_soft.springaddons.samples.webmvc_oidcauthentication; import java.util.Map; - import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - +import com.c4_soft.springaddons.security.oidc.OpenidToken; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor public class GreetingController { - private final MessageService messageService; - - @GetMapping("/greet") - public String greet(OAuthentication auth) { - return messageService.greet(auth); - } - - @GetMapping("/secured-route") - public String securedRoute() { - return messageService.getSecret(); - } - - @GetMapping("/secured-method") - @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") - public String securedMethod() { - return messageService.getSecret(); - } - - @GetMapping("/claims") - public Map getClaims(@AuthenticationPrincipal OpenidClaimSet token) { - return token; - } + private final MessageService messageService; + + @GetMapping("/greet") + public String greet(OAuthentication auth) { + return messageService.greet(auth); + } + + @GetMapping("/secured-route") + public String securedRoute() { + return messageService.getSecret(); + } + + @GetMapping("/secured-method") + @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") + public String securedMethod() { + return messageService.getSecret(); + } + + @GetMapping("/claims") + public Map getClaims(@AuthenticationPrincipal OpenidClaimSet token) { + return token; + } } diff --git a/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageService.java b/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageService.java index dddd9bb4e..7dc681f31 100644 --- a/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageService.java +++ b/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageService.java @@ -1,13 +1,15 @@ /* * Copyright 2019 Jérôme Wacongne * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webmvc_oidcauthentication; @@ -15,25 +17,24 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; - import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - +import com.c4_soft.springaddons.security.oidc.OpenidToken; import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor public class MessageService { - private final SecretRepo fooRepo; + private final SecretRepo fooRepo; - @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") - public String getSecret() { - return fooRepo.findSecretByUsername(SecurityContextHolder.getContext().getAuthentication().getName()); - } + @PreAuthorize("hasRole('AUTHORIZED_PERSONNEL')") + public String getSecret() { + return fooRepo + .findSecretByUsername(SecurityContextHolder.getContext().getAuthentication().getName()); + } - @PreAuthorize("isAuthenticated()") - public String greet(OAuthentication who) { - return String.format("Hello %s! You are granted with %s.", who.getName(), who.getAuthorities()); - } + @PreAuthorize("isAuthenticated()") + public String greet(OAuthentication who) { + return String.format("Hello %s! You are granted with %s.", who.getName(), who.getAuthorities()); + } -} \ No newline at end of file +} diff --git a/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java b/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java index b97f79d49..bd42e0378 100644 --- a/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java +++ b/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java @@ -2,7 +2,6 @@ import java.util.Collection; import java.util.Map; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.converter.Converter; @@ -11,9 +10,9 @@ import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.JwtAbstractAuthenticationTokenConverter; @@ -22,25 +21,26 @@ @Configuration @EnableMethodSecurity public class SecurityConfig { - @Bean - JwtAbstractAuthenticationTokenConverter authenticationConverter( - Converter, Collection> authoritiesConverter, - OpenidProviderPropertiesResolver opPropertiesResolver) { - return jwt -> new OAuthentication<>( - new OpenidClaimSet( - jwt.getClaims(), - opPropertiesResolver.resolve(jwt.getClaims()).orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())).getUsernameClaim()), - authoritiesConverter.convert(jwt.getClaims()), - jwt.getTokenValue()); + @Bean + JwtAbstractAuthenticationTokenConverter authenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return jwt -> new OAuthentication<>( + new OpenidToken(new OpenidClaimSet(jwt.getClaims(), + opPropertiesResolver.resolve(jwt.getClaims()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())) + .getUsernameClaim()), + jwt.getTokenValue()), + authoritiesConverter.convert(jwt.getClaims())); - } + } - @Bean - ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { - // @formatter:off + @Bean + ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { + // @formatter:off return (AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry) -> registry .requestMatchers(new AntPathRequestMatcher("/secured-route")).hasRole("AUTHORIZED_PERSONNEL") .anyRequest().authenticated(); // @formatter:on - } + } } diff --git a/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerAnnotatedTest.java b/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerAnnotatedTest.java index c49170650..c6589fc3b 100644 --- a/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerAnnotatedTest.java +++ b/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerAnnotatedTest.java @@ -1,13 +1,15 @@ /* * Copyright 2019 Jérôme Wacongne. * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webmvc_oidcauthentication; @@ -15,76 +17,78 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; - import com.c4_soft.springaddons.security.oauth2.test.annotations.WithJwt; import com.c4_soft.springaddons.security.oauth2.test.webmvc.AutoConfigureAddonsWebmvcResourceServerSecurity; import com.c4_soft.springaddons.security.oauth2.test.webmvc.MockMvcSupport; import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; /** * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ @WebMvcTest(GreetingController.class) @AutoConfigureAddonsWebmvcResourceServerSecurity -@Import({ SecurityConfig.class }) +@Import({SecurityConfig.class}) class GreetingControllerAnnotatedTest { - @MockBean - private MessageService messageService; + @MockBean + private MessageService messageService; - @Autowired - MockMvcSupport api; + @Autowired + MockMvcSupport api; - @SuppressWarnings("unchecked") - @BeforeEach - public void setUp() { - when(messageService.greet(any(OAuthentication.class))).thenAnswer(invocation -> { - final OAuthentication auth = invocation.getArgument(0, OAuthentication.class); - return String.format("Hello %s! You are granted with %s.", auth.getName(), auth.getAuthorities()); - }); - when(messageService.getSecret()).thenReturn("Secret message"); - } + @SuppressWarnings("unchecked") + @BeforeEach + public void setUp() { + when(messageService.greet(any(OAuthentication.class))).thenAnswer(invocation -> { + final OAuthentication auth = invocation.getArgument(0, OAuthentication.class); + return String.format("Hello %s! You are granted with %s.", auth.getName(), + auth.getAuthorities()); + }); + when(messageService.getSecret()).thenReturn("Secret message"); + } - @Test - void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception { - api.get("/greet").andExpect(status().isUnauthorized()); - } + @Test + void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception { + api.get("/greet").andExpect(status().isUnauthorized()); + } - @Test - @WithJwt("ch4mp.json") - void givenUserIsCh4mpy_whenGetGreet_thenOk() throws Exception { - api.get("/greet").andExpect(content().string("Hello ch4mp! You are granted with [USER_ROLES_EDITOR, ROLE_AUTHORIZED_PERSONNEL].")); - } + @Test + @WithJwt("ch4mp.json") + void givenUserIsCh4mpy_whenGetGreet_thenOk() throws Exception { + api.get("/greet").andExpect(content().string( + "Hello ch4mp! You are granted with [USER_ROLES_EDITOR, ROLE_AUTHORIZED_PERSONNEL].")); + } - @Test - @WithJwt("tonton-pirate.json") - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() throws Exception { - api.get("/secured-route").andExpect(status().isForbidden()); - } + @Test + @WithJwt("tonton-pirate.json") + void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() + throws Exception { + api.get("/secured-route").andExpect(status().isForbidden()); + } - @Test - @WithJwt("ch4mp.json") - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception { - api.get("/secured-route").andExpect(status().isOk()); - } + @Test + @WithJwt("ch4mp.json") + void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception { + api.get("/secured-route").andExpect(status().isOk()); + } - @Test - @WithJwt("tonton-pirate.json") - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenForbidden() throws Exception { - api.get("/secured-method").andExpect(status().isForbidden()); - } + @Test + @WithJwt("tonton-pirate.json") + void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenForbidden() + throws Exception { + api.get("/secured-method").andExpect(status().isForbidden()); + } - @Test - @WithJwt("ch4mp.json") - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenOk() throws Exception { - api.get("/secured-method").andExpect(status().isOk()); - } + @Test + @WithJwt("ch4mp.json") + void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenOk() throws Exception { + api.get("/secured-method").andExpect(status().isOk()); + } } diff --git a/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerFluentApiTest.java b/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerFluentApiTest.java deleted file mode 100644 index d865204dc..000000000 --- a/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/GreetingControllerFluentApiTest.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2019 Jérôme Wacongne. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - */ -package com.c4_soft.springaddons.samples.webmvc_oidcauthentication; - -import static com.c4_soft.springaddons.security.oauth2.test.webmvc.OidcIdAuthenticationTokenRequestPostProcessor.mockOidcId; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; - -import com.c4_soft.springaddons.security.oauth2.test.webmvc.AutoConfigureAddonsWebmvcResourceServerSecurity; -import com.c4_soft.springaddons.security.oauth2.test.webmvc.MockMvcSupport; -import com.c4_soft.springaddons.security.oauth2.test.webmvc.OidcIdAuthenticationTokenRequestPostProcessor; -import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - -/** - * @author Jérôme Wacongne <ch4mp@c4-soft.com> - */ -@WebMvcTest(GreetingController.class) -@AutoConfigureAddonsWebmvcResourceServerSecurity -@Import({ SecurityConfig.class }) -class GreetingControllerFluentApiTest { - - @MockBean - private MessageService messageService; - - @Autowired - MockMvcSupport api; - - @BeforeEach - public void setUp() { - when(messageService.greet(any())).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") - final OAuthentication auth = invocation.getArgument(0, OAuthentication.class); - return String.format("Hello %s! You are granted with %s.", auth.getName(), auth.getAuthorities()); - }); - when(messageService.getSecret()).thenReturn("Secret message"); - } - - @Test - void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception { - api.with(anonymous()).get("/greet").andExpect(status().isUnauthorized()); - } - - @Test - void givenUserIsAuthenticated_whenGetGreet_thenOk() throws Exception { - api.with(mockOidcId().token(token -> token.subject("user"))).get("/greet").andExpect(content().string("Hello user! You are granted with [].")); - } - - @Test - void givenUserIsCh4mpy_whenGetGreet_thenOk() throws Exception { - api.with(ch4mpy()).get("/greet").andExpect(content().string("Hello Ch4mpy! You are granted with [ROLE_AUTHORIZED_PERSONNEL].")); - } - - @Test - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() throws Exception { - api.with(mockOidcId()).get("/secured-route").andExpect(status().isForbidden()); - } - - @Test - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenForbidden() throws Exception { - api.with(mockOidcId()).get("/secured-method").andExpect(status().isForbidden()); - } - - @Test - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception { - api.with(ch4mpy()).get("/secured-route").andExpect(status().isOk()); - } - - @Test - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecuredMethod_thenOk() throws Exception { - api.with(ch4mpy()).get("/secured-method").andExpect(status().isOk()); - } - - private OidcIdAuthenticationTokenRequestPostProcessor ch4mpy() { - return mockOidcId().token(token -> token.subject("Ch4mpy")).authorities("ROLE_AUTHORIZED_PERSONNEL"); - } -} diff --git a/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageServiceTests.java b/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageServiceTests.java index 96dde8777..31404a1ac 100644 --- a/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageServiceTests.java +++ b/samples/webmvc-jwt-oauthentication/src/test/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/MessageServiceTests.java @@ -1,13 +1,15 @@ /* * Copyright 2019 Jérôme Wacongne. * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.samples.webmvc_oidcauthentication; @@ -15,9 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; - import java.util.stream.Stream; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -27,12 +27,11 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.Authentication; - import com.c4_soft.springaddons.security.oauth2.test.annotations.WithJwt; import com.c4_soft.springaddons.security.oauth2.test.annotations.parameterized.ParameterizedAuthentication; import com.c4_soft.springaddons.security.oauth2.test.webmvc.AddonsWebmvcComponentTest; import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; /** *

Unit-test a secured service or repository which has injected dependencies

@@ -42,60 +41,62 @@ // Import security configuration and test component @AddonsWebmvcComponentTest -@SpringBootTest(classes = { SecurityConfig.class, MessageService.class }) +@SpringBootTest(classes = {SecurityConfig.class, MessageService.class}) class MessageServiceTests { - // auto-wire tested component - @Autowired - private MessageService messageService; - - @Autowired - WithJwt.AuthenticationFactory authFactory; - - // mock dependencies - @MockBean - SecretRepo secretRepo; - - @BeforeEach - public void setUp() { - when(secretRepo.findSecretByUsername(anyString())).thenReturn("incredible"); - } - - @Test() - void givenRequestIsAnonymous_whenGetSecret_thenThrows() { - // call tested components methods directly (do not use MockMvc nor WebTestClient) - assertThrows(Exception.class, () -> messageService.getSecret()); - } - - @Test() - void givenRequestIsAnonymous_whenGetGreet_thenThrows() { - assertThrows(Exception.class, () -> messageService.greet(null)); - } - - /*--------------*/ - /* @WithMockJwt */ - /*--------------*/ - @Test() - @WithJwt("tonton-pirate.json") - void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecret_thenThrows() { - assertThrows(Exception.class, () -> messageService.getSecret()); - } - - @Test - @WithJwt("ch4mp.json") - void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecret_thenReturnsSecret() { - assertThat(messageService.getSecret()).isEqualTo("incredible"); - } - - @SuppressWarnings("unchecked") - @ParameterizedTest - @MethodSource("identities") - void givenUserIsAuthenticated_whenGetGreet_thenReturnsGreeting(@ParameterizedAuthentication Authentication auth) { - final var oauth = (OAuthentication) auth; - assertThat(messageService.greet(oauth)).isEqualTo("Hello %s! You are granted with %s.".formatted(auth.getName(), auth.getAuthorities())); - } - - Stream identities() { - return authFactory.authenticationsFrom("ch4mp.json", "tonton-pirate.json"); - } + // auto-wire tested component + @Autowired + private MessageService messageService; + + @Autowired + WithJwt.AuthenticationFactory authFactory; + + // mock dependencies + @MockBean + SecretRepo secretRepo; + + @BeforeEach + public void setUp() { + when(secretRepo.findSecretByUsername(anyString())).thenReturn("incredible"); + } + + @Test() + void givenRequestIsAnonymous_whenGetSecret_thenThrows() { + // call tested components methods directly (do not use MockMvc nor WebTestClient) + assertThrows(Exception.class, () -> messageService.getSecret()); + } + + @Test() + void givenRequestIsAnonymous_whenGetGreet_thenThrows() { + assertThrows(Exception.class, () -> messageService.greet(null)); + } + + /*--------------*/ + /* @WithMockJwt */ + /*--------------*/ + @Test() + @WithJwt("tonton-pirate.json") + void givenUserIsNotGrantedWithAuthorizedPersonnel_whenGetSecret_thenThrows() { + assertThrows(Exception.class, () -> messageService.getSecret()); + } + + @Test + @WithJwt("ch4mp.json") + void givenUserIsGrantedWithAuthorizedPersonnel_whenGetSecret_thenReturnsSecret() { + assertThat(messageService.getSecret()).isEqualTo("incredible"); + } + + @SuppressWarnings("unchecked") + @ParameterizedTest + @MethodSource("identities") + void givenUserIsAuthenticated_whenGetGreet_thenReturnsGreeting( + @ParameterizedAuthentication Authentication auth) { + final var oauth = (OAuthentication) auth; + assertThat(messageService.greet(oauth)).isEqualTo( + "Hello %s! You are granted with %s.".formatted(auth.getName(), auth.getAuthorities())); + } + + Stream identities() { + return authFactory.authenticationsFrom("ch4mp.json", "tonton-pirate.json"); + } } diff --git a/spring-addons-oauth2-test/pom.xml b/spring-addons-oauth2-test/pom.xml index 3669adbb6..695b33d06 100644 --- a/spring-addons-oauth2-test/pom.xml +++ b/spring-addons-oauth2-test/pom.xml @@ -5,7 +5,7 @@ com.c4-soft.springaddons spring-addons - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. spring-addons-oauth2-test @@ -51,6 +51,7 @@ org.apache.tomcat.embed tomcat-embed-core + true org.junit.jupiter diff --git a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/AuthenticationBuilder.java b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/AuthenticationBuilder.java index d670dcf59..dd6e35113 100644 --- a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/AuthenticationBuilder.java +++ b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/AuthenticationBuilder.java @@ -16,7 +16,7 @@ /** * Common interface for test authentication builders * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> * @param capture for extending class type */ public interface AuthenticationBuilder { diff --git a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/Defaults.java b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/Defaults.java index 38482a554..d28a73b8f 100644 --- a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/Defaults.java +++ b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/Defaults.java @@ -20,7 +20,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; /** - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public class Defaults { private Defaults() { diff --git a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OAuthenticationTestingBuilder.java b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OAuthenticationTestingBuilder.java index 5ac38e15a..bcc4a2f5a 100644 --- a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OAuthenticationTestingBuilder.java +++ b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OAuthenticationTestingBuilder.java @@ -1,13 +1,15 @@ /* * Copyright 2020 Jérôme Wacongne. * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.security.oauth2.test; @@ -16,46 +18,40 @@ import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; - import org.springframework.security.core.authority.SimpleGrantedAuthority; - import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - -public class OAuthenticationTestingBuilder> implements AuthenticationBuilder> { - - protected final OpenidClaimSetBuilder tokenBuilder; - private final Set authorities; - private String bearerString = "machin.truc.chose"; - - public OAuthenticationTestingBuilder() { - this.tokenBuilder = new OpenidClaimSetBuilder().subject(Defaults.SUBJECT).name(Defaults.AUTH_NAME); - this.authorities = new HashSet<>(Defaults.AUTHORITIES); - } - - @Override - public OAuthentication build() { - return new OAuthentication<>(tokenBuilder.build(), authorities.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet()), bearerString); - } - - public T authorities(String... authorities) { - this.authorities.clear(); - this.authorities.addAll(Arrays.asList(authorities)); - return downcast(); - } - - public T token(Consumer tokenBuilderConsumer) { - tokenBuilderConsumer.accept(tokenBuilder); - return downcast(); - } - - public T bearerString(String bearerString) { - this.bearerString = bearerString; - return downcast(); - } - - @SuppressWarnings("unchecked") - protected T downcast() { - return (T) this; - } -} \ No newline at end of file +import com.c4_soft.springaddons.security.oidc.OpenidToken; + +public class OAuthenticationTestingBuilder + implements AuthenticationBuilder> { + + protected final OpenidTokenBuilder tokenBuilder; + private final Set authorities; + + public OAuthenticationTestingBuilder() { + this.tokenBuilder = new OpenidTokenBuilder().subject(Defaults.SUBJECT).name(Defaults.AUTH_NAME); + this.authorities = new HashSet<>(Defaults.AUTHORITIES); + } + + @Override + public OAuthentication build() { + return new OAuthentication<>(tokenBuilder.build(), + authorities.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet())); + } + + public OAuthenticationTestingBuilder authorities(String... authorities) { + this.authorities.clear(); + this.authorities.addAll(Arrays.asList(authorities)); + return this; + } + + public OAuthenticationTestingBuilder token(Consumer tokenBuilderConsumer) { + tokenBuilderConsumer.accept(tokenBuilder); + return this; + } + + public OAuthenticationTestingBuilder bearerString(String bearerString) { + this.tokenBuilder.tokenValue(bearerString); + return this; + } +} diff --git a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OpenidClaimSetBuilder.java b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OpenidClaimSetBuilder.java index c5e4dd3b4..e3f83835c 100644 --- a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OpenidClaimSetBuilder.java +++ b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OpenidClaimSetBuilder.java @@ -1,13 +1,15 @@ /* * Copyright 2020 Jérôme Wacongne * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.security.oauth2.test; @@ -16,12 +18,10 @@ import java.util.Collection; import java.util.List; import java.util.Map; - import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; import org.springframework.security.oauth2.core.oidc.StandardClaimNames; import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.util.StringUtils; - import com.c4_soft.springaddons.security.oidc.ModifiableClaimSet; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; @@ -30,254 +30,258 @@ * * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ -public class OpenidClaimSetBuilder extends ModifiableClaimSet { +public class OpenidClaimSetBuilder> extends ModifiableClaimSet { + + private static final long serialVersionUID = 8050195176203128543L; - private static final long serialVersionUID = 8050195176203128543L; + private String usernameClaim = StandardClaimNames.SUB; - private String usernameClaim = StandardClaimNames.SUB; + public OpenidClaimSetBuilder() {} - public OpenidClaimSetBuilder() { - } + public OpenidClaimSetBuilder(Map ptivateClaims) { + super(ptivateClaims); + } - public OpenidClaimSetBuilder(Map ptivateClaims) { - super(ptivateClaims); - } + public OpenidClaimSet build() { + return new OpenidClaimSet(this, usernameClaim); + } - public OpenidClaimSet build() { - return new OpenidClaimSet(this, usernameClaim); - } + @SuppressWarnings("unchecked") + private T cast() { + return (T) this; + } - public OpenidClaimSetBuilder usernameClaim(String usernameClaim) { - this.usernameClaim = usernameClaim; - return this; - } + public T usernameClaim(String usernameClaim) { + this.usernameClaim = usernameClaim; + return cast(); + } - public OpenidClaimSetBuilder acr(String acr) { - return setIfNonEmpty(IdTokenClaimNames.ACR, acr); - } + public T acr(String acr) { + return setIfNonEmpty(IdTokenClaimNames.ACR, acr); + } - public OpenidClaimSetBuilder amr(List amr) { - return setIfNonEmpty(IdTokenClaimNames.AMR, amr); - } + public T amr(List amr) { + return setIfNonEmpty(IdTokenClaimNames.AMR, amr); + } - public OpenidClaimSetBuilder audience(List audience) { - return setIfNonEmpty(IdTokenClaimNames.AUD, audience); - } + public T audience(List audience) { + return setIfNonEmpty(IdTokenClaimNames.AUD, audience); + } - public OpenidClaimSetBuilder authTime(Instant authTime) { - return setIfNonEmpty(IdTokenClaimNames.AUTH_TIME, authTime); - } + public T authTime(Instant authTime) { + return setIfNonEmpty(IdTokenClaimNames.AUTH_TIME, authTime); + } - public OpenidClaimSetBuilder azp(String azp) { - return setIfNonEmpty(IdTokenClaimNames.AZP, azp); - } + public T azp(String azp) { + return setIfNonEmpty(IdTokenClaimNames.AZP, azp); + } - public OpenidClaimSetBuilder expiresAt(Instant expiresAt) { - return setIfNonEmpty(IdTokenClaimNames.EXP, expiresAt); - } + public T expiresAt(Instant expiresAt) { + return setIfNonEmpty(IdTokenClaimNames.EXP, expiresAt); + } - public OpenidClaimSetBuilder issuedAt(Instant issuedAt) { - return setIfNonEmpty(IdTokenClaimNames.IAT, issuedAt); - } + public T issuedAt(Instant issuedAt) { + return setIfNonEmpty(IdTokenClaimNames.IAT, issuedAt); + } - public OpenidClaimSetBuilder jwtId(String jti) { - return setIfNonEmpty(JwtClaimNames.JTI, jti); - } + public T jwtId(String jti) { + return setIfNonEmpty(JwtClaimNames.JTI, jti); + } - public OpenidClaimSetBuilder issuer(URL issuer) { - return setIfNonEmpty(IdTokenClaimNames.ISS, issuer.toString()); - } + public T issuer(URL issuer) { + return setIfNonEmpty(IdTokenClaimNames.ISS, issuer.toString()); + } - public OpenidClaimSetBuilder nonce(String nonce) { - return setIfNonEmpty(IdTokenClaimNames.NONCE, nonce); - } + public T nonce(String nonce) { + return setIfNonEmpty(IdTokenClaimNames.NONCE, nonce); + } - public OpenidClaimSetBuilder notBefore(Instant nbf) { - return setIfNonEmpty(JwtClaimNames.NBF, nbf); - } + public T notBefore(Instant nbf) { + return setIfNonEmpty(JwtClaimNames.NBF, nbf); + } - public OpenidClaimSetBuilder accessTokenHash(String atHash) { - return setIfNonEmpty(IdTokenClaimNames.AT_HASH, atHash); - } + public T accessTokenHash(String atHash) { + return setIfNonEmpty(IdTokenClaimNames.AT_HASH, atHash); + } - public OpenidClaimSetBuilder authorizationCodeHash(String cHash) { - return setIfNonEmpty(IdTokenClaimNames.C_HASH, cHash); - } + public T authorizationCodeHash(String cHash) { + return setIfNonEmpty(IdTokenClaimNames.C_HASH, cHash); + } - public OpenidClaimSetBuilder sessionState(String sessionState) { - return setIfNonEmpty("session_state", sessionState); - } + public T sessionState(String sessionState) { + return setIfNonEmpty("session_state", sessionState); + } - public OpenidClaimSetBuilder subject(String subject) { - return setIfNonEmpty(IdTokenClaimNames.SUB, subject); - } - - public OpenidClaimSetBuilder name(String value) { - return setIfNonEmpty(StandardClaimNames.NAME, value); - } - - public OpenidClaimSetBuilder givenName(String value) { - return setIfNonEmpty(StandardClaimNames.GIVEN_NAME, value); - } - - public OpenidClaimSetBuilder familyName(String value) { - return setIfNonEmpty(StandardClaimNames.FAMILY_NAME, value); - } - - public OpenidClaimSetBuilder middleName(String value) { - return setIfNonEmpty(StandardClaimNames.MIDDLE_NAME, value); - } - - public OpenidClaimSetBuilder nickname(String value) { - return setIfNonEmpty(StandardClaimNames.NICKNAME, value); - } - - public OpenidClaimSetBuilder preferredUsername(String value) { - return setIfNonEmpty(StandardClaimNames.PREFERRED_USERNAME, value); - } - - public OpenidClaimSetBuilder profile(String value) { - return setIfNonEmpty(StandardClaimNames.PROFILE, value); - } - - public OpenidClaimSetBuilder picture(String value) { - return setIfNonEmpty(StandardClaimNames.PICTURE, value); - } - - public OpenidClaimSetBuilder website(String value) { - return setIfNonEmpty(StandardClaimNames.WEBSITE, value); - } - - public OpenidClaimSetBuilder email(String value) { - return setIfNonEmpty(StandardClaimNames.EMAIL, value); - } - - public OpenidClaimSetBuilder emailVerified(Boolean value) { - return setIfNonEmpty(StandardClaimNames.EMAIL_VERIFIED, value); - } - - public OpenidClaimSetBuilder gender(String value) { - return setIfNonEmpty(StandardClaimNames.GENDER, value); - } - - public OpenidClaimSetBuilder birthdate(String value) { - return setIfNonEmpty(StandardClaimNames.BIRTHDATE, value); - } - - public OpenidClaimSetBuilder zoneinfo(String value) { - return setIfNonEmpty(StandardClaimNames.ZONEINFO, value); - } - - public OpenidClaimSetBuilder locale(String value) { - return setIfNonEmpty(StandardClaimNames.LOCALE, value); - } - - public OpenidClaimSetBuilder phoneNumber(String value) { - return setIfNonEmpty(StandardClaimNames.PHONE_NUMBER, value); - } - - public OpenidClaimSetBuilder phoneNumberVerified(Boolean value) { - return setIfNonEmpty(StandardClaimNames.PHONE_NUMBER_VERIFIED, value); - } - - public OpenidClaimSetBuilder address(AddressClaim value) { - if (value == null) { - this.remove("address"); - } else { - this.put("address", value); - } - return this; - } - - public OpenidClaimSetBuilder claims(Map claims) { - this.putAll(claims); - return this; - } - - public OpenidClaimSetBuilder privateClaims(Map claims) { - return this.claims(claims); - } - - public OpenidClaimSetBuilder otherClaims(Map claims) { - return this.claims(claims); - } - - public OpenidClaimSetBuilder updatedAt(Instant value) { - return setIfNonEmpty("", value); - } - - protected OpenidClaimSetBuilder setIfNonEmpty(String claimName, String claimValue) { - if (StringUtils.hasText(claimValue)) { - this.put(claimName, claimValue); - } else { - this.remove(claimName); - } - return this; - } - - protected OpenidClaimSetBuilder setIfNonEmpty(String claimName, Collection claimValue) { - if (claimValue == null || claimValue.isEmpty()) { - this.remove(claimName); - } else if (claimValue.isEmpty()) { - this.setIfNonEmpty(claimName, claimValue.iterator().next()); - } else { - this.put(claimName, claimValue); - } - return this; - } - - protected OpenidClaimSetBuilder setIfNonEmpty(String claimName, Instant claimValue) { - if (claimValue == null) { - this.remove(claimName); - } else { - this.put(claimName, claimValue.getEpochSecond()); - } - return this; - } - - protected OpenidClaimSetBuilder setIfNonEmpty(String claimName, Boolean claimValue) { - if (claimValue == null) { - this.remove(claimName); - } else { - this.put(claimName, claimValue); - } - return this; - } - - public static final class AddressClaim extends ModifiableClaimSet { - private static final long serialVersionUID = 28800769851008900L; - - public AddressClaim formatted(String value) { - return setIfNonEmpty("formatted", value); - } - - public AddressClaim streetAddress(String value) { - return setIfNonEmpty("street_address", value); - } - - public AddressClaim locality(String value) { - return setIfNonEmpty("locality", value); - } - - public AddressClaim region(String value) { - return setIfNonEmpty("region", value); - } - - public AddressClaim postalCode(String value) { - return setIfNonEmpty("postal_code", value); - } - - public AddressClaim country(String value) { - return setIfNonEmpty("country", value); - } - - private AddressClaim setIfNonEmpty(String claimName, String claimValue) { - if (StringUtils.hasText(claimValue)) { - this.put(claimName, claimValue); - } else { - this.remove(claimName); - } - return this; - } - } + public T subject(String subject) { + return setIfNonEmpty(IdTokenClaimNames.SUB, subject); + } + + public T name(String value) { + return setIfNonEmpty(StandardClaimNames.NAME, value); + } + + public T givenName(String value) { + return setIfNonEmpty(StandardClaimNames.GIVEN_NAME, value); + } + + public T familyName(String value) { + return setIfNonEmpty(StandardClaimNames.FAMILY_NAME, value); + } + + public T middleName(String value) { + return setIfNonEmpty(StandardClaimNames.MIDDLE_NAME, value); + } + + public T nickname(String value) { + return setIfNonEmpty(StandardClaimNames.NICKNAME, value); + } + + public T preferredUsername(String value) { + return setIfNonEmpty(StandardClaimNames.PREFERRED_USERNAME, value); + } + + public T profile(String value) { + return setIfNonEmpty(StandardClaimNames.PROFILE, value); + } + + public T picture(String value) { + return setIfNonEmpty(StandardClaimNames.PICTURE, value); + } + + public T website(String value) { + return setIfNonEmpty(StandardClaimNames.WEBSITE, value); + } + + public T email(String value) { + return setIfNonEmpty(StandardClaimNames.EMAIL, value); + } + + public T emailVerified(Boolean value) { + return setIfNonEmpty(StandardClaimNames.EMAIL_VERIFIED, value); + } + + public T gender(String value) { + return setIfNonEmpty(StandardClaimNames.GENDER, value); + } + + public T birthdate(String value) { + return setIfNonEmpty(StandardClaimNames.BIRTHDATE, value); + } + + public T zoneinfo(String value) { + return setIfNonEmpty(StandardClaimNames.ZONEINFO, value); + } + + public T locale(String value) { + return setIfNonEmpty(StandardClaimNames.LOCALE, value); + } + + public T phoneNumber(String value) { + return setIfNonEmpty(StandardClaimNames.PHONE_NUMBER, value); + } + + public T phoneNumberVerified(Boolean value) { + return setIfNonEmpty(StandardClaimNames.PHONE_NUMBER_VERIFIED, value); + } + + public T address(AddressClaim value) { + if (value == null) { + this.remove("address"); + } else { + this.put("address", value); + } + return cast(); + } + + public T claims(Map claims) { + this.putAll(claims); + return cast(); + } + + public T privateClaims(Map claims) { + return this.claims(claims); + } + + public T otherClaims(Map claims) { + return this.claims(claims); + } + + public T updatedAt(Instant value) { + return setIfNonEmpty("", value); + } + + protected T setIfNonEmpty(String claimName, String claimValue) { + if (StringUtils.hasText(claimValue)) { + this.put(claimName, claimValue); + } else { + this.remove(claimName); + } + return cast(); + } + + protected T setIfNonEmpty(String claimName, Collection claimValue) { + if (claimValue == null || claimValue.isEmpty()) { + this.remove(claimName); + } else if (claimValue.isEmpty()) { + this.setIfNonEmpty(claimName, claimValue.iterator().next()); + } else { + this.put(claimName, claimValue); + } + return cast(); + } + + protected T setIfNonEmpty(String claimName, Instant claimValue) { + if (claimValue == null) { + this.remove(claimName); + } else { + this.put(claimName, claimValue.getEpochSecond()); + } + return cast(); + } + + protected T setIfNonEmpty(String claimName, Boolean claimValue) { + if (claimValue == null) { + this.remove(claimName); + } else { + this.put(claimName, claimValue); + } + return cast(); + } + + public static final class AddressClaim extends ModifiableClaimSet { + private static final long serialVersionUID = 28800769851008900L; + + public AddressClaim formatted(String value) { + return setIfNonEmpty("formatted", value); + } + + public AddressClaim streetAddress(String value) { + return setIfNonEmpty("street_address", value); + } + + public AddressClaim locality(String value) { + return setIfNonEmpty("locality", value); + } + + public AddressClaim region(String value) { + return setIfNonEmpty("region", value); + } + + public AddressClaim postalCode(String value) { + return setIfNonEmpty("postal_code", value); + } + + public AddressClaim country(String value) { + return setIfNonEmpty("country", value); + } + + private AddressClaim setIfNonEmpty(String claimName, String claimValue) { + if (StringUtils.hasText(claimValue)) { + this.put(claimName, claimValue); + } else { + this.remove(claimName); + } + return this; + } + } } diff --git a/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OpenidTokenBuilder.java b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OpenidTokenBuilder.java new file mode 100644 index 000000000..17eef9621 --- /dev/null +++ b/spring-addons-oauth2-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/OpenidTokenBuilder.java @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Jérôme Wacongne + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.c4_soft.springaddons.security.oauth2.test; + +import com.c4_soft.springaddons.security.oidc.OpenidToken; + +/** + * https://openid.net/specs/openid-connect-core-1_0.html + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +public class OpenidTokenBuilder extends OpenidClaimSetBuilder { + private static final long serialVersionUID = -1742198682772227737L; + + private String tokenValue = "machin.truc.bidule"; + + public OpenidTokenBuilder tokenValue(String tokenValue) { + this.tokenValue = tokenValue; + return this; + } + + @Override + public OpenidToken build() { + return new OpenidToken(super.build(), tokenValue); + } +} diff --git a/spring-addons-oauth2/pom.xml b/spring-addons-oauth2/pom.xml index 190ea0977..35e2755b4 100644 --- a/spring-addons-oauth2/pom.xml +++ b/spring-addons-oauth2/pom.xml @@ -1,92 +1,99 @@ - 4.0.0 - - com.c4-soft.springaddons - spring-addons - 7.8.13-SNAPSHOT - .. - - spring-addons-oauth2 - - https://github.com/ch4mpy/spring-addons/ - - scm:git:git://github.com/ch4mpy/spring-addons.git - scm:git:git@github.com:ch4mpy/spring-addons.git - https://github.com/ch4mpy/spring-addons - spring-addons-7.8.8 - - - - - org.springframework.security - spring-security-oauth2-jose - - - org.springframework.security - spring-security-config - - - org.springframework.security - spring-security-oauth2-client - - - com.jayway.jsonpath - json-path - - - io.projectreactor - reactor-core - - - - org.springframework.boot - spring-boot-autoconfigure - provided - true - - - org.springframework.security - spring-security-config - provided - true - - - org.springframework.boot - spring-boot - provided - true - - - jakarta.servlet - jakarta.servlet-api - provided - true - - - org.projectlombok - lombok - provided - - - junit - junit - test - - - org.assertj - assertj-core - test - - - org.mockito - mockito-core - test - - - org.springframework.boot - spring-boot-configuration-processor - true - - + 4.0.0 + + com.c4-soft.springaddons + spring-addons + 8.0.0-RC2-SNAPSHOT + .. + + spring-addons-oauth2 + + https://github.com/ch4mpy/spring-addons/ + + scm:git:git://github.com/ch4mpy/spring-addons.git + scm:git:git@github.com:ch4mpy/spring-addons.git + https://github.com/ch4mpy/spring-addons + spring-addons-7.8.8 + + + + + org.springframework.security + spring-security-oauth2-jose + + + org.springframework.security + spring-security-config + + + org.springframework.security + spring-security-oauth2-client + + + com.jayway.jsonpath + json-path + + + io.projectreactor + reactor-core + true + + + + org.springframework.security + spring-security-oauth2-resource-server + true + + + + org.springframework.boot + spring-boot-autoconfigure + provided + true + + + org.springframework.security + spring-security-config + provided + true + + + org.springframework.boot + spring-boot + provided + true + + + jakarta.servlet + jakarta.servlet-api + provided + true + + + org.projectlombok + lombok + provided + + + junit + junit + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + org.springframework.boot + spring-boot-configuration-processor + true + + \ No newline at end of file diff --git a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/ClaimSet.java b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/ClaimSet.java index be921af88..0132d4ac4 100644 --- a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/ClaimSet.java +++ b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/ClaimSet.java @@ -30,7 +30,7 @@ /** * Claim-sets are collections of key-value pairs, so lets extend {@code Map} * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public interface ClaimSet extends Map, Serializable { diff --git a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/DelegatingMap.java b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/DelegatingMap.java index 6120b7adf..329d690f3 100644 --- a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/DelegatingMap.java +++ b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/DelegatingMap.java @@ -20,7 +20,7 @@ * Allows to work around some JDK limitations. For instance, {@link java.util.Collections} {@code UnmodifiableMap} can't be extended (private). With this, it is * possible to extend a Map delegating to an unmodifiable one. * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public class DelegatingMap implements Map { diff --git a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/ModifiableClaimSet.java b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/ModifiableClaimSet.java index 61e0faad3..86c6524b4 100644 --- a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/ModifiableClaimSet.java +++ b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/ModifiableClaimSet.java @@ -17,7 +17,7 @@ /** * Modifiable Map<String, Object> used to assemble claims during test setup * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public class ModifiableClaimSet extends HashMap implements ClaimSet { private static final long serialVersionUID = -1967790894352277253L; diff --git a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OAuthentication.java b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OAuthentication.java index 62f6f5981..23d8ac57c 100644 --- a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OAuthentication.java +++ b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OAuthentication.java @@ -1,13 +1,15 @@ /* * Copyright 2020 Jérôme Wacongne * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.security.oidc; @@ -15,90 +17,82 @@ import java.security.Principal; import java.util.Collection; import java.util.Map; -import java.util.Optional; - -import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken; import org.springframework.util.StringUtils; - -import lombok.Data; import lombok.EqualsAndHashCode; /** - * @author ch4mp - * @param OpenidClaimSet or any specialization. See {@link } + * @author ch4mp + * @param OpenidClaimSet or any specialization. See {@link } */ -@Data @EqualsAndHashCode(callSuper = true) -public class OAuthentication< - T extends Map & Serializable & Principal> extends AbstractAuthenticationToken implements OAuth2AuthenticatedPrincipal { - private static final long serialVersionUID = -2827891205034221389L; - - /** - * Bearer string to set as Authorization header if we ever need to call a downstream service on behalf of the same resource-owner - */ - private final String tokenString; +public class OAuthentication & Serializable & Principal & OAuth2Token> + extends AbstractOAuth2TokenAuthenticationToken implements OAuth2AuthenticatedPrincipal { + private static final long serialVersionUID = 8193642106297738796L; + /** + * Claim-set associated with the access-token (attributes retrieved from the token or + * introspection end-point) + */ + private final T token; - /** - * Claim-set associated with the access-token (attributes retrieved from the token or introspection end-point) - */ - private final T claims; + /** + * @param token OAuth2Token of any-type (a {@link OpenidToken} or any sub-type is probably + * convenient) + * @param authorities Granted authorities associated with this authentication instance + */ + public OAuthentication(T token, Collection authorities) { + super(token, authorities); + super.setAuthenticated(true); + super.setDetails(token); + this.token = token; + } - /** - * @param claims Claim-set of any-type - * @param authorities Granted authorities associated with this authentication instance - * @param tokenString Original encoded Bearer string (in case resource-server needs - */ - public OAuthentication(T claims, Collection authorities, String tokenString) { - super(authorities); - super.setAuthenticated(true); - super.setDetails(claims); - this.claims = claims; - this.tokenString = Optional.ofNullable(tokenString).map(ts -> ts.toLowerCase().startsWith("bearer ") ? ts.substring(7) : ts).orElse(null); - } + @Override + public T getTokenAttributes() { + return token; + } - @Override - public void setDetails(Object details) { - // Do nothing until spring-security 6.1.0 and - // https://github.com/spring-projects/spring-security/issues/11822 fix is - // released - // throw new RuntimeException("OAuthentication details are immutable"); - } + @Override + public void setDetails(Object details) { + throw new RuntimeException("OAuthentication details are immutable"); + } - @Override - public void setAuthenticated(boolean isAuthenticated) { - throw new RuntimeException("OAuthentication authentication status is immutable"); - } + @Override + public void setAuthenticated(boolean isAuthenticated) { + throw new RuntimeException("OAuthentication authentication status is immutable"); + } - @Override - public String getCredentials() { - return tokenString; - } + @Override + public String getCredentials() { + return token.getTokenValue(); + } - @Override - public String getName() { - return getPrincipal().getName(); - } + @Override + public String getName() { + return getPrincipal().getName(); + } - @Override - public T getPrincipal() { - return claims; - } + @Override + public T getPrincipal() { + return token; + } - @Override - public T getAttributes() { - return claims; - } + @Override + public T getAttributes() { + return token; + } - public T getClaims() { - return claims; - } + public T getClaims() { + return token; + } - public String getBearerHeader() { - if (!StringUtils.hasText(tokenString)) { - return null; - } - return String.format("Bearer %s", tokenString); - } -} \ No newline at end of file + public String getBearerHeader() { + if (!StringUtils.hasText(token.getTokenValue())) { + return null; + } + return String.format("Bearer %s", token.getTokenValue()); + } +} diff --git a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OpenidClaimSet.java b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OpenidClaimSet.java index 049a73f96..03ee4c5e0 100644 --- a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OpenidClaimSet.java +++ b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OpenidClaimSet.java @@ -2,42 +2,43 @@ import java.security.Principal; import java.util.Map; - import org.springframework.security.oauth2.core.oidc.IdTokenClaimAccessor; import org.springframework.security.oauth2.core.oidc.StandardClaimNames; import org.springframework.security.oauth2.jwt.JwtClaimNames; - import com.jayway.jsonpath.PathNotFoundException; - -public class OpenidClaimSet extends UnmodifiableClaimSet implements IdTokenClaimAccessor, Principal { - private static final long serialVersionUID = -5149299350697429528L; - - /** - * JSON path for the claim to use as "name" source - */ - private final String usernameClaim; - - public OpenidClaimSet(Map claims, String usernameClaim) { - super(claims); - this.usernameClaim = usernameClaim; - } - - public OpenidClaimSet(Map claims) { - this(claims, StandardClaimNames.SUB); - } - - @Override - public Map getClaims() { - return this; - } - - @Override - public String getName() { - try { - return getByJsonPath(usernameClaim); - } catch (PathNotFoundException e) { - return getByJsonPath(JwtClaimNames.SUB); - } - } +import lombok.Getter; + +public class OpenidClaimSet extends UnmodifiableClaimSet + implements IdTokenClaimAccessor, Principal { + private static final long serialVersionUID = -5149299350697429528L; + + /** + * JSON path for the claim to use as "name" source + */ + @Getter + private final String usernameClaim; + + public OpenidClaimSet(Map claims, String usernameClaim) { + super(claims); + this.usernameClaim = usernameClaim; + } + + public OpenidClaimSet(Map claims) { + this(claims, StandardClaimNames.SUB); + } + + @Override + public Map getClaims() { + return this; + } + + @Override + public String getName() { + try { + return getByJsonPath(usernameClaim); + } catch (PathNotFoundException e) { + return getByJsonPath(JwtClaimNames.SUB); + } + } } diff --git a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OpenidToken.java b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OpenidToken.java new file mode 100644 index 000000000..a63d6d508 --- /dev/null +++ b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/OpenidToken.java @@ -0,0 +1,37 @@ +package com.c4_soft.springaddons.security.oidc; + +import java.time.Instant; +import java.util.Map; +import org.springframework.security.oauth2.core.OAuth2Token; + +public class OpenidToken extends OpenidClaimSet implements OAuth2Token { + private static final long serialVersionUID = 913910545139553602L; + + private final String tokenValue; + + public OpenidToken(Map claims, String usernameClaim, String tokenValue) { + super(claims, usernameClaim); + this.tokenValue = tokenValue; + } + + public OpenidToken(OpenidClaimSet openidClaimSet, String tokenValue) { + super(openidClaimSet, openidClaimSet.getUsernameClaim()); + this.tokenValue = tokenValue; + } + + @Override + public String getTokenValue() { + return tokenValue; + } + + @Override + public Instant getExpiresAt() { + return super.getExpiresAt(); + } + + @Override + public Instant getIssuedAt() { + return super.getIssuedAt(); + } + +} diff --git a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/UnmodifiableClaimSet.java b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/UnmodifiableClaimSet.java index 423910682..bab027bec 100644 --- a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/UnmodifiableClaimSet.java +++ b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/UnmodifiableClaimSet.java @@ -17,7 +17,7 @@ import java.util.stream.Collectors; /** - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public class UnmodifiableClaimSet extends DelegatingMap implements ClaimSet { private static final long serialVersionUID = 5103156342740420106L; diff --git a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/spring/C4MethodSecurityExpressionRoot.java b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/spring/C4MethodSecurityExpressionRoot.java index 5154170bf..a80cbd98d 100644 --- a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/spring/C4MethodSecurityExpressionRoot.java +++ b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/spring/C4MethodSecurityExpressionRoot.java @@ -10,7 +10,7 @@ /** * org.springframework.security.access.expression.method.MethodSecurityExpressionRoot is protected. * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> * @deprecated replaced by {@link SpringAddonsMethodSecurityExpressionRoot} */ @Deprecated(forRemoval = true) diff --git a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/spring/SpringAddonsMethodSecurityExpressionRoot.java b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/spring/SpringAddonsMethodSecurityExpressionRoot.java index a1ef76ff0..5f45d5c09 100644 --- a/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/spring/SpringAddonsMethodSecurityExpressionRoot.java +++ b/spring-addons-oauth2/src/main/java/com/c4_soft/springaddons/security/oidc/spring/SpringAddonsMethodSecurityExpressionRoot.java @@ -10,7 +10,7 @@ /** * org.springframework.security.access.expression.method.MethodSecurityExpressionRoot is protected. * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public class SpringAddonsMethodSecurityExpressionRoot extends SecurityExpressionRoot implements MethodSecurityExpressionOperations { diff --git a/spring-addons-starter-oidc-test/pom.xml b/spring-addons-starter-oidc-test/pom.xml index df84c56a4..45bda8494 100644 --- a/spring-addons-starter-oidc-test/pom.xml +++ b/spring-addons-starter-oidc-test/pom.xml @@ -3,7 +3,7 @@ com.c4-soft.springaddons spring-addons - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. spring-addons-starter-oidc-test @@ -32,10 +32,12 @@ io.projectreactor reactor-core + true org.apache.tomcat.embed tomcat-embed-core + true org.junit.jupiter @@ -53,6 +55,7 @@ org.springframework spring-webmvc + true provided diff --git a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AuthenticationConfigurer.java b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AuthenticationConfigurer.java index ce58a1ed1..d185ef6a4 100644 --- a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AuthenticationConfigurer.java +++ b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AuthenticationConfigurer.java @@ -26,7 +26,7 @@ /** * Redundant code for {@link Authentication} WebTestClient configurers * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> * @param concrete {@link Authentication} type to build and configure in test security context */ public interface AuthenticationConfigurer extends WebTestClientConfigurer, MockServerConfigurer, AuthenticationBuilder { diff --git a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxClientSecurity.java b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxClientSecurity.java index 7233d866d..f42535b33 100644 --- a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxClientSecurity.java +++ b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxClientSecurity.java @@ -20,7 +20,7 @@ *

* See {@link AutoConfigureAddonsWebmvcMinimalSecurity} * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> * @see AddonsWebmvcComponentTest * @see AutoConfigureAddonsWebmvcResourceServerSecurity */ diff --git a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxMinimalSecurity.java b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxMinimalSecurity.java index 87c98a4f3..926d1a0b8 100644 --- a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxMinimalSecurity.java +++ b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxMinimalSecurity.java @@ -25,7 +25,7 @@ *

* See {@link AddonsWebmvcComponentTest} See {@link AutoConfigureAddonsWebmvcResourceServerSecurity} See {@link AutoConfigureAddonsWebfluxClientSecurity} * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> * @see AddonsWebmvcComponentTest * @see AutoConfigureAddonsWebfluxClientSecurity * @see AutoConfigureAddonsWebmvcResourceServerSecurity diff --git a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxResourceServerSecurity.java b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxResourceServerSecurity.java index 3c6e97ed9..6268bfd3a 100644 --- a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxResourceServerSecurity.java +++ b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/AutoConfigureAddonsWebfluxResourceServerSecurity.java @@ -16,7 +16,7 @@ * repositories (web context is not desired in that case). *

* - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) diff --git a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/OidcIdAuthenticationTokenWebTestClientConfigurer.java b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/OidcIdAuthenticationTokenWebTestClientConfigurer.java index 25b9a82e9..c41ae6bd7 100644 --- a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/OidcIdAuthenticationTokenWebTestClientConfigurer.java +++ b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webflux/OidcIdAuthenticationTokenWebTestClientConfigurer.java @@ -1,26 +1,27 @@ /* * Copyright 2019 Jérôme Wacongne * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package com.c4_soft.springaddons.security.oauth2.test.webflux; import com.c4_soft.springaddons.security.oauth2.test.OAuthenticationTestingBuilder; import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.OpenidToken; -public class OidcIdAuthenticationTokenWebTestClientConfigurer extends OAuthenticationTestingBuilder - implements - AuthenticationConfigurer> { +public class OidcIdAuthenticationTokenWebTestClientConfigurer extends OAuthenticationTestingBuilder + implements AuthenticationConfigurer> { - public static OidcIdAuthenticationTokenWebTestClientConfigurer oidcId() { - return new OidcIdAuthenticationTokenWebTestClientConfigurer(); - } -} \ No newline at end of file + public static OidcIdAuthenticationTokenWebTestClientConfigurer oidcId() { + return new OidcIdAuthenticationTokenWebTestClientConfigurer(); + } +} diff --git a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AuthenticationRequestPostProcessor.java b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AuthenticationRequestPostProcessor.java index 915488002..1dea7d4ba 100644 --- a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AuthenticationRequestPostProcessor.java +++ b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AuthenticationRequestPostProcessor.java @@ -21,7 +21,7 @@ /** * Redundant code for {@link Authentication} MockMvc request post-processors * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> * @param concrete {@link Authentication} type to build and configure in test security context */ public interface AuthenticationRequestPostProcessor extends RequestPostProcessor, AuthenticationBuilder { diff --git a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcClientSecurity.java b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcClientSecurity.java index 657db481c..504182491 100644 --- a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcClientSecurity.java +++ b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcClientSecurity.java @@ -16,7 +16,7 @@ * test controllers but not services or repositories (web context is not desired in that case). *

* - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> * @see AddonsWebmvcComponentTest * @see AutoConfigureAddonsWebfluxClientSecurity */ diff --git a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcMinimalSecurity.java b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcMinimalSecurity.java index b10f587bf..f7cd6dd12 100644 --- a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcMinimalSecurity.java +++ b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcMinimalSecurity.java @@ -24,7 +24,7 @@ *

* See {@link AddonsWebmvcComponentTest} See {@link AutoConfigureAddonsWebmvcResourceServerSecurity} See {@link AutoConfigureAddonsWebfluxClientSecurity} * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> * @see AddonsWebmvcComponentTest * @see AutoConfigureAddonsWebfluxClientSecurity * @see AutoConfigureAddonsWebmvcResourceServerSecurity diff --git a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcResourceServerSecurity.java b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcResourceServerSecurity.java index cbee34e50..88fd812c5 100644 --- a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcResourceServerSecurity.java +++ b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/AutoConfigureAddonsWebmvcResourceServerSecurity.java @@ -16,7 +16,7 @@ * used to test controllers but not services or repositories (web context is not desired in that case). *

* - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> * @see AddonsWebmvcComponentTest * @see AutoConfigureAddonsWebfluxClientSecurity */ diff --git a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/OidcIdAuthenticationTokenRequestPostProcessor.java b/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/OidcIdAuthenticationTokenRequestPostProcessor.java deleted file mode 100644 index 09698db39..000000000 --- a/spring-addons-starter-oidc-test/src/main/java/com/c4_soft/springaddons/security/oauth2/test/webmvc/OidcIdAuthenticationTokenRequestPostProcessor.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2019 Jérôme Wacongne - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the - * License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - */ - -package com.c4_soft.springaddons.security.oauth2.test.webmvc; - -import com.c4_soft.springaddons.security.oauth2.test.OAuthenticationTestingBuilder; -import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - -public class OidcIdAuthenticationTokenRequestPostProcessor extends OAuthenticationTestingBuilder - implements - AuthenticationRequestPostProcessor> { - - public static OidcIdAuthenticationTokenRequestPostProcessor mockOidcId() { - return new OidcIdAuthenticationTokenRequestPostProcessor(); - } -} \ No newline at end of file diff --git a/spring-addons-starter-oidc/README.MD b/spring-addons-starter-oidc/README.MD index c89cb4e14..c5bba4ec5 100644 --- a/spring-addons-starter-oidc/README.MD +++ b/spring-addons-starter-oidc/README.MD @@ -4,7 +4,7 @@ This project is a Spring Boot starter to use in addition to `spring-boot-starter ```xml - 8.0.0-RC1 + 8.0.0 diff --git a/spring-addons-starter-oidc/pom.xml b/spring-addons-starter-oidc/pom.xml index 1610c52bc..fb4c002d8 100644 --- a/spring-addons-starter-oidc/pom.xml +++ b/spring-addons-starter-oidc/pom.xml @@ -3,7 +3,7 @@ com.c4-soft.springaddons spring-addons - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. spring-addons-starter-oidc 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 ed4c9ca54..08bcb929f 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 @@ -5,312 +5,336 @@ import java.util.List; import java.util.Map; import java.util.Optional; - -import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.http.HttpStatus; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.server.ServerAuthenticationEntryPoint; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.util.UriComponentsBuilder; - 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 public class SpringAddonsOidcClientProperties { - public static final String RESPONSE_STATUS_HEADER = "X-RESPONSE-STATUS"; - - 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_AUTHENTICATION_FAILURE_CAUSE_ATTRIBUTE = "error"; - - 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 List securityMatchers = List.of(); + public static final String RESPONSE_STATUS_HEADER = "X-RESPONSE-STATUS"; + + 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_AUTHENTICATION_FAILURE_CAUSE_ATTRIBUTE = "error"; + + 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 List securityMatchers = List.of(); + + /** + * Fully qualified URI of the configured OAuth2 client. + */ + private URI clientUri = URI.create("/"); + + /** + * URI at which a login can be performed. If left empty, ${client-uri}/login is used. Can be + * changed to the URI on a SPA or a mobile application deep-link + */ + private Optional loginUri = 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() { + 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; + + /** + * Path matchers for the routes accessible to anonymous requests + */ + private List permitAll = List.of("/login/**", "/oauth2/**"); + + /** + * CSRF protection configuration for the auto-configured client filter-chain + */ + private Csrf csrf = Csrf.DEFAULT; + + /** + * When true, PKCE is enabled (by default, Spring enables it only for "public" clients) + */ + private boolean pkceForced = false; + + /** + * Fine grained CORS configuration + * + * @deprecated use com.c4-soft.springaddons.oidc.cors instead + */ + @Deprecated(forRemoval = true) + private List cors = List.of(); + + /** + * Additional parameters to send with authorization request, mapped by client registration IDs + * + * @deprecated use the more concise authorization-params syntax + */ + @Deprecated + private Map> authorizationRequestParams = new HashMap<>(); + + /** + *

+ * Additional parameters to send with authorization request, mapped by client registration IDs. + *

+ *

+ * {@link OAuth2AuthorizationRequest#getAdditionalParameters()} return a Map<String, + * Object>, when it should probably be Map<String, List<String>>. Also the + * serializer does not handle collections correctly (serializes using {@link Object#toString()} + * instead of repeating the parameter with each value toString()). What spring-addons does is + * joining the String values with a comma. + *

+ */ + private Map>> authorizationParams = new HashMap<>(); + + public MultiValueMap getExtraAuthorizationParameters(String registrationId) { + return getExtraParameters(registrationId, authorizationRequestParams, authorizationParams); + } + + /** + * Additional parameters to send with token request, mapped by client registration IDs + * + * @deprecated use the more concise token-params syntax + */ + @Deprecated + private Map> tokenRequestParams = new HashMap<>(); + + /** + * Additional parameters to send with authorization request, mapped by client registration IDs + */ + private Map>> tokenParams = new HashMap<>(); + + public MultiValueMap getExtraTokenParameters(String registrationId) { + return getExtraParameters(registrationId, tokenRequestParams, tokenParams); + } + + private static MultiValueMap getExtraParameters(String registrationId, + Map> requestParams, + Map>> requestParamsMap) { + final var extraParameters = Optional.ofNullable(requestParamsMap.get(registrationId)) + .map(LinkedMultiValueMap::new).orElse(new LinkedMultiValueMap<>()); + for (final var param : requestParams.getOrDefault(registrationId, List.of())) { + if (StringUtils.hasText(param.getName())) { + extraParameters.add(param.getName(), param.getValue()); + } + } + return extraParameters; + } - /** - * Fully qualified URI of the configured OAuth2 client. - */ - private URI clientUri = URI.create("/"); + /** + * 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 { /** - * 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 + * URI on the authorization server where to redirect the user for logout */ - private Optional loginPath = Optional.empty(); + private URI uri; /** - * URI containing scheme, host and port where the user should be redirected after a successful login (defaults to the client URI) + * request param name for client-id */ - private Optional postLoginRedirectHost = Optional.empty(); + private Optional clientIdRequestParam = Optional.empty(); /** - * Where to redirect the user after successful login + * 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 postLoginRedirectPath = Optional.empty(); + private Optional postLogoutUriRequestParam = Optional.empty(); /** - * Where to redirect the user after login failure + * request param name for setting an ID-Token hint */ - private Optional loginErrorRedirectPath = Optional.empty(); + private Optional idTokenHintRequestParam = 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, ...). + * RP-Initiated Logout is enabled by default. Setting this to false disables it. */ - private OAuth2RedirectionProperties oauth2Redirections = new OAuth2RedirectionProperties(); - - public URI getPostLoginRedirectHost() { - return postLoginRedirectHost.orElse(clientUri); - } + private boolean enabled = true; + } - public Optional getPostLoginRedirectUri() { - if (postLoginRedirectHost.isEmpty() && postLoginRedirectPath.isEmpty()) { - return Optional.empty(); - } - final var uri = UriComponentsBuilder.fromUri(getPostLoginRedirectHost()); - postLoginRedirectPath.ifPresent(uri::path); + private BackChannelLogoutProperties backChannelLogout = new BackChannelLogoutProperties(); - 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(); + @Data + public static class BackChannelLogoutProperties { + private boolean enabled = false; /** - * Path (relative to clientUri) where the user should be redirected after being logged out from authorization server(s) + * The URI for a loop of the Spring client to itself in which it actually ends the user session. + * Overriding this can be useful to force the scheme and port in the case where the client is + * behind a reverse proxy with different scheme and port (default URI uses the original + * Back-Channel Logout request scheme and ports). */ - private Optional postLogoutRedirectPath = Optional.empty(); - - public URI getPostLogoutRedirectHost() { - return postLogoutRedirectHost.orElse(clientUri); - } - - public URI getPostLogoutRedirectUri() { - var uri = UriComponentsBuilder.fromUri(getPostLogoutRedirectHost()); - postLogoutRedirectPath.ifPresent(uri::path); - - return uri.build(Map.of()); - } - + private Optional internalLogoutUri = Optional.empty(); + + private Optional cookieName = Optional.empty(); + } + + /** + * Request parameter + * + * @author Jerome Wacongne ch4mp@c4-soft.com + */ + @Data + public static class RequestParam { /** - * 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)
  • - *
+ * request parameter name */ - private Map oauth2Logout = new HashMap<>(); + private String name; /** - *

- * 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). - *

+ * request parameter value */ - private boolean multiTenancyEnabled = false; + private String value; + } + @Data + public static class OAuth2RedirectionProperties { /** - * Path matchers for the routes accessible to anonymous requests + * Defines {@link AuthenticationEntryPoint} or {@link ServerAuthenticationEntryPoint} behavior */ - private List permitAll = List.of("/login/**", "/oauth2/**"); + private HttpStatus authenticationEntryPoint = HttpStatus.FOUND; /** - * CSRF protection configuration for the auto-configured client filter-chain + * Status for the 1st response in authorization code flow, with location to get authorization + * code from authorization server */ - private Csrf csrf = Csrf.DEFAULT; + private HttpStatus preAuthorizationCode = HttpStatus.FOUND; /** - * When true, PKCE is enabled (by default, Spring enables it only for "public" clients) + * Status for the response after authorization code, with location to the UI */ - private boolean pkceForced = false; + private HttpStatus postAuthorizationCode = HttpStatus.FOUND; /** - * Fine grained CORS configuration - * - * @deprecated use com.c4-soft.springaddons.oidc.cors instead + * Status for the response after an authorization failure */ - @Deprecated(forRemoval = true) - private List cors = List.of(); + private HttpStatus postAuthorizationFailure = HttpStatus.FOUND; /** - * Additional parameters to send with authorization request, mapped by client registration IDs - * - * @deprecated use the more concise authorization-params syntax + * Status for the response after BFF logout, with location to authorization server logout + * endpoint */ - @Deprecated - private Map> authorizationRequestParams = new HashMap<>(); - + private HttpStatus rpInitiatedLogout = HttpStatus.FOUND; /** - *

- * Additional parameters to send with authorization request, mapped by client registration IDs. - *

- *

- * {@link OAuth2AuthorizationRequest#getAdditionalParameters()} return a Map<String, Object>, when it should probably be Map<String, - * List<String>>. Also the serializer does not handle collections correctly (serializes using {@link Object#toString()} instead of repeating the - * parameter with each value toString()). What spring-addons does is joining the String values with a comma. - *

+ * Used only in servlet applications */ - private Map>> authorizationParams = new HashMap<>(); + private HttpStatus invalidSessionStrategy = HttpStatus.FOUND; + } - public MultiValueMap getExtraAuthorizationParameters(String registrationId) { - return getExtraParameters(registrationId, authorizationRequestParams, authorizationParams); - } - - /** - * Additional parameters to send with token request, mapped by client registration IDs - * - * @deprecated use the more concise token-params syntax - */ - @Deprecated - private Map> tokenRequestParams = new HashMap<>(); - - /** - * Additional parameters to send with authorization request, mapped by client registration IDs - */ - private Map>> tokenParams = new HashMap<>(); - - public MultiValueMap getExtraTokenParameters(String registrationId) { - return getExtraParameters(registrationId, tokenRequestParams, tokenParams); - } - - private static MultiValueMap getExtraParameters( - String registrationId, - Map> requestParams, - Map>> requestParamsMap) { - final var extraParameters = Optional.ofNullable(requestParamsMap.get(registrationId)).map(LinkedMultiValueMap::new).orElse(new LinkedMultiValueMap<>()); - for (final var param : requestParams.getOrDefault(registrationId, List.of())) { - if (StringUtils.hasText(param.getName())) { - extraParameters.add(param.getName(), param.getValue()); - } - } - return extraParameters; - } - - /** - * Logout properties for OpenID Providers which do not implement the RP-Initiated Logout spec - * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ - @Data - @ConfigurationProperties - 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(); - - /** - * RP-Initiated Logout is enabled by default. Setting this to false disables it. - */ - private boolean enabled = true; - } - - private BackChannelLogoutProperties backChannelLogout = new BackChannelLogoutProperties(); - - @Data - @ConfigurationProperties - public static class BackChannelLogoutProperties { - private boolean enabled = false; - - /** - * The URI for a loop of the Spring client to itself in which it actually ends the user session. Overriding this can be useful to force the scheme and - * port in the case where the client is behind a reverse proxy with different scheme and port (default URI uses the original Back-Channel Logout request - * scheme and ports). - */ - private Optional internalLogoutUri = Optional.empty(); - } - - /** - * Request parameter - * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ - @Data - @ConfigurationProperties - public static class RequestParam { - /** - * request parameter name - */ - private String name; - - /** - * request parameter value - */ - private String value; - } - - @Data - @ConfigurationProperties - 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 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/properties/SpringAddonsOidcProperties.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcProperties.java index d63f88a04..b9c461716 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcProperties.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcProperties.java @@ -2,17 +2,16 @@ import java.net.URI; import java.util.List; - import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.security.oauth2.core.oidc.StandardClaimNames; - import lombok.Data; /** *

- * Configuration properties for OAuth2 auto-configuration extensions to spring-boot-starter-oauth2-client and spring-boot-starter-oauth2-resource-server. + * Configuration properties for OAuth2 auto-configuration extensions to + * spring-boot-starter-oauth2-client and spring-boot-starter-oauth2-resource-server. *

* The following spring-boot standard properties are used: *
    @@ -20,8 +19,8 @@ *
  • spring.security.oauth2.client.registration.*
  • *
  • spring.security.oauth2.resourceserver.opaquetoken.*
  • *
- * spring.security.oauth2.resourceserver.jwt.* properties are ignored. The reason for that is it is applicable only to single tenant scenarios. Use - * properties + * spring.security.oauth2.resourceserver.jwt.* properties are ignored. The reason for that is + * it is applicable only to single tenant scenarios. Use properties * * @author Jerome Wacongne ch4mp@c4-soft.com */ @@ -30,95 +29,100 @@ @ConfigurationProperties(prefix = "com.c4-soft.springaddons.oidc") public class SpringAddonsOidcProperties { + /** + * OpenID Providers configuration: JWK set URI, issuer URI, audience, and authorities mapping + * configuration for each issuer. A minimum of one issuer is required. Properties defined here + * are a replacement for spring.security.oauth2.resourceserver.jwt.* (which will be ignored). + * Authorities mapping defined there is used by both client and resource server filter-chains. + */ + private List ops = List.of(); + + /** + * 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). + */ + @NestedConfigurationProperty + private SpringAddonsOidcClientProperties client = new SpringAddonsOidcClientProperties(); + + /** + * Auto-configuration for an OAuth2 resource server Security(Web)FilterChain with + * @Order(LOWEST_PRECEDENCE). Typical use case is a REST API secured with access tokens. + * Default configuration is as follow: no securityMatcher to process all the requests that were + * not intercepted by higher @Order Security(Web)FilterChains, no session, disabled CSRF + * protection, and 401 to unauthorized requests. + */ + @NestedConfigurationProperty + private SpringAddonsOidcResourceServerProperties resourceserver = + new SpringAddonsOidcResourceServerProperties(); + + private List cors = List.of(); + + /** + * OpenID Providers configuration. A minimum of one issuer is required. Properties defined here + * are a replacement for spring.security.oauth2.resourceserver.jwt.* (which will be ignored). + * Authorities mapping defined here is used by both client and resource server filter-chains. + * + * @author Jerome Wacongne ch4mp@c4-soft.com + */ + @Data + static public class OpenidProviderProperties { /** - * OpenID Providers configuration: JWK set URI, issuer URI, audience, and authorities mapping configuration for each issuer. A minimum of one issuer is - * required. Properties defined here are a replacement for spring.security.oauth2.resourceserver.jwt.* (which will be ignored). Authorities mapping - * defined there is used by both client and resource server filter-chains. + *

+ * Must be exactly the same as in access tokens (even trailing slash, if any, is important). In + * case of doubt, open one of your access tokens with a tool like + * https://jwt.io. + *

*/ - private List ops = List.of(); + private URI iss; /** - * 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). + * Can be omitted if OpenID configuration can be retrieved from + * ${iss}/.well-known/openid-configuration */ - @NestedConfigurationProperty - private SpringAddonsOidcClientProperties client = new SpringAddonsOidcClientProperties(); + private URI jwkSetUri; /** - * Auto-configuration for an OAuth2 resource server Security(Web)FilterChain with @Order(LOWEST_PRECEDENCE). Typical use case is a REST API secured with - * access tokens. Default configuration is as follow: no securityMatcher to process all the requests that were not intercepted by higher @Order - * Security(Web)FilterChains, no session, disabled CSRF protection, and 401 to unauthorized requests. + * Can be omitted. Will insert an audience validator if not null or empty */ - @NestedConfigurationProperty - private SpringAddonsOidcResourceServerProperties resourceserver = new SpringAddonsOidcResourceServerProperties(); + private String aud; - private List cors = List.of(); + /** + * Authorities mapping configuration, per claim + */ + private List authorities = List.of(); /** - * OpenID Providers configuration. A minimum of one issuer is required. Properties defined here are a replacement for - * spring.security.oauth2.resourceserver.jwt.* (which will be ignored). Authorities mapping defined here is used by both client and resource server - * filter-chains. - * - * @author Jerome Wacongne ch4mp@c4-soft.com + * JSON path for the claim to use as "name" source */ + private String usernameClaim = StandardClaimNames.SUB; + @Data - @ConfigurationProperties - static public class OpenidProviderProperties { - /** - *

- * Must be exactly the same as in access tokens (even trailing slash, if any, is important). In case of doubt, open one of your access tokens with a - * tool like https://jwt.io. - *

- */ - private URI iss; - - /** - * Can be omitted if OpenID configuration can be retrieved from ${iss}/.well-known/openid-configuration - */ - private URI jwkSetUri; - - /** - * Can be omitted. Will insert an audience validator if not null or empty - */ - private String aud; - - /** - * Authorities mapping configuration, per claim - */ - private List authorities = List.of(); - - /** - * JSON path for the claim to use as "name" source - */ - private String usernameClaim = StandardClaimNames.SUB; - - @Data - @ConfigurationProperties - public static class SimpleAuthoritiesMappingProperties { - /** - * JSON path of the claim(s) to map with this properties - */ - private String path = "$.realm_access.roles"; - - /** - * What to prefix authorities with (for instance "ROLE_" or "SCOPE_") - */ - private String prefix = ""; - - /** - * Whether to transform authorities to uppercase, lowercase, or to leave it unchanged - */ - private Case caze = Case.UNCHANGED; - - public static enum Case { - UNCHANGED, - UPPER, - LOWER - } - - } + public static class SimpleAuthoritiesMappingProperties { + /** + * JSON path of the claim(s) to map with this properties + */ + private String path = "$.realm_access.roles"; + + /** + * What to prefix authorities with (for instance "ROLE_" or "SCOPE_") + */ + private String prefix = ""; + + /** + * Whether to transform authorities to uppercase, lowercase, or to leave it unchanged + */ + private Case caze = Case.UNCHANGED; + + public static enum Case { + UNCHANGED, UPPER, LOWER + } + } + } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcResourceServerProperties.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcResourceServerProperties.java index 6827b318b..610abb54d 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcResourceServerProperties.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcResourceServerProperties.java @@ -1,48 +1,47 @@ package com.c4_soft.springaddons.security.oidc.starter.properties; import java.util.List; - -import org.springframework.boot.context.properties.ConfigurationProperties; - import lombok.Data; /** - * Auto-configuration for an OAuth2 resource server Security(Web)FilterChain with @Order(LOWEST_PRECEDENCE). Typical use case is a REST API secured with - * access tokens. Default configuration is as follow: no securityMatcher to process all the requests that were not intercepted by higher @Order - * Security(Web)FilterChains, no session, disabled CSRF protection, and 401 to unauthorized requests. + * Auto-configuration for an OAuth2 resource server Security(Web)FilterChain with + * @Order(LOWEST_PRECEDENCE). Typical use case is a REST API secured with access tokens. Default + * configuration is as follow: no securityMatcher to process all the requests that were not + * intercepted by higher @Order Security(Web)FilterChains, no session, disabled CSRF protection, + * and 401 to unauthorized requests. * * @author Jerome Wacongne ch4mp@c4-soft.com */ @Data -@ConfigurationProperties public class SpringAddonsOidcResourceServerProperties { - /** - * Resource server SecurityFilterChain bean and all its dependencies are instantiated only if true. - */ - private boolean enabled = true; - - /** - * Path matchers for the routes accessible to anonymous requests - */ - private List permitAll = List.of(); - - /** - * Whether to disable sessions. It should remain true. - */ - private boolean statlessSessions = true; - - /** - * CSRF protection configuration for the auto-configured client filter-chain - */ - private Csrf csrf = Csrf.DISABLE; - - /** - * Fine grained CORS configuration - * - * @deprecated use com.c4-soft.springaddons.oidc.cors instead - */ - @Deprecated(forRemoval = true) - private List cors = List.of(); + /** + * Resource server SecurityFilterChain bean and all its dependencies are instantiated only if + * true. + */ + private boolean enabled = true; + + /** + * Path matchers for the routes accessible to anonymous requests + */ + private List permitAll = List.of(); + + /** + * Whether to disable sessions. It should remain true. + */ + private boolean statlessSessions = true; + + /** + * CSRF protection configuration for the auto-configured client filter-chain + */ + private Csrf csrf = Csrf.DISABLE; + + /** + * Fine grained CORS configuration + * + * @deprecated use com.c4-soft.springaddons.oidc.cors instead + */ + @Deprecated(forRemoval = true) + private List cors = List.of(); } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/HasTokenEdpointParametersPropertiesCondition.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/HasTokenEdpointParametersPropertiesCondition.java index 511d8e0ab..322f4c5b4 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/HasTokenEdpointParametersPropertiesCondition.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/HasTokenEdpointParametersPropertiesCondition.java @@ -2,7 +2,7 @@ public class HasTokenEdpointParametersPropertiesCondition extends HasPropertyPrefixCondition { - public HasTokenEdpointParametersPropertiesCondition() { - super("com.c4-soft.springaddons.oidc.client.token-request-params"); - } + public HasTokenEdpointParametersPropertiesCondition() { + super("com.c4-soft.springaddons.oidc.client.token-request-params"); + } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultAuthenticationEntryPointCondition.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultAuthenticationEntryPointCondition.java new file mode 100644 index 000000000..7df6c7b01 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultAuthenticationEntryPointCondition.java @@ -0,0 +1,21 @@ +package com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.NoneNestedConditions; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.server.ServerAuthenticationEntryPoint; + +public class DefaultAuthenticationEntryPointCondition extends NoneNestedConditions { + + public DefaultAuthenticationEntryPointCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnBean(AuthenticationEntryPoint.class) + static class AuthenticationEntryPointCondition { + } + + @ConditionalOnBean(ServerAuthenticationEntryPoint.class) + static class ServerAuthenticationEntryPointCondition { + } +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultAuthenticationSuccessHandlerCondition.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultAuthenticationSuccessHandlerCondition.java index 34c04f0a0..a4fe0c30d 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultAuthenticationSuccessHandlerCondition.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultAuthenticationSuccessHandlerCondition.java @@ -3,6 +3,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.NoneNestedConditions; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; public class DefaultAuthenticationSuccessHandlerCondition extends NoneNestedConditions { @@ -14,7 +15,7 @@ public DefaultAuthenticationSuccessHandlerCondition() { static class AuthenticationSuccessHandlerProvidedCondition { } - @ConditionalOnBean(AuthenticationSuccessHandler.class) + @ConditionalOnBean(ServerAuthenticationSuccessHandler.class) static class ServerAuthenticationSuccessHandlerProvidedCondition { } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultOidcBackChannelLogoutHandlerCondition.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultOidcBackChannelLogoutHandlerCondition.java new file mode 100644 index 000000000..d096bae10 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultOidcBackChannelLogoutHandlerCondition.java @@ -0,0 +1,27 @@ +package com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcBackChannelLogoutHandler; +import org.springframework.security.config.web.server.OidcBackChannelServerLogoutHandler; + +public class DefaultOidcBackChannelLogoutHandlerCondition extends AllNestedConditions { + + public DefaultOidcBackChannelLogoutHandlerCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = "com.c4-soft.springaddons.oidc.client.back-channel-logout.enabled") + static class BackChannelLogoutEnabledCondition { + } + + @ConditionalOnMissingBean(OidcBackChannelLogoutHandler.class) + static class NoOidcBackChannelLogoutHandlerCondition { + } + + @ConditionalOnMissingBean(OidcBackChannelServerLogoutHandler.class) + static class NoOidcBackChannelServerLogoutHandlerCondition { + } + +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultOidcSessionRegistryCondition.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultOidcSessionRegistryCondition.java new file mode 100644 index 000000000..538d26f74 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/condition/bean/DefaultOidcSessionRegistryCondition.java @@ -0,0 +1,24 @@ +package com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; + +public class DefaultOidcSessionRegistryCondition extends AllNestedConditions { + + public DefaultOidcSessionRegistryCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = "com.c4-soft.springaddons.oidc.client.back-channel-logout.enabled") + static class BackChannelLogoutEnabledCondition {} + + @ConditionalOnMissingBean(OidcSessionRegistry.class) + static class NoOidcSessionRegistryCondition {} + + @ConditionalOnMissingBean(ReactiveOidcSessionRegistry.class) + static class NoReactiveOidcSessionRegistryCondition {} + +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveConfigurationSupport.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveConfigurationSupport.java index 54dd95101..6d358f39b 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveConfigurationSupport.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveConfigurationSupport.java @@ -1,16 +1,18 @@ package com.c4_soft.springaddons.security.oidc.starter.reactive; import static org.springframework.security.config.Customizer.withDefaults; - +import java.net.URI; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.List; -import java.util.Optional; - +import java.util.stream.Collectors; import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.web.server.ServerAuthenticationEntryPoint; -import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository; import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository; import org.springframework.security.web.server.csrf.CsrfToken; @@ -20,176 +22,193 @@ import org.springframework.web.cors.reactive.CorsWebFilter; import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; import org.springframework.web.server.ServerWebExchange; - import com.c4_soft.springaddons.security.oidc.starter.properties.CorsProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.Csrf; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties.OpenidProviderProperties; import com.c4_soft.springaddons.security.oidc.starter.reactive.client.ClientAuthorizeExchangeSpecPostProcessor; import com.c4_soft.springaddons.security.oidc.starter.reactive.client.ClientReactiveHttpSecurityPostProcessor; import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ResourceServerAuthorizeExchangeSpecPostProcessor; import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ResourceServerReactiveHttpSecurityPostProcessor; - import reactor.core.publisher.Mono; public class ReactiveConfigurationSupport { - public static ServerHttpSecurity configureResourceServer( - ServerHttpSecurity http, - ServerProperties serverProperties, - SpringAddonsOidcProperties addonsProperties, - ServerAuthenticationEntryPoint authenticationEntryPoint, - Optional accessDeniedHandler, - ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor, - ResourceServerReactiveHttpSecurityPostProcessor httpPostProcessor) { - - ReactiveConfigurationSupport - .configureState(http, addonsProperties.getResourceserver().isStatlessSessions(), addonsProperties.getResourceserver().getCsrf()); - - // FIXME: use only the new CORS properties at next major release - final var corsProps = new ArrayList<>(addonsProperties.getCors()); - final var deprecatedClientCorsProps = addonsProperties.getClient().getCors(); - final var deprecatedResourceServerCorsProps = addonsProperties.getResourceserver().getCors(); - corsProps.addAll(deprecatedClientCorsProps); - corsProps.addAll(deprecatedResourceServerCorsProps); - ReactiveConfigurationSupport.configureAccess(http, addonsProperties.getResourceserver().getPermitAll(), corsProps); - - http.exceptionHandling(handling -> { - handling.authenticationEntryPoint(authenticationEntryPoint); - accessDeniedHandler.ifPresent(handling::accessDeniedHandler); - }); - - if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { - http.redirectToHttps(withDefaults()); - } - - http.authorizeExchange(registry -> authorizePostProcessor.authorizeHttpRequests(registry)); - httpPostProcessor.process(http); - - return http; + public static ServerHttpSecurity configureResourceServer(ServerHttpSecurity http, + ServerProperties serverProperties, SpringAddonsOidcProperties addonsProperties, + ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor, + ResourceServerReactiveHttpSecurityPostProcessor httpPostProcessor) { + + http.exceptionHandling(exceptions -> { + final var issuers = addonsProperties.getOps().stream().map(OpenidProviderProperties::getIss) + .filter(iss -> iss != null).map(URI::toString) + .collect(Collectors.joining(",", "\"", "\"")); + exceptions + .authenticationEntryPoint((ServerWebExchange exchange, AuthenticationException ex) -> { + var response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, + "OAuth realm=%s".formatted(issuers)); + var dataBufferFactory = response.bufferFactory(); + var buffer = dataBufferFactory.wrap(ex.getMessage().getBytes(Charset.defaultCharset())); + return response.writeWith(Mono.just(buffer)) + .doOnError(error -> DataBufferUtils.release(buffer)); + }); + }); + + ReactiveConfigurationSupport.configureState(http, + addonsProperties.getResourceserver().isStatlessSessions(), + addonsProperties.getResourceserver().getCsrf()); + + // FIXME: use only the new CORS properties at next major release + final var corsProps = new ArrayList<>(addonsProperties.getCors()); + final var deprecatedClientCorsProps = addonsProperties.getClient().getCors(); + final var deprecatedResourceServerCorsProps = addonsProperties.getResourceserver().getCors(); + corsProps.addAll(deprecatedClientCorsProps); + corsProps.addAll(deprecatedResourceServerCorsProps); + ReactiveConfigurationSupport.configureAccess(http, + addonsProperties.getResourceserver().getPermitAll(), corsProps); + + if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { + http.redirectToHttps(withDefaults()); } - public static ServerHttpSecurity configureClient( - ServerHttpSecurity http, - ServerProperties serverProperties, - SpringAddonsOidcProperties addonsProperties, - ClientAuthorizeExchangeSpecPostProcessor authorizePostProcessor, - ClientReactiveHttpSecurityPostProcessor httpPostProcessor) { + http.authorizeExchange(registry -> authorizePostProcessor.authorizeHttpRequests(registry)); + httpPostProcessor.process(http); - ReactiveConfigurationSupport.configureState(http, false, addonsProperties.getClient().getCsrf()); + return http; + } - // FIXME: use only the new CORS properties at next major release - final var corsProps = new ArrayList<>(addonsProperties.getCors()); - final var deprecatedClientCorsProps = addonsProperties.getClient().getCors(); - final var deprecatedResourceServerCorsProps = addonsProperties.getResourceserver().getCors(); - corsProps.addAll(deprecatedClientCorsProps); - corsProps.addAll(deprecatedResourceServerCorsProps); - ReactiveConfigurationSupport.configureAccess(http, addonsProperties.getClient().getPermitAll(), corsProps); + public static ServerHttpSecurity configureClient(ServerHttpSecurity http, + ServerProperties serverProperties, SpringAddonsOidcProperties addonsProperties, + ClientAuthorizeExchangeSpecPostProcessor authorizePostProcessor, + ClientReactiveHttpSecurityPostProcessor httpPostProcessor) { - if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { - http.redirectToHttps(withDefaults()); - } + ReactiveConfigurationSupport.configureState(http, false, + addonsProperties.getClient().getCsrf()); - http.authorizeExchange(registry -> authorizePostProcessor.authorizeHttpRequests(registry)); - httpPostProcessor.process(http); + // FIXME: use only the new CORS properties at next major release + final var corsProps = new ArrayList<>(addonsProperties.getCors()); + final var deprecatedClientCorsProps = addonsProperties.getClient().getCors(); + final var deprecatedResourceServerCorsProps = addonsProperties.getResourceserver().getCors(); + corsProps.addAll(deprecatedClientCorsProps); + corsProps.addAll(deprecatedResourceServerCorsProps); + ReactiveConfigurationSupport.configureAccess(http, addonsProperties.getClient().getPermitAll(), + corsProps); - return http; + if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { + http.redirectToHttps(withDefaults()); } - public static ServerHttpSecurity configureAccess(ServerHttpSecurity http, List permitAll, List corsProperties) { - final var permittedCorsOptions = corsProperties - .stream() - .filter(cors -> (cors.getAllowedMethods().contains("*") || cors.getAllowedMethods().contains("OPTIONS")) && !cors.isDisableAnonymousOptions()) - .map(CorsProperties::getPath) - .toList(); + http.authorizeExchange(registry -> authorizePostProcessor.authorizeHttpRequests(registry)); + httpPostProcessor.process(http); + + return http; + } - if (permitAll.size() > 0 || permittedCorsOptions.size() > 0) { - http.anonymous(withDefaults()); - } + public static ServerHttpSecurity configureAccess(ServerHttpSecurity http, List permitAll, + List corsProperties) { + final var permittedCorsOptions = corsProperties.stream() + .filter(cors -> (cors.getAllowedMethods().contains("*") + || cors.getAllowedMethods().contains("OPTIONS")) && !cors.isDisableAnonymousOptions()) + .map(CorsProperties::getPath).toList(); - if (permitAll.size() > 0) { - http.authorizeExchange(authorizeExchange -> authorizeExchange.pathMatchers(permitAll.toArray(new String[] {})).permitAll()); - } + if (permitAll.size() > 0 || permittedCorsOptions.size() > 0) { + http.anonymous(withDefaults()); + } - if (permittedCorsOptions.size() > 0) { - http - .authorizeExchange( - authorizeExchange -> authorizeExchange.pathMatchers(HttpMethod.OPTIONS, permittedCorsOptions.toArray(new String[] {})).permitAll()); - } + if (permitAll.size() > 0) { + http.authorizeExchange(authorizeExchange -> authorizeExchange + .pathMatchers(permitAll.toArray(new String[] {})).permitAll()); + } - return http; + if (permittedCorsOptions.size() > 0) { + http.authorizeExchange(authorizeExchange -> authorizeExchange + .pathMatchers(HttpMethod.OPTIONS, permittedCorsOptions.toArray(new String[] {})) + .permitAll()); } - public static CorsWebFilter getCorsFilterBean(List corsProperties) { - final var source = new UrlBasedCorsConfigurationSource(); - for (final var corsProps : corsProperties) { - final var configuration = new CorsConfiguration(); - configuration.setAllowCredentials(corsProps.getAllowCredentials()); - configuration.setAllowedHeaders(corsProps.getAllowedHeaders()); - configuration.setAllowedMethods(corsProps.getAllowedMethods()); - configuration.setAllowedOriginPatterns(corsProps.getAllowedOriginPatterns()); - configuration.setExposedHeaders(corsProps.getExposedHeaders()); - configuration.setMaxAge(corsProps.getMaxAge()); - source.registerCorsConfiguration(corsProps.getPath(), configuration); - } - return new CorsWebFilter(source); + return http; + } + + public static CorsWebFilter getCorsFilterBean(List corsProperties) { + final var source = new UrlBasedCorsConfigurationSource(); + for (final var corsProps : corsProperties) { + final var configuration = new CorsConfiguration(); + configuration.setAllowCredentials(corsProps.getAllowCredentials()); + configuration.setAllowedHeaders(corsProps.getAllowedHeaders()); + configuration.setAllowedMethods(corsProps.getAllowedMethods()); + configuration.setAllowedOriginPatterns(corsProps.getAllowedOriginPatterns()); + configuration.setExposedHeaders(corsProps.getExposedHeaders()); + configuration.setMaxAge(corsProps.getMaxAge()); + source.registerCorsConfiguration(corsProps.getPath(), configuration); + } + return new CorsWebFilter(source); + } + + public static ServerHttpSecurity configureState(ServerHttpSecurity http, boolean isStatless, + Csrf csrfEnum) { + + if (isStatless) { + http.securityContextRepository(NoOpServerSecurityContextRepository.getInstance()); } - public static ServerHttpSecurity configureState(ServerHttpSecurity http, boolean isStatless, Csrf csrfEnum) { - - if (isStatless) { - http.securityContextRepository(NoOpServerSecurityContextRepository.getInstance()); - } - - http.csrf(csrf -> { - switch (csrfEnum) { - case DISABLE: - csrf.disable(); - break; - case DEFAULT: - if (isStatless) { - csrf.disable(); - } else { - withDefaults(); - } - break; - case SESSION: - withDefaults(); - break; - case COOKIE_ACCESSIBLE_FROM_JS: - // adapted from https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa - csrf.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()).csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()); - break; - } - }); - - return http; + http.csrf(csrf -> { + switch (csrfEnum) { + case DISABLE: + csrf.disable(); + break; + case DEFAULT: + if (isStatless) { + csrf.disable(); + } else { + withDefaults(); + } + break; + case SESSION: + withDefaults(); + break; + case COOKIE_ACCESSIBLE_FROM_JS: + // adapted from + // https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa + csrf.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()); + break; + } + }); + + return http; + } + + /** + * Adapted from + * https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa + */ + static final class SpaCsrfTokenRequestHandler extends ServerCsrfTokenRequestAttributeHandler { + private final ServerCsrfTokenRequestAttributeHandler delegate = + new XorServerCsrfTokenRequestAttributeHandler(); + + @Override + public void handle(ServerWebExchange exchange, Mono csrfToken) { + /* + * Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of the + * CsrfToken when it is rendered in the response body. + */ + this.delegate.handle(exchange, csrfToken); } - /** - * Adapted from https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa - */ - static final class SpaCsrfTokenRequestHandler extends ServerCsrfTokenRequestAttributeHandler { - private final ServerCsrfTokenRequestAttributeHandler delegate = new XorServerCsrfTokenRequestAttributeHandler(); - - @Override - public void handle(ServerWebExchange exchange, Mono csrfToken) { - /* - * Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of the CsrfToken when it is rendered in the response body. - */ - this.delegate.handle(exchange, csrfToken); - } - - @Override - public Mono resolveCsrfTokenValue(ServerWebExchange exchange, CsrfToken csrfToken) { - /* - * If the request contains a X-XSRF-TOKEN header, use it. This applies when a single-page application includes the header value automatically, - * which was obtained via a cookie containing the raw CsrfToken. In all other cases (e.g. if the request contains a request parameter), use - * XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies when a server-side rendered form includes the _csrf request parameter - * as a hidden input. - */ - return Mono - .justOrEmpty(exchange.getRequest().getHeaders().getFirst(csrfToken.getHeaderName())) - .switchIfEmpty(this.delegate.resolveCsrfTokenValue(exchange, csrfToken)); - } + @Override + public Mono resolveCsrfTokenValue(ServerWebExchange exchange, CsrfToken csrfToken) { + /* + * If the request contains a X-XSRF-TOKEN header, use it. This applies when a single-page + * application includes the header value automatically, which was obtained via a cookie + * containing the raw CsrfToken. In all other cases (e.g. if the request contains a request + * parameter), use XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies + * when a server-side rendered form includes the _csrf request parameter as a hidden input. + */ + return Mono + .justOrEmpty(exchange.getRequest().getHeaders().getFirst(csrfToken.getHeaderName())) + .switchIfEmpty(this.delegate.resolveCsrfTokenValue(exchange, csrfToken)); } + } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientWithLoginBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientWithLoginBeans.java index 93bb41fbe..a9a49027c 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientWithLoginBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientWithLoginBeans.java @@ -2,7 +2,6 @@ import java.util.ArrayList; import java.util.Optional; - import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -13,14 +12,16 @@ 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.OidcBackChannelServerLogoutHandler; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry; 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.ServerAuthenticationEntryPoint; import org.springframework.security.web.server.ServerRedirectStrategy; -import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint; import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; @@ -31,146 +32,157 @@ import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.web.cors.reactive.CorsWebFilter; import org.springframework.web.server.WebFilter; -import org.springframework.web.util.UriComponentsBuilder; - import com.c4_soft.springaddons.security.oidc.starter.ClaimSetAuthoritiesConverter; import com.c4_soft.springaddons.security.oidc.starter.ConfigurableClaimSetAuthoritiesConverter; 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.CookieCsrfCondition; +import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultAuthenticationEntryPointCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultAuthenticationFailureHandlerCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultAuthenticationSuccessHandlerCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultCorsWebFilterCondition; +import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultOidcBackChannelLogoutHandlerCondition; +import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultOidcSessionRegistryCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.configuration.IsClientWithLoginCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.configuration.IsNotServlet; import com.c4_soft.springaddons.security.oidc.starter.reactive.ReactiveConfigurationSupport; import com.c4_soft.springaddons.security.oidc.starter.reactive.ReactiveSpringAddonsOidcBeans; - import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; /** * The following {@link ConditionalOnMissingBean @ConditionalOnMissingBeans} are auto-configured *

    - *
  • springAddonsClientFilterChain: a {@link SecurityWebFilterChain}. Instantiated only if "com.c4-soft.springaddons.oidc.client.security-matchers" property - * has at least one entry. If defined, it is with a high precedence, to ensure that all routes defined in this security matcher property are intercepted by this - * filter-chain.
  • - *
  • logoutRequestUriBuilder: builder for RP-Initiated Logout queries, taking - * configuration from properties for OIDC providers which do not strictly comply with the spec: logout URI not provided by OIDC conf or non standard parameter - * names (Auth0 and Cognito are samples of such OPs)
  • - *
  • logoutSuccessHandler: a {@link ServerLogoutSuccessHandler}. Default instance is a {@link SpringAddonsServerLogoutSuccessHandler} which logs a user out - * from the last authorization server he logged on
  • - *
  • authoritiesConverter: an {@link ClaimSetAuthoritiesConverter}. Default instance is a {@link ConfigurableClaimSetAuthoritiesConverter} which reads - * spring-addons {@link SpringAddonsOidcProperties}
  • - *
  • csrfCookieWebFilter: a {@link WebFilter} to set the CSRF cookie if "com.c4-soft.springaddons.oidc.client.csrf" is set to cookie
  • - *
  • clientAuthorizePostProcessor: a {@link ClientAuthorizeExchangeSpecPostProcessor} post processor to fine tune access control from java configuration. It - * applies to all routes not listed in "permit-all" property configuration. Default requires users to be authenticated.
  • - *
  • clientHttpPostProcessor: a {@link ClientReactiveHttpSecurityPostProcessor} to override anything from above auto-configuration. It is called just before - * the security filter-chain is returned. Default is a no-op.
  • - *
  • authorizationRequestResolver: a {@link ServerOAuth2AuthorizationRequestResolver} to add custom parameters (from application properties) to authorization - * code request
  • + *
  • springAddonsClientFilterChain: a {@link SecurityWebFilterChain}. Instantiated only if + * "com.c4-soft.springaddons.oidc.client.security-matchers" property has at least one entry. If + * defined, it is with a high precedence, to ensure that all routes defined in this security matcher + * property are intercepted by this filter-chain.
  • + *
  • logoutRequestUriBuilder: builder for + * RP-Initiated Logout + * queries, taking configuration from properties for OIDC providers which do not strictly comply + * with the spec: logout URI not provided by OIDC conf or non standard parameter names (Auth0 and + * Cognito are samples of such OPs)
  • + *
  • logoutSuccessHandler: a {@link ServerLogoutSuccessHandler}. Default instance is a + * {@link SpringAddonsServerLogoutSuccessHandler} which logs a user out from the last authorization + * server he logged on
  • + *
  • authoritiesConverter: an {@link ClaimSetAuthoritiesConverter}. Default instance is a + * {@link ConfigurableClaimSetAuthoritiesConverter} which reads spring-addons + * {@link SpringAddonsOidcProperties}
  • + *
  • csrfCookieWebFilter: a {@link WebFilter} to set the CSRF cookie if + * "com.c4-soft.springaddons.oidc.client.csrf" is set to cookie
  • + *
  • clientAuthorizePostProcessor: a {@link ClientAuthorizeExchangeSpecPostProcessor} post + * processor to fine tune access control from java configuration. It applies to all routes not + * listed in "permit-all" property configuration. Default requires users to be authenticated.
  • + *
  • clientHttpPostProcessor: a {@link ClientReactiveHttpSecurityPostProcessor} to override + * anything from above auto-configuration. It is called just before the security filter-chain is + * returned. Default is a no-op.
  • + *
  • authorizationRequestResolver: a {@link ServerOAuth2AuthorizationRequestResolver} to add + * custom parameters (from application properties) to authorization code request
  • *
* * @author Jerome Wacongne ch4mp@c4-soft.com */ -@Conditional({ IsClientWithLoginCondition.class, IsNotServlet.class }) +@Conditional({IsClientWithLoginCondition.class, IsNotServlet.class}) @EnableWebFluxSecurity @AutoConfiguration @ImportAutoConfiguration(ReactiveSpringAddonsOidcBeans.class) @Slf4j public class ReactiveSpringAddonsOidcClientWithLoginBeans { - /** - *

- * 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 ClientReactiveHttpSecurityPostProcessor} 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). By default, a - * {@link SpringAddonsServerOAuth2AuthorizationRequestResolver} is used - * @param preAuthorizationCodeRedirectStrategy the redirection strategy to use for authorization-code request - * @param authenticationSuccessHandler the authentication success handler to use. By default, a {@link SpringAddonsOauth2ServerAuthenticationSuccessHandler} - * is used. - * @param authenticationFailureHandler the authentication failure handler to use. By default, a {@link SpringAddonsOauth2ServerAuthenticationFailureHandler} - * is used. - * @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} - * @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 - */ - @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, - ClientReactiveHttpSecurityPostProcessor httpPostProcessor, - Optional logoutHandler, - Customizer oidcLogoutCustomizer) - throws Exception { + /** + *

+ * 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 ClientReactiveHttpSecurityPostProcessor} 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). By default, a + * {@link SpringAddonsServerOAuth2AuthorizationRequestResolver} is used + * @param preAuthorizationCodeRedirectStrategy the redirection strategy to use for + * authorization-code request + * @param authenticationSuccessHandler the authentication success handler to use. By default, a + * {@link SpringAddonsOauth2ServerAuthenticationSuccessHandler} is used. + * @param authenticationFailureHandler the authentication failure handler to use. By default, a + * {@link SpringAddonsOauth2ServerAuthenticationFailureHandler} is used. + * @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} + * @param oidcBackChannelLogoutHandler if present, Back-Channel Logout is enabled. A default + * {@link OidcBackChannelServerLogoutHandler} is provided if + * com.c4-soft.springaddons.oidc.client.back-channel-logout.enabled is true + * @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, + ServerAuthenticationEntryPoint authenticationEntryPoint, + ServerAuthenticationSuccessHandler authenticationSuccessHandler, + ServerAuthenticationFailureHandler authenticationFailureHandler, + ServerLogoutSuccessHandler logoutSuccessHandler, + ClientAuthorizeExchangeSpecPostProcessor authorizePostProcessor, + ClientReactiveHttpSecurityPostProcessor httpPostProcessor, + Optional logoutHandler, + Optional oidcBackChannelLogoutHandler) throws Exception { - final var clientRoutes = addonsProperties - .getClient() - .getSecurityMatchers() - .stream() - .map(PathPatternParserServerWebExchangeMatcher::new) - .map(ServerWebExchangeMatcher.class::cast) - .toList(); - log.info("Applying client OAuth2 configuration for: {}", addonsProperties.getClient().getSecurityMatchers()); - http.securityMatcher(new OrServerWebExchangeMatcher(clientRoutes)); + final var clientRoutes = addonsProperties.getClient().getSecurityMatchers().stream() + .map(PathPatternParserServerWebExchangeMatcher::new) + .map(ServerWebExchangeMatcher.class::cast).toList(); + log.info("Applying client OAuth2 configuration for: {}", + 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()))); + // @formatter:off + http.exceptionHandling(exceptions -> { + exceptions.authenticationEntryPoint(authenticationEntryPoint); }); http.oauth2Login(oauth2 -> { oauth2.authorizationRequestResolver(authorizationRequestResolver); oauth2.authorizationRedirectStrategy(preAuthorizationCodeRedirectStrategy); - authenticationSuccessHandler.ifPresent(oauth2::authenticationSuccessHandler); - authenticationFailureHandler.ifPresent(oauth2::authenticationFailureHandler); + oauth2.authenticationSuccessHandler(authenticationSuccessHandler); + oauth2.authenticationFailureHandler(authenticationFailureHandler); }); http.logout((logout) -> { - logoutHandler.ifPresent(logout::logoutHandler); + logoutHandler.ifPresent(handler -> { + if(!(handler instanceof OidcBackChannelServerLogoutHandler)) { + logout.logoutHandler(handler); + } + }); logout.logoutSuccessHandler(logoutSuccessHandler); }); - if(addonsProperties.getClient().getBackChannelLogout().isEnabled()) { - http.oidcLogout((logout) -> { - logout.backChannel(bc -> { - addonsProperties.getClient().getBackChannelLogout().getInternalLogoutUri().ifPresent(bc::logoutUri); - }); - }); - } + if (oidcBackChannelLogoutHandler.isPresent()) { + http.oidcLogout(ol -> ol.backChannel(bc -> bc.logoutHandler(oidcBackChannelLogoutHandler.get()))); + } ReactiveConfigurationSupport.configureClient(http, serverProperties, addonsProperties, authorizePostProcessor, httpPostProcessor); @@ -267,6 +279,12 @@ PreAuthorizationCodeServerRedirectStrategy preAuthorizationCodeRedirectStrategy( addonsProperties.getClient().getOauth2Redirections().getPreAuthorizationCode()); } + @Conditional(DefaultAuthenticationEntryPointCondition.class) + @Bean + ServerAuthenticationEntryPoint authenticationEntryPoint(SpringAddonsOidcProperties addonsProperties) { + return new SpringAddonsServerAuthenticationEntryPoint(addonsProperties.getClient()); + } + @Conditional(DefaultAuthenticationSuccessHandlerCondition.class) @Bean ServerAuthenticationSuccessHandler authenticationSuccessHandler(SpringAddonsOidcProperties addonsProperties) { @@ -285,13 +303,6 @@ public static class SpringAddonsPreAuthorizationCodeServerRedirectStrategy exten public SpringAddonsPreAuthorizationCodeServerRedirectStrategy(HttpStatus defaultStatus) { super(defaultStatus); } - - } - - @ConditionalOnMissingBean - @Bean - Customizer oidcLogoutSpec() { - return Customizer.withDefaults(); } /** @@ -306,4 +317,19 @@ CorsWebFilter corsFilter(SpringAddonsOidcProperties addonsProperties) { return ReactiveConfigurationSupport.getCorsFilterBean(corsProps); } + + @Conditional(DefaultOidcSessionRegistryCondition.class) + @Bean + ReactiveOidcSessionRegistry oidcSessionRegistry() { + return new InMemoryReactiveOidcSessionRegistry(); + } + + @Conditional(DefaultOidcBackChannelLogoutHandlerCondition.class) + @Bean + OidcBackChannelServerLogoutHandler oidcBackChannelLogoutHandler(ReactiveOidcSessionRegistry sessionRegistry, SpringAddonsOidcProperties addonsProperties) { + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(sessionRegistry); + addonsProperties.getClient().getBackChannelLogout().getInternalLogoutUri().ifPresent(logoutHandler::setLogoutUri); + addonsProperties.getClient().getBackChannelLogout().getCookieName().ifPresent(logoutHandler::setSessionCookieName); + return logoutHandler; + } } \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerAuthenticationFailureHandler.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerAuthenticationFailureHandler.java index 76685fe69..4a21dede4 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerAuthenticationFailureHandler.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerAuthenticationFailureHandler.java @@ -2,6 +2,8 @@ import java.net.URI; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; @@ -11,6 +13,8 @@ import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcClientProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** @@ -21,23 +25,36 @@ * @see SpringAddonsOidcClientProperties#POST_AUTHENTICATION_FAILURE_URI_SESSION_ATTRIBUTE for constant used as session attribute keys * @see SpringAddonsServerOAuth2AuthorizationRequestResolver which sets the post-login URI session attribute */ +@Slf4j public class SpringAddonsOauth2ServerAuthenticationFailureHandler implements ServerAuthenticationFailureHandler { private final URI defaultRedirectUri; - private final SpringAddonsOauth2ServerRedirectStrategy redirectStrategy; + private final HttpStatus postAuthorizationFailureStatus; public SpringAddonsOauth2ServerAuthenticationFailureHandler(SpringAddonsOidcProperties addonsProperties) { this.defaultRedirectUri = addonsProperties.getClient().getLoginErrorRedirectPath().orElse(URI.create("/")); - this.redirectStrategy = new SpringAddonsOauth2ServerRedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPostAuthorizationCode()); + this.postAuthorizationFailureStatus = addonsProperties.getClient().getOauth2Redirections().getPostAuthorizationFailure(); } @Override public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) { return webFilterExchange.getExchange().getSession().flatMap(session -> { - final var uri = UriComponentsBuilder.fromUri( + final var location = UriComponentsBuilder.fromUri( session.getAttributeOrDefault(SpringAddonsOidcClientProperties.POST_AUTHENTICATION_FAILURE_URI_SESSION_ATTRIBUTE, defaultRedirectUri)) .queryParam(SpringAddonsOidcClientProperties.POST_AUTHENTICATION_FAILURE_CAUSE_ATTRIBUTE, HtmlUtils.htmlEscape(exception.getMessage())) - .build().toUri(); - return redirectStrategy.sendRedirect(webFilterExchange.getExchange(), uri); + .build().toUri().toString(); + + final var response = webFilterExchange.getExchange().getResponse(); + response.setStatusCode(postAuthorizationFailureStatus); + response.getHeaders().add(HttpHeaders.LOCATION, location); + response.getHeaders().add(SpringAddonsOidcClientProperties.POST_AUTHENTICATION_FAILURE_CAUSE_ATTRIBUTE, exception.getMessage()); + + log.debug("Login failure. Status: {}, location: {}, message: {}", postAuthorizationFailureStatus, location, exception.getMessage()); + + if (postAuthorizationFailureStatus.is4xxClientError() || postAuthorizationFailureStatus.is5xxServerError()) { + final var buffer = response.bufferFactory().wrap(exception.getMessage().getBytes()); + return response.writeWith(Flux.just(buffer)); + } + return response.setComplete(); }); } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerAuthenticationSuccessHandler.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerAuthenticationSuccessHandler.java index 6e3e93e8d..995c83bec 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerAuthenticationSuccessHandler.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerAuthenticationSuccessHandler.java @@ -9,6 +9,7 @@ import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcClientProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; /** @@ -19,6 +20,7 @@ * @see SpringAddonsOidcClientProperties#POST_AUTHENTICATION_SUCCESS_URI_SESSION_ATTRIBUTE for constant used as session attribute keys * @see SpringAddonsServerOAuth2AuthorizationRequestResolver which sets the post-login URI session attribute */ +@Slf4j public class SpringAddonsOauth2ServerAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { private final URI defaultRedirectUri; private final SpringAddonsOauth2ServerRedirectStrategy redirectStrategy; @@ -33,6 +35,8 @@ public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, A return webFilterExchange.getExchange().getSession().flatMap(session -> { final var uri = session.getAttributeOrDefault(SpringAddonsOidcClientProperties.POST_AUTHENTICATION_SUCCESS_URI_SESSION_ATTRIBUTE, defaultRedirectUri); + + log.debug("Login success. Status: {}, location: {}", redirectStrategy.getDefaultStatus(), uri.toString()); return redirectStrategy.sendRedirect(webFilterExchange.getExchange(), uri); }); } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerRedirectStrategy.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerRedirectStrategy.java index ff024d2f7..15a5852b0 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerRedirectStrategy.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsOauth2ServerRedirectStrategy.java @@ -13,43 +13,40 @@ import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcClientProperties; +import lombok.Getter; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; /** - * A redirect strategy that might not actually redirect: the HTTP status is taken from com.c4-soft.springaddons.oidc.client.oauth2-redirect-status property. - * User-agents will auto redirect only if the status is in 3xx range. This gives single page and mobile applications a chance to intercept the redirection and - * choose to follow the redirection (or not), with which agent and potentially by clearing some headers. + * A redirect strategy that might not actually redirect: the HTTP status is taken from + * com.c4-soft.springaddons.oidc.client.oauth2-redirect-status property. User-agents will auto redirect only if the status is in 3xx range. + * This gives single page and mobile applications a chance to intercept the redirection and choose to follow the redirection (or not), with + * which agent and potentially by clearing some headers. * * @author Jerome Wacongne ch4mp@c4-soft.com */ @RequiredArgsConstructor public class SpringAddonsOauth2ServerRedirectStrategy implements ServerRedirectStrategy { - 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(SpringAddonsOidcClientProperties.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); - - response.getHeaders().setLocation(location); - }); - } + @Getter + 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(SpringAddonsOidcClientProperties.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); + + response.getHeaders().setLocation(location); + }); + } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsServerAuthenticationEntryPoint.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsServerAuthenticationEntryPoint.java new file mode 100644 index 000000000..ec4e9bab3 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/SpringAddonsServerAuthenticationEntryPoint.java @@ -0,0 +1,47 @@ +package com.c4_soft.springaddons.security.oidc.starter.reactive.client; + +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.server.ServerAuthenticationEntryPoint; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; + +import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcClientProperties; + +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Slf4j +public class SpringAddonsServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { + private final SpringAddonsOidcClientProperties clientProperties; + + public SpringAddonsServerAuthenticationEntryPoint(SpringAddonsOidcClientProperties addonsProperties) { + this.clientProperties = addonsProperties; + } + + @Override + public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { + final var location = clientProperties + .getLoginUri() + .orElse( + UriComponentsBuilder.fromUri(clientProperties.getClientUri()).pathSegment(clientProperties.getClientUri().getPath(), "/login").build().toUri()) + .toString(); + log.debug("Status: {}, location: {}", clientProperties.getOauth2Redirections().getAuthenticationEntryPoint().value(), location); + + final var response = exchange.getResponse(); + response.setStatusCode(clientProperties.getOauth2Redirections().getAuthenticationEntryPoint()); + response.getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, "OAuth realm=%s".formatted(location)); + response.getHeaders().add(HttpHeaders.LOCATION, location.toString()); + + if (clientProperties.getOauth2Redirections().getAuthenticationEntryPoint().is4xxClientError() || clientProperties + .getOauth2Redirections() + .getAuthenticationEntryPoint() + .is5xxServerError()) { + final var buffer = response.bufferFactory().wrap("Unauthorized. Please authenticate at %s".formatted(location.toString()).getBytes()); + return response.writeWith(Flux.just(buffer)); + } + + return response.setComplete(); + } +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveJWTClaimsSetAuthenticationManager.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveJWTClaimsSetAuthenticationManager.java index b8826d742..f17ed318a 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveJWTClaimsSetAuthenticationManager.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveJWTClaimsSetAuthenticationManager.java @@ -38,7 +38,7 @@ * configuration properties could not be resolved from the JWT claims. *

* - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public class ReactiveJWTClaimsSetAuthenticationManager implements ReactiveAuthenticationManager { @@ -81,7 +81,7 @@ public Mono authenticate(Authentication authentication) throws A * Provider configuration properties could not be resolved from the JWT claims. *

* - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ @RequiredArgsConstructor public static class ReactiveJWTClaimsSetAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver { diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveSpringAddonsOidcResourceServerBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveSpringAddonsOidcResourceServerBeans.java index 38ac02c56..8f9bece9a 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveSpringAddonsOidcResourceServerBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveSpringAddonsOidcResourceServerBeans.java @@ -1,12 +1,10 @@ package com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver; -import java.nio.charset.Charset; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; -import java.util.Map; -import java.util.Optional; import java.util.Date; +import java.util.Map; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; @@ -18,15 +16,11 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.convert.converter.Converter; -import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; @@ -37,11 +31,7 @@ import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionAuthenticatedPrincipal; import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.server.SecurityWebFilterChain; -import org.springframework.security.web.server.ServerAuthenticationEntryPoint; -import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler; import org.springframework.security.web.server.csrf.CsrfToken; import org.springframework.web.cors.reactive.CorsWebFilter; import org.springframework.web.server.ServerWebExchange; @@ -68,22 +58,22 @@ /** *

* Usage
- * If not using spring-boot, @Import or @ComponentScan this class. All beans defined here are @ConditionalOnMissingBean => just define your own - * @Beans to override. + * If not using spring-boot, @Import or @ComponentScan this class. All beans defined here are @ConditionalOnMissingBean => + * just define your own @Beans to override. *

*

* Provided @Beans *

*
    - *
  • SecurityWebFilterChain: applies CORS, CSRF, anonymous, sessionCreationPolicy, SSL redirect and 401 instead of redirect to login properties as - * defined in {@link SpringAddonsOidcProperties}
  • - *
  • AuthorizeExchangeSpecPostProcessor. Override if you need fined grained HTTP security (more than authenticated() to all routes but the ones defined - * as permitAll() in {@link SpringAddonsOidcProperties}
  • + *
  • SecurityWebFilterChain: applies CORS, CSRF, anonymous, sessionCreationPolicy, SSL redirect and 401 instead of redirect to + * login properties as defined in {@link SpringAddonsOidcProperties}
  • + *
  • AuthorizeExchangeSpecPostProcessor. Override if you need fined grained HTTP security (more than authenticated() to all routes + * but the ones defined as permitAll() in {@link SpringAddonsOidcProperties}
  • *
  • Jwt2AuthoritiesConverter: responsible for converting the JWT into Collection<? extends GrantedAuthority>
  • - *
  • ReactiveJwt2OpenidClaimSetConverter<T extends Map<String, Object> & Serializable>: responsible for converting the JWT into a - * claim-set of your choice (OpenID or not)
  • - *
  • ReactiveJwt2AuthenticationConverter<OAuthentication<T extends OpenidClaimSet>>: responsible for converting the JWT into an - * Authentication (uses both beans above)
  • + *
  • ReactiveJwt2OpenidClaimSetConverter<T extends Map<String, Object> & Serializable>: responsible for converting + * the JWT into a claim-set of your choice (OpenID or not)
  • + *
  • ReactiveJwt2AuthenticationConverter<OAuthentication<T extends OpenidClaimSet>>: responsible for converting the JWT + * into an Authentication (uses both beans above)
  • *
  • ReactiveAuthenticationManagerResolver: required to be able to define more than one token issuer until * https://github.com/spring-projects/spring-boot/issues/30108 is solved
  • *
@@ -96,277 +86,234 @@ @ImportAutoConfiguration(ReactiveSpringAddonsOidcBeans.class) public class ReactiveSpringAddonsOidcResourceServerBeans { - /** - *

- * Applies SpringAddonsSecurityProperties to web security config. Be aware that defining a {@link SecurityWebFilterChain} bean with no security matcher and - * an order higher than LOWEST_PRECEDENCE will disable most of this lib auto-configuration for OpenID resource-servers. - *

- *

- * You should consider to set security matcher to all other {@link SecurityWebFilterChain} beans and provide a - * {@link ResourceServerReactiveHttpSecurityPostProcessor} bean to override anything from this bean - *

- * . - * - * @param http HTTP security to configure - * @param serverProperties Spring "server" configuration properties - * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties - * @param authorizePostProcessor Hook to override access-control rules for all path that are not listed in "permit-all" - * @param httpPostProcessor Hook to override all or part of HttpSecurity auto-configuration - * @param authenticationManagerResolver Converts successful JWT decoding result into an {@link Authentication} - * @param authenticationEntryPoint The {@link AuthenticationEntryPoint} to use (defaults returns 401) - * @param accessDeniedHandler An optional {@link AccessDeniedHandler} to use instead of Boot default one - * @return A default {@link SecurityWebFilterChain} for reactive resource-servers with JWT decoder(matches all unmatched routes with lowest precedence) - */ - @Conditional(IsJwtDecoderResourceServerCondition.class) - @Order(Ordered.LOWEST_PRECEDENCE) - @Bean - SecurityWebFilterChain springAddonsJwtResourceServerSecurityFilterChain( - ServerHttpSecurity http, - ServerProperties serverProperties, - SpringAddonsOidcProperties addonsProperties, - ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor, - ResourceServerReactiveHttpSecurityPostProcessor httpPostProcessor, - ReactiveAuthenticationManagerResolver authenticationManagerResolver, - ServerAuthenticationEntryPoint authenticationEntryPoint, - Optional accessDeniedHandler) { - http.oauth2ResourceServer(server -> server.authenticationManagerResolver(authenticationManagerResolver)); - - ReactiveConfigurationSupport - .configureResourceServer( - http, - serverProperties, - addonsProperties, - authenticationEntryPoint, - accessDeniedHandler, - authorizePostProcessor, - httpPostProcessor); + /** + *

+ * Applies SpringAddonsSecurityProperties to web security config. Be aware that defining a {@link SecurityWebFilterChain} bean with no + * security matcher and an order higher than LOWEST_PRECEDENCE will disable most of this lib auto-configuration for OpenID resource-servers. + *

+ *

+ * You should consider to set security matcher to all other {@link SecurityWebFilterChain} beans and provide a + * {@link ResourceServerReactiveHttpSecurityPostProcessor} bean to override anything from this bean + *

+ * . + * + * @param http HTTP security to configure + * @param serverProperties Spring "server" configuration properties + * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties + * @param authorizePostProcessor Hook to override access-control rules for all path that are not listed in "permit-all" + * @param httpPostProcessor Hook to override all or part of HttpSecurity auto-configuration + * @param authenticationManagerResolver Converts successful JWT decoding result into an {@link Authentication} + * @return A default {@link SecurityWebFilterChain} for reactive resource-servers with JWT decoder(matches all + * unmatched routes with lowest precedence) + */ + @Conditional(IsJwtDecoderResourceServerCondition.class) + @Order(Ordered.LOWEST_PRECEDENCE) + @Bean + SecurityWebFilterChain springAddonsJwtResourceServerSecurityFilterChain( + ServerHttpSecurity http, + ServerProperties serverProperties, + SpringAddonsOidcProperties addonsProperties, + ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor, + ResourceServerReactiveHttpSecurityPostProcessor httpPostProcessor, + ReactiveAuthenticationManagerResolver authenticationManagerResolver) { + http.oauth2ResourceServer(server -> { + server.authenticationManagerResolver(authenticationManagerResolver); + }); - return http.build(); - } + ReactiveConfigurationSupport.configureResourceServer(http, serverProperties, addonsProperties, authorizePostProcessor, httpPostProcessor); - /** - *

- * Applies SpringAddonsSecurityProperties to web security config. Be aware that defining a {@link SecurityWebFilterChain} bean with no security matcher and - * an order higher than LOWEST_PRECEDENCE will disable most of this lib auto-configuration for OpenID resource-servers. - *

- *

- * You should consider to set security matcher to all other {@link SecurityWebFilterChain} beans and provide a - * {@link ResourceServerReactiveHttpSecurityPostProcessor} bean to override anything from this bean - *

- * . - * - * @param http HTTP security to configure - * @param serverProperties Spring "server" configuration properties - * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties - * @param authorizePostProcessor Hook to override access-control rules for all path that are not listed in "permit-all" - * @param httpPostProcessor Hook to override all or part of HttpSecurity auto-configuration - * @param introspectionAuthenticationConverter Converts successful introspection result into an {@link Authentication} - * @param authenticationEntryPoint The {@link AuthenticationEntryPoint} to use (defaults returns 401) - * @param accessDeniedHandler An optional {@link AccessDeniedHandler} to use instead of Boot default one - * @return A default {@link SecurityWebFilterChain} for reactive resource-servers with access-token introspection (matches all unmatched routes with lowest - * precedence) - */ - @Conditional(IsIntrospectingResourceServerCondition.class) - @Order(Ordered.LOWEST_PRECEDENCE) - @Bean - SecurityWebFilterChain springAddonsIntrospectingResourceServerSecurityFilterChain( - ServerHttpSecurity http, - ServerProperties serverProperties, - SpringAddonsOidcProperties addonsProperties, - ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor, - ResourceServerReactiveHttpSecurityPostProcessor httpPostProcessor, - ReactiveOpaqueTokenAuthenticationConverter introspectionAuthenticationConverter, - ReactiveOpaqueTokenIntrospector opaqueTokenIntrospector, - ServerAuthenticationEntryPoint authenticationEntryPoint, - Optional accessDeniedHandler) { - http.oauth2ResourceServer(server -> server.opaqueToken(ot -> { - ot.introspector(opaqueTokenIntrospector); - ot.authenticationConverter(introspectionAuthenticationConverter); - })); + return http.build(); + } - ReactiveConfigurationSupport - .configureResourceServer( - http, - serverProperties, - addonsProperties, - authenticationEntryPoint, - accessDeniedHandler, - authorizePostProcessor, - httpPostProcessor); + /** + *

+ * Applies SpringAddonsSecurityProperties to web security config. Be aware that defining a {@link SecurityWebFilterChain} bean with no + * security matcher and an order higher than LOWEST_PRECEDENCE will disable most of this lib auto-configuration for OpenID resource-servers. + *

+ *

+ * You should consider to set security matcher to all other {@link SecurityWebFilterChain} beans and provide a + * {@link ResourceServerReactiveHttpSecurityPostProcessor} bean to override anything from this bean + *

+ * . + * + * @param http HTTP security to configure + * @param serverProperties Spring "server" configuration properties + * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties + * @param authorizePostProcessor Hook to override access-control rules for all path that are not listed in "permit-all" + * @param httpPostProcessor Hook to override all or part of HttpSecurity auto-configuration + * @param introspectionAuthenticationConverter Converts successful introspection result into an {@link Authentication} + * @return A default {@link SecurityWebFilterChain} for reactive resource-servers with access-token + * introspection (matches all unmatched routes with lowest precedence) + */ + @Conditional(IsIntrospectingResourceServerCondition.class) + @Order(Ordered.LOWEST_PRECEDENCE) + @Bean + SecurityWebFilterChain springAddonsIntrospectingResourceServerSecurityFilterChain( + ServerHttpSecurity http, + ServerProperties serverProperties, + SpringAddonsOidcProperties addonsProperties, + ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor, + ResourceServerReactiveHttpSecurityPostProcessor httpPostProcessor, + ReactiveOpaqueTokenAuthenticationConverter introspectionAuthenticationConverter, + ReactiveOpaqueTokenIntrospector opaqueTokenIntrospector) { + http.oauth2ResourceServer(server -> server.opaqueToken(ot -> { + ot.introspector(opaqueTokenIntrospector); + ot.authenticationConverter(introspectionAuthenticationConverter); + })); - return http.build(); - } + ReactiveConfigurationSupport.configureResourceServer(http, serverProperties, addonsProperties, authorizePostProcessor, httpPostProcessor); - /** - * Hook to override security rules for all path that are not listed in "permit-all". Default is isAuthenticated(). - * - * @return a hook to override security rules for all path that are not listed in "permit-all". Default is isAuthenticated(). - */ - @ConditionalOnMissingBean - @Bean - ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor() { - return (ServerHttpSecurity.AuthorizeExchangeSpec spec) -> spec.anyExchange().authenticated(); - } + return http.build(); + } - /** - * Hook to override all or part of HttpSecurity auto-configuration. Called after spring-addons configuration was applied so that you can modify anything - * - * @return a hook to override all or part of HttpSecurity auto-configuration. Called after spring-addons configuration was applied so that you can modify - * anything - */ - @ConditionalOnMissingBean - @Bean - ResourceServerReactiveHttpSecurityPostProcessor httpPostProcessor() { - return serverHttpSecurity -> serverHttpSecurity; - } + /** + * Hook to override security rules for all path that are not listed in "permit-all". Default is isAuthenticated(). + * + * @return a hook to override security rules for all path that are not listed in "permit-all". Default is isAuthenticated(). + */ + @ConditionalOnMissingBean + @Bean + ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor() { + return (ServerHttpSecurity.AuthorizeExchangeSpec spec) -> spec.anyExchange().authenticated(); + } - @ConditionalOnMissingBean - @Bean - SpringAddonsReactiveJwtDecoderFactory springAddonsJwtDecoderFactory() { - return new DefaultSpringAddonsReactiveJwtDecoderFactory(); - } + /** + * Hook to override all or part of HttpSecurity auto-configuration. Called after spring-addons configuration was applied so that you can + * modify anything + * + * @return a hook to override all or part of HttpSecurity auto-configuration. Called after spring-addons configuration was applied so that + * you can modify anything + */ + @ConditionalOnMissingBean + @Bean + ResourceServerReactiveHttpSecurityPostProcessor httpPostProcessor() { + return serverHttpSecurity -> serverHttpSecurity; + } - /** - * Provides with multi-tenancy: builds a ReactiveAuthenticationManagerResolver per provided OIDC issuer URI - * - * @param opPropertiesResolver "com.c4-soft.springaddons.oidc" configuration properties - * @param jwtDecoderFactory something to build a JWT decoder from OpenID Provider configuration properties - * @param jwtAuthenticationConverter converts from a {@link Jwt} to an {@link Authentication} implementation - * @return Multi-tenant {@link ReactiveAuthenticationManagerResolver} (one for each configured issuer) - */ - @Conditional(DefaultAuthenticationManagerResolverCondition.class) - @Bean - ReactiveAuthenticationManagerResolver authenticationManagerResolver( - OpenidProviderPropertiesResolver opPropertiesResolver, - SpringAddonsReactiveJwtDecoderFactory jwtDecoderFactory, - Converter> jwtAuthenticationConverter) { - return new SpringAddonsReactiveJwtAuthenticationManagerResolver(opPropertiesResolver, jwtDecoderFactory, jwtAuthenticationConverter); - } + @ConditionalOnMissingBean + @Bean + SpringAddonsReactiveJwtDecoderFactory springAddonsJwtDecoderFactory() { + return new DefaultSpringAddonsReactiveJwtDecoderFactory(); + } - /** - * Bean to switch from default behavior of redirecting unauthorized users to login (302) to returning 401 (unauthorized) - * - * @return a bean to switch from default behavior of redirecting unauthorized users to login (302) to returning 401 (unauthorized) - */ - @ConditionalOnMissingBean - @Bean - ServerAuthenticationEntryPoint authenticationEntryPoint() { - return (ServerWebExchange exchange, AuthenticationException ex) -> exchange.getPrincipal().flatMap(principal -> { - var response = exchange.getResponse(); - response.setStatusCode(HttpStatus.UNAUTHORIZED); - response.getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"Restricted Content\""); - var dataBufferFactory = response.bufferFactory(); - var buffer = dataBufferFactory.wrap(ex.getMessage().getBytes(Charset.defaultCharset())); - return response.writeWith(Mono.just(buffer)).doOnError(error -> DataBufferUtils.release(buffer)); - }); - } + /** + * Provides with multi-tenancy: builds a ReactiveAuthenticationManagerResolver per provided OIDC issuer URI + * + * @param opPropertiesResolver "com.c4-soft.springaddons.oidc" configuration properties + * @param jwtDecoderFactory something to build a JWT decoder from OpenID Provider configuration properties + * @param jwtAuthenticationConverter converts from a {@link Jwt} to an {@link Authentication} implementation + * @return Multi-tenant {@link ReactiveAuthenticationManagerResolver} (one for each configured issuer) + */ + @Conditional(DefaultAuthenticationManagerResolverCondition.class) + @Bean + ReactiveAuthenticationManagerResolver authenticationManagerResolver( + OpenidProviderPropertiesResolver opPropertiesResolver, + SpringAddonsReactiveJwtDecoderFactory jwtDecoderFactory, + Converter> jwtAuthenticationConverter) { + return new SpringAddonsReactiveJwtAuthenticationManagerResolver(opPropertiesResolver, jwtDecoderFactory, jwtAuthenticationConverter); + } - /** - * https://docs.spring.io/spring-security/reference/5.8/migration/reactive.html#_i_am_using_angularjs_or_another_javascript_framework - */ - @Conditional(CookieCsrfCondition.class) - @ConditionalOnMissingBean(name = "csrfCookieWebFilter") - @Bean - WebFilter csrfCookieWebFilter() { - return (exchange, chain) -> { - Mono csrfToken = exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty()); - return csrfToken.doOnSuccess(token -> {}).then(chain.filter(exchange)); - }; - } + /** + * https://docs.spring.io/spring-security/reference/5.8/migration/reactive.html#_i_am_using_angularjs_or_another_javascript_framework + */ + @Conditional(CookieCsrfCondition.class) + @ConditionalOnMissingBean(name = "csrfCookieWebFilter") + @Bean + WebFilter csrfCookieWebFilter() { + return (exchange, chain) -> { + Mono csrfToken = exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty()); + return csrfToken.doOnSuccess(token -> { + }).then(chain.filter(exchange)); + }; + } - /** - * Converter bean from {@link Jwt} to {@link AbstractAuthenticationToken} - * - * @param authoritiesConverter converts access-token claims into Spring authorities - * @param opPropertiesResolver "com.c4-soft.springaddons.oidc" configuration properties - * @return a converter from {@link Jwt} to {@link AbstractAuthenticationToken} - */ - @Conditional(DefaultJwtAbstractAuthenticationTokenConverterCondition.class) - @Bean - ReactiveJwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter( - Converter, Collection> authoritiesConverter, - OpenidProviderPropertiesResolver opPropertiesResolver) { - return jwt -> Mono - .just( - new JwtAuthenticationToken( - jwt, - authoritiesConverter.convert(jwt.getClaims()), - new OpenidClaimSet( - jwt.getClaims(), - opPropertiesResolver - .resolve(jwt.getClaims()) - .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())) - .getUsernameClaim()).getName())); - } + /** + * Converter bean from {@link Jwt} to {@link AbstractAuthenticationToken} + * + * @param authoritiesConverter converts access-token claims into Spring authorities + * @param opPropertiesResolver "com.c4-soft.springaddons.oidc" configuration properties + * @return a converter from {@link Jwt} to {@link AbstractAuthenticationToken} + */ + @Conditional(DefaultJwtAbstractAuthenticationTokenConverterCondition.class) + @Bean + ReactiveJwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return jwt -> Mono.just( + new JwtAuthenticationToken( + jwt, + authoritiesConverter.convert(jwt.getClaims()), + new OpenidClaimSet( + jwt.getClaims(), + opPropertiesResolver.resolve(jwt.getClaims()).orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())) + .getUsernameClaim()).getName())); + } - /** - * Converter bean from successful introspection result to {@link Authentication} instance - * - * @param authoritiesConverter converts access-token claims into Spring authorities - * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties - * @param resourceServerProperties Spring Boot standard resource server configuration properties - * @return a converter from successful introspection result to {@link Authentication} instance - */ - @Conditional(DefaultOpaqueTokenAuthenticationConverterCondition.class) - @Bean - @SuppressWarnings("unchecked") - ReactiveOpaqueTokenAuthenticationConverter introspectionAuthenticationConverter( - Converter, Collection> authoritiesConverter, - SpringAddonsOidcProperties addonsProperties, - OAuth2ResourceServerProperties resourceServerProperties) { - return (String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> Mono - .just( - new BearerTokenAuthentication( - new OAuth2IntrospectionAuthenticatedPrincipal( - new OpenidClaimSet( - authenticatedPrincipal.getAttributes(), - addonsProperties - .getOps() - .stream() - .filter(issProps -> resourceServerProperties.getOpaquetoken().getIntrospectionUri().contains(issProps.getIss().toString())) - .findAny() - .orElse(addonsProperties.getOps().get(0)) - .getUsernameClaim()).getName(), - authenticatedPrincipal.getAttributes(), - (Collection) authenticatedPrincipal.getAuthorities()), - new OAuth2AccessToken( - OAuth2AccessToken.TokenType.BEARER, - introspectedToken, - toInstant(authenticatedPrincipal.getAttribute(OAuth2TokenIntrospectionClaimNames.IAT)), - toInstant(authenticatedPrincipal.getAttribute(OAuth2TokenIntrospectionClaimNames.EXP))), - authoritiesConverter.convert(authenticatedPrincipal.getAttributes()))); - } + /** + * Converter bean from successful introspection result to {@link Authentication} instance + * + * @param authoritiesConverter converts access-token claims into Spring authorities + * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties + * @param resourceServerProperties Spring Boot standard resource server configuration properties + * @return a converter from successful introspection result to {@link Authentication} instance + */ + @Conditional(DefaultOpaqueTokenAuthenticationConverterCondition.class) + @Bean + @SuppressWarnings("unchecked") + ReactiveOpaqueTokenAuthenticationConverter introspectionAuthenticationConverter( + Converter, Collection> authoritiesConverter, + SpringAddonsOidcProperties addonsProperties, + OAuth2ResourceServerProperties resourceServerProperties) { + return (String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> Mono.just( + new BearerTokenAuthentication( + new OAuth2IntrospectionAuthenticatedPrincipal( + new OpenidClaimSet( + authenticatedPrincipal.getAttributes(), + addonsProperties.getOps().stream() + .filter( + issProps -> resourceServerProperties.getOpaquetoken().getIntrospectionUri() + .contains(issProps.getIss().toString())) + .findAny().orElse(addonsProperties.getOps().get(0)).getUsernameClaim()).getName(), + authenticatedPrincipal.getAttributes(), + (Collection) authenticatedPrincipal.getAuthorities()), + new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + introspectedToken, + toInstant(authenticatedPrincipal.getAttribute(OAuth2TokenIntrospectionClaimNames.IAT)), + toInstant(authenticatedPrincipal.getAttribute(OAuth2TokenIntrospectionClaimNames.EXP))), + authoritiesConverter.convert(authenticatedPrincipal.getAttributes()))); + } - /** - * FIXME: use only the new CORS properties at next major release - */ - @Conditional(DefaultCorsWebFilterCondition.class) - @Bean - CorsWebFilter corsFilter(SpringAddonsOidcProperties addonsProperties) { - final var corsProps = new ArrayList<>(addonsProperties.getCors()); - final var deprecatedClientCorsProps = addonsProperties.getResourceserver().getCors(); - corsProps.addAll(deprecatedClientCorsProps); + /** + * FIXME: use only the new CORS properties at next major release + */ + @Conditional(DefaultCorsWebFilterCondition.class) + @Bean + CorsWebFilter corsFilter(SpringAddonsOidcProperties addonsProperties) { + final var corsProps = new ArrayList<>(addonsProperties.getCors()); + final var deprecatedClientCorsProps = addonsProperties.getResourceserver().getCors(); + corsProps.addAll(deprecatedClientCorsProps); - return ReactiveConfigurationSupport.getCorsFilterBean(corsProps); - } + return ReactiveConfigurationSupport.getCorsFilterBean(corsProps); + } - private static final Instant toInstant(Object claim) { - if (claim == null) { - return null; - } - if (claim instanceof Instant i) { - return i; - } - if (claim instanceof Date d) { - return d.toInstant(); - } - if (claim instanceof Integer i) { - return Instant.ofEpochSecond((i).longValue()); - } else if (claim instanceof Long l) { - return Instant.ofEpochSecond(l); - } else { - return null; - } - } + private static final Instant toInstant(Object claim) { + if (claim == null) { + return null; + } + if (claim instanceof Instant i) { + return i; + } + if (claim instanceof Date d) { + return d.toInstant(); + } + if (claim instanceof Integer i) { + return Instant.ofEpochSecond((i).longValue()); + } else if (claim instanceof Long l) { + return Instant.ofEpochSecond(l); + } else { + return null; + } + } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtAuthenticationManagerResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtAuthenticationManagerResolver.java index c35bbf64b..ec99b7b3c 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtAuthenticationManagerResolver.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtAuthenticationManagerResolver.java @@ -23,7 +23,7 @@ * configuration properties could not be resolved from the JWT claims. *

* - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public class SpringAddonsReactiveJwtAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver { private final ReactiveJWTClaimsSetAuthenticationManager authenticationManager; diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/ServletConfigurationSupport.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/ServletConfigurationSupport.java index 9131e6f28..c270f0ce7 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/ServletConfigurationSupport.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/ServletConfigurationSupport.java @@ -1,13 +1,15 @@ package com.c4_soft.springaddons.security.oidc.starter.synchronised; import static org.springframework.security.config.Customizer.withDefaults; - import java.io.IOException; +import java.net.URI; import java.util.ArrayList; import java.util.List; import java.util.function.Supplier; - +import java.util.stream.Collectors; import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.lang.NonNull; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -23,15 +25,14 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; import org.springframework.web.filter.OncePerRequestFilter; - import com.c4_soft.springaddons.security.oidc.starter.properties.CorsProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.Csrf; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties.OpenidProviderProperties; import com.c4_soft.springaddons.security.oidc.starter.synchronised.client.ClientExpressionInterceptUrlRegistryPostProcessor; import com.c4_soft.springaddons.security.oidc.starter.synchronised.client.ClientSynchronizedHttpSecurityPostProcessor; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.ResourceServerExpressionInterceptUrlRegistryPostProcessor; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.ResourceServerSynchronizedHttpSecurityPostProcessor; - import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -39,185 +40,192 @@ public class ServletConfigurationSupport { - public static HttpSecurity configureResourceServer( - HttpSecurity http, - ServerProperties serverProperties, - SpringAddonsOidcProperties addonsProperties, - ResourceServerExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, - ResourceServerSynchronizedHttpSecurityPostProcessor httpPostProcessor) - throws Exception { - - ServletConfigurationSupport - .configureState(http, addonsProperties.getResourceserver().isStatlessSessions(), addonsProperties.getResourceserver().getCsrf()); - - // FIXME: use only the new CORS properties at next major release - final var corsProps = new ArrayList<>(addonsProperties.getCors()); - final var deprecatedClientCorsProps = addonsProperties.getClient().getCors(); - final var deprecatedResourceServerCorsProps = addonsProperties.getResourceserver().getCors(); - corsProps.addAll(deprecatedClientCorsProps); - corsProps.addAll(deprecatedResourceServerCorsProps); - ServletConfigurationSupport.configureAccess(http, addonsProperties.getResourceserver().getPermitAll(), corsProps, authorizePostProcessor); - - if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { - http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); - } - - return httpPostProcessor.process(http); + public static HttpSecurity configureResourceServer(HttpSecurity http, + ServerProperties serverProperties, SpringAddonsOidcProperties addonsProperties, + ResourceServerExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, + ResourceServerSynchronizedHttpSecurityPostProcessor httpPostProcessor) throws Exception { + + http.exceptionHandling(exceptions -> { + final var issuers = addonsProperties.getOps().stream().map(OpenidProviderProperties::getIss) + .filter(iss -> iss != null).map(URI::toString) + .collect(Collectors.joining(",", "\"", "\"")); + exceptions.authenticationEntryPoint((request, response, authException) -> { + response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "OAuth realm=%s".formatted(issuers)); + response.sendError(HttpStatus.UNAUTHORIZED.value(), + HttpStatus.UNAUTHORIZED.getReasonPhrase()); + }); + }); + + ServletConfigurationSupport.configureState(http, + addonsProperties.getResourceserver().isStatlessSessions(), + addonsProperties.getResourceserver().getCsrf()); + + // FIXME: use only the new CORS properties at next major release + final var corsProps = new ArrayList<>(addonsProperties.getCors()); + final var deprecatedClientCorsProps = addonsProperties.getClient().getCors(); + final var deprecatedResourceServerCorsProps = addonsProperties.getResourceserver().getCors(); + corsProps.addAll(deprecatedClientCorsProps); + corsProps.addAll(deprecatedResourceServerCorsProps); + ServletConfigurationSupport.configureAccess(http, + addonsProperties.getResourceserver().getPermitAll(), corsProps, authorizePostProcessor); + + if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { + http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); + } + + return httpPostProcessor.process(http); + } + + public static HttpSecurity configureClient(HttpSecurity http, ServerProperties serverProperties, + SpringAddonsOidcProperties addonsProperties, + ClientExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, + ClientSynchronizedHttpSecurityPostProcessor httpPostProcessor) throws Exception { + + ServletConfigurationSupport.configureState(http, false, addonsProperties.getClient().getCsrf()); + + // FIXME: use only the new CORS properties at next major release + final var corsProps = new ArrayList<>(addonsProperties.getCors()); + final var deprecatedClientCorsProps = addonsProperties.getClient().getCors(); + final var deprecatedResourceServerCorsProps = addonsProperties.getResourceserver().getCors(); + corsProps.addAll(deprecatedClientCorsProps); + corsProps.addAll(deprecatedResourceServerCorsProps); + ServletConfigurationSupport.configureAccess(http, addonsProperties.getClient().getPermitAll(), + corsProps, authorizePostProcessor); + + if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { + http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); + } + + return httpPostProcessor.process(http); + } + + public static HttpSecurity configureAccess(HttpSecurity http, List permitAll, + List corsProperties, + ExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor) throws Exception { + final var permittedCorsOptions = corsProperties.stream() + .filter(cors -> (cors.getAllowedMethods().contains("*") + || cors.getAllowedMethods().contains("OPTIONS")) && !cors.isDisableAnonymousOptions()) + .map(CorsProperties::getPath).toList(); + + if (permitAll.size() > 0 || permittedCorsOptions.size() > 0) { + http.anonymous(withDefaults()); } - public static HttpSecurity configureClient( - HttpSecurity http, - ServerProperties serverProperties, - SpringAddonsOidcProperties addonsProperties, - ClientExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, - ClientSynchronizedHttpSecurityPostProcessor httpPostProcessor) - throws Exception { - - ServletConfigurationSupport.configureState(http, false, addonsProperties.getClient().getCsrf()); - - // FIXME: use only the new CORS properties at next major release - final var corsProps = new ArrayList<>(addonsProperties.getCors()); - final var deprecatedClientCorsProps = addonsProperties.getClient().getCors(); - final var deprecatedResourceServerCorsProps = addonsProperties.getResourceserver().getCors(); - corsProps.addAll(deprecatedClientCorsProps); - corsProps.addAll(deprecatedResourceServerCorsProps); - ServletConfigurationSupport.configureAccess(http, addonsProperties.getClient().getPermitAll(), corsProps, authorizePostProcessor); - - if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { - http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); - } - - return httpPostProcessor.process(http); + if (permitAll.size() > 0) { + http.authorizeHttpRequests(registry -> registry.requestMatchers( + permitAll.stream().map(AntPathRequestMatcher::new).toArray(AntPathRequestMatcher[]::new)) + .permitAll()); } - public static HttpSecurity configureAccess( - HttpSecurity http, - List permitAll, - List corsProperties, - ExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor) - throws Exception { - final var permittedCorsOptions = corsProperties - .stream() - .filter(cors -> (cors.getAllowedMethods().contains("*") || cors.getAllowedMethods().contains("OPTIONS")) && !cors.isDisableAnonymousOptions()) - .map(CorsProperties::getPath) - .toList(); - - if (permitAll.size() > 0 || permittedCorsOptions.size() > 0) { - http.anonymous(withDefaults()); - } - - if (permitAll.size() > 0) { - http - .authorizeHttpRequests( - registry -> registry.requestMatchers(permitAll.stream().map(AntPathRequestMatcher::new).toArray(AntPathRequestMatcher[]::new)).permitAll()); - } - - if (permittedCorsOptions.size() > 0) { - http - .authorizeHttpRequests( - registry -> registry - .requestMatchers( - permittedCorsOptions - .stream() - .map(corsPathPattern -> new AntPathRequestMatcher(corsPathPattern, "OPTIONS")) - .toArray(AntPathRequestMatcher[]::new)) - .permitAll()); - } - - return http.authorizeHttpRequests(registry -> authorizePostProcessor.authorizeHttpRequests(registry)); + if (permittedCorsOptions.size() > 0) { + http.authorizeHttpRequests(registry -> registry.requestMatchers(permittedCorsOptions.stream() + .map(corsPathPattern -> new AntPathRequestMatcher(corsPathPattern, "OPTIONS")) + .toArray(AntPathRequestMatcher[]::new)).permitAll()); } - public static CorsFilter getCorsFilterBean(List corsProperties) { - final var source = new UrlBasedCorsConfigurationSource(); - for (final var corsProps : corsProperties) { - final var configuration = new CorsConfiguration(); - configuration.setAllowCredentials(corsProps.getAllowCredentials()); - configuration.setAllowedHeaders(corsProps.getAllowedHeaders()); - configuration.setAllowedMethods(corsProps.getAllowedMethods()); - configuration.setAllowedOriginPatterns(corsProps.getAllowedOriginPatterns()); - configuration.setExposedHeaders(corsProps.getExposedHeaders()); - configuration.setMaxAge(corsProps.getMaxAge()); - source.registerCorsConfiguration(corsProps.getPath(), configuration); - } - return new CorsFilter(source); + return http + .authorizeHttpRequests(registry -> authorizePostProcessor.authorizeHttpRequests(registry)); + } + + public static CorsFilter getCorsFilterBean(List corsProperties) { + final var source = new UrlBasedCorsConfigurationSource(); + for (final var corsProps : corsProperties) { + final var configuration = new CorsConfiguration(); + configuration.setAllowCredentials(corsProps.getAllowCredentials()); + configuration.setAllowedHeaders(corsProps.getAllowedHeaders()); + configuration.setAllowedMethods(corsProps.getAllowedMethods()); + configuration.setAllowedOriginPatterns(corsProps.getAllowedOriginPatterns()); + configuration.setExposedHeaders(corsProps.getExposedHeaders()); + configuration.setMaxAge(corsProps.getMaxAge()); + source.registerCorsConfiguration(corsProps.getPath(), configuration); } + return new CorsFilter(source); + } - public static HttpSecurity configureState(HttpSecurity http, boolean isStatless, Csrf csrfEnum) throws Exception { - - if (isStatless) { - http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - } - - http.csrf(configurer -> { - switch (csrfEnum) { - case DISABLE: - configurer.disable(); - break; - case DEFAULT: - if (isStatless) { - configurer.disable(); - } - break; - case SESSION: - break; - case COOKIE_ACCESSIBLE_FROM_JS: - // Taken from - // https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa-configuration - configurer.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()); - http.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class); - break; - } - }); - - return http; + public static HttpSecurity configureState(HttpSecurity http, boolean isStatless, Csrf csrfEnum) + throws Exception { + + if (isStatless) { + http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); } - /** - * Copied from https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa-configuration - */ - static final class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler { - private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler(); - - @Override - public void handle(HttpServletRequest request, HttpServletResponse response, Supplier csrfToken) { - /* - * Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of the CsrfToken when it is rendered in the response body. - */ - this.delegate.handle(request, response, csrfToken); - } - - @Override - public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) { - /* - * If the request contains a request header, use CsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies when a single-page - * application includes the header value automatically, which was obtained via a cookie containing the raw CsrfToken. - */ - final var csrfHeader = request.getHeader(csrfToken.getHeaderName()); - if (StringUtils.hasText(csrfHeader)) { - return csrfHeader; - } - /* - * In all other cases (e.g. if the request contains a request parameter), use XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This - * applies when a server-side rendered form includes the _csrf request parameter as a hidden input. - */ - return this.delegate.resolveCsrfTokenValue(request, csrfToken); - } + http.csrf(configurer -> { + switch (csrfEnum) { + case DISABLE: + configurer.disable(); + break; + case DEFAULT: + if (isStatless) { + configurer.disable(); + } + break; + case SESSION: + break; + case COOKIE_ACCESSIBLE_FROM_JS: + // Taken from + // https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa-configuration + configurer.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()); + http.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class); + break; + } + }); + + return http; + } + + /** + * Copied from + * https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa-configuration + */ + static final class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler { + private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + Supplier csrfToken) { + /* + * Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of the + * CsrfToken when it is rendered in the response body. + */ + this.delegate.handle(request, response, csrfToken); } - /** - * Copied from https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa-configuration - */ - static final class CsrfCookieFilter extends OncePerRequestFilter { - - @Override - protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) - throws ServletException, - IOException { - CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf"); - // Render the token value to a cookie by causing the deferred token to be loaded - csrfToken.getToken(); - - filterChain.doFilter(request, response); - } + @Override + public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) { + /* + * If the request contains a request header, use CsrfTokenRequestAttributeHandler to resolve + * the CsrfToken. This applies when a single-page application includes the header value + * automatically, which was obtained via a cookie containing the raw CsrfToken. + */ + final var csrfHeader = request.getHeader(csrfToken.getHeaderName()); + if (StringUtils.hasText(csrfHeader)) { + return csrfHeader; + } + /* + * In all other cases (e.g. if the request contains a request parameter), use + * XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies when a + * server-side rendered form includes the _csrf request parameter as a hidden input. + */ + return this.delegate.resolveCsrfTokenValue(request, csrfToken); + } + } + + /** + * Copied from + * https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa-configuration + */ + static final class CsrfCookieFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) + throws ServletException, IOException { + CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf"); + // Render the token value to a cookie by causing the deferred token to be loaded + csrfToken.getToken(); + + filterChain.doFilter(request, response); } + } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsAuthenticationEntryPoint.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsAuthenticationEntryPoint.java new file mode 100644 index 000000000..25a831f03 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsAuthenticationEntryPoint.java @@ -0,0 +1,47 @@ +package com.c4_soft.springaddons.security.oidc.starter.synchronised.client; + +import java.io.IOException; + +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.web.util.UriComponentsBuilder; + +import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcClientProperties; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SpringAddonsAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final SpringAddonsOidcClientProperties clientProperties; + + public SpringAddonsAuthenticationEntryPoint(SpringAddonsOidcClientProperties addonsProperties) { + this.clientProperties = addonsProperties; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + final var location = clientProperties + .getLoginUri() + .orElse( + UriComponentsBuilder.fromUri(clientProperties.getClientUri()).pathSegment(clientProperties.getClientUri().getPath(), "/login").build().toUri()) + .toString(); + log.debug("Status: {}, location: {}", clientProperties.getOauth2Redirections().getAuthenticationEntryPoint().value(), location); + + response.setStatus(clientProperties.getOauth2Redirections().getAuthenticationEntryPoint().value()); + response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "OAuth realm=%s".formatted(location)); + response.setHeader(HttpHeaders.LOCATION, location.toString()); + + if (clientProperties.getOauth2Redirections().getAuthenticationEntryPoint().is4xxClientError() || clientProperties + .getOauth2Redirections() + .getAuthenticationEntryPoint() + .is5xxServerError()) { + response.getOutputStream().write("Unauthorized. Please authenticate at %s".formatted(location.toString()).getBytes()); + } + + response.flushBuffer(); + } +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2AuthenticationFailureHandler.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2AuthenticationFailureHandler.java index ea5bd8a26..bf40fe3d3 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2AuthenticationFailureHandler.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2AuthenticationFailureHandler.java @@ -4,6 +4,8 @@ import java.net.URI; import java.util.Optional; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.web.util.HtmlUtils; @@ -15,6 +17,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; /** * An authentication failure handler reading post-login failure URI in session (set by the frontend with a header or request param when @@ -24,24 +27,36 @@ * @see SpringAddonsOidcClientProperties#POST_AUTHENTICATION_SUCCESS_URI_SESSION_ATTRIBUTE for constant used as session attribute keys * @see SpringAddonsOAuth2AuthorizationRequestResolver which sets the post-login URI session attribute */ +@Slf4j public class SpringAddonsOauth2AuthenticationFailureHandler implements AuthenticationFailureHandler { private final String redirectUri; - private final SpringAddonsOauth2RedirectStrategy redirectStrategy; + private final HttpStatus postAuthorizationFailureStatus; public SpringAddonsOauth2AuthenticationFailureHandler(SpringAddonsOidcProperties addonsProperties) { this.redirectUri = addonsProperties.getClient().getLoginErrorRedirectPath().map(URI::toString).orElse("/"); - this.redirectStrategy = new SpringAddonsOauth2RedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPostAuthorizationCode()); + this.postAuthorizationFailureStatus = addonsProperties.getClient().getOauth2Redirections().getPostAuthorizationFailure(); } @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { - final var uri = UriComponentsBuilder.fromUriString( + final var location = UriComponentsBuilder.fromUriString( Optional.ofNullable(request.getSession().getAttribute(SpringAddonsOidcClientProperties.POST_AUTHENTICATION_FAILURE_URI_SESSION_ATTRIBUTE)) .map(Object::toString).orElse(redirectUri)) .queryParam(SpringAddonsOidcClientProperties.POST_AUTHENTICATION_FAILURE_CAUSE_ATTRIBUTE, HtmlUtils.htmlEscape(exception.getMessage())).build() - .toUri(); - redirectStrategy.sendRedirect(request, response, uri.toString()); + .toUri().toString(); + + log.debug("Authentication failure. Status: {}, location: {}, message: {}", postAuthorizationFailureStatus.value(), location, exception.getMessage()); + + response.setStatus(postAuthorizationFailureStatus.value()); + response.setHeader(HttpHeaders.LOCATION, location); + response.setHeader(SpringAddonsOidcClientProperties.POST_AUTHENTICATION_FAILURE_CAUSE_ATTRIBUTE, exception.getMessage()); + + if (postAuthorizationFailureStatus.is4xxClientError() || postAuthorizationFailureStatus.is5xxServerError()) { + response.getOutputStream().write(exception.getMessage().getBytes()); + } + + response.flushBuffer(); } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2AuthenticationSuccessHandler.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2AuthenticationSuccessHandler.java index 2f82b0161..4243941d5 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2AuthenticationSuccessHandler.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2AuthenticationSuccessHandler.java @@ -13,6 +13,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; /** * An authentication success handler reading post-login success URI in session (set by the frontend with a header or request param when @@ -22,6 +23,7 @@ * @see SpringAddonsOidcClientProperties#POST_AUTHENTICATION_SUCCESS_URI_SESSION_ATTRIBUTE for constant used as session attribute keys * @see SpringAddonsOAuth2AuthorizationRequestResolver which sets the post-login URI session attribute */ +@Slf4j public class SpringAddonsOauth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler { private final String redirectUri; private final SpringAddonsOauth2RedirectStrategy redirectStrategy; @@ -38,7 +40,9 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo final var uri = Optional.ofNullable(request.getSession().getAttribute(SpringAddonsOidcClientProperties.POST_AUTHENTICATION_SUCCESS_URI_SESSION_ATTRIBUTE)) .map(Object::toString).orElse(redirectUri); - redirectStrategy.sendRedirect(request, response, uri); + log.debug("Authentication success. Status: {}, location: {}", redirectStrategy.getDefaultStatus(), uri); + + redirectStrategy.sendRedirect(request, response, uri); } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2RedirectStrategy.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2RedirectStrategy.java index f6f36ab79..319e015f8 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2RedirectStrategy.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOauth2RedirectStrategy.java @@ -10,25 +10,28 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; import lombok.RequiredArgsConstructor; /** - * A redirect strategy that might not actually redirect: the HTTP status is taken from com.c4-soft.springaddons.oidc.client.oauth2-redirect-status property. - * User-agents will auto redirect only if the status is in 3xx range. This gives single page and mobile applications a chance to intercept the redirection and - * choose to follow the redirection (or not), with which agent and potentially by clearing some headers. + * A redirect strategy that might not actually redirect: the HTTP status is taken from + * com.c4-soft.springaddons.oidc.client.oauth2-redirect-status property. User-agents will auto redirect only if the status is in 3xx range. + * This gives single page and mobile applications a chance to intercept the redirection and choose to follow the redirection (or not), with + * which agent and potentially by clearing some headers. * * @author Jerome Wacongne ch4mp@c4-soft.com */ @RequiredArgsConstructor public class SpringAddonsOauth2RedirectStrategy implements RedirectStrategy { - private final HttpStatus defaultStatus; + @Getter + private final HttpStatus defaultStatus; - @Override - public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String location) throws IOException { - final var requestedStatus = request.getIntHeader(SpringAddonsOidcClientProperties.RESPONSE_STATUS_HEADER); - response.setStatus(requestedStatus > -1 ? requestedStatus : defaultStatus.value()); + @Override + public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String location) throws IOException { + final var requestedStatus = request.getIntHeader(SpringAddonsOidcClientProperties.RESPONSE_STATUS_HEADER); + response.setStatus(requestedStatus > -1 ? requestedStatus : defaultStatus.value()); - response.setHeader(HttpHeaders.LOCATION, location); - } + response.setHeader(HttpHeaders.LOCATION, location); + } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOidcClientWithLoginBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOidcClientWithLoginBeans.java index dadef0a8b..e6a86ec59 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOidcClientWithLoginBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/SpringAddonsOidcClientWithLoginBeans.java @@ -2,7 +2,6 @@ import java.util.ArrayList; import java.util.Optional; - import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -14,52 +13,72 @@ import org.springframework.context.annotation.Conditional; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; 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.config.annotation.web.configurers.oauth2.client.OidcBackChannelLogoutHandler; +import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.security.web.session.InvalidSessionStrategy; +import org.springframework.security.web.session.SimpleRedirectInvalidSessionStrategy; import org.springframework.web.filter.CorsFilter; - +import org.springframework.web.util.UriComponentsBuilder; import com.c4_soft.springaddons.security.oidc.starter.ClaimSetAuthoritiesConverter; import com.c4_soft.springaddons.security.oidc.starter.ConfigurableClaimSetAuthoritiesConverter; 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.SpringAddonsOidcClientProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultAuthenticationEntryPointCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultAuthenticationFailureHandlerCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultAuthenticationSuccessHandlerCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultCorsFilterCondition; +import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultOidcBackChannelLogoutHandlerCondition; +import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultOidcSessionRegistryCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.configuration.IsClientWithLoginCondition; import com.c4_soft.springaddons.security.oidc.starter.synchronised.ServletConfigurationSupport; import com.c4_soft.springaddons.security.oidc.starter.synchronised.SpringAddonsOidcBeans; - +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; /** * The following {@link ConditionalOnMissingBean @ConditionalOnMissingBeans} are auto-configured *
    - *
  • springAddonsClientFilterChain: a {@link SecurityFilterChain}. Instantiated only if "com.c4-soft.springaddons.oidc.client.security-matchers" property has - * at least one entry. If defined, it is with highest precedence, to ensure that all routes defined in this security matcher property are intercepted by this - * filter-chain.
  • - *
  • oAuth2AuthorizationRequestResolver: a {@link OAuth2AuthorizationRequestResolver}. Default instance is a - * {@link SpringAddonsOAuth2AuthorizationRequestResolver} which sets the client hostname in the redirect URI with - * {@link SpringAddonsOidcClientProperties#clientUri SpringAddonsOidcClientProperties#client-uri}
  • - *
  • logoutRequestUriBuilder: builder for RP-Initiated Logout queries, taking - * configuration from properties for OIDC providers which do not strictly comply with the spec: logout URI not provided by OIDC conf or non standard parameter - * names (Auth0 and Cognito are samples of such OPs)
  • - *
  • logoutSuccessHandler: a {@link LogoutSuccessHandler}. Default instance is a {@link SpringAddonsLogoutSuccessHandler} which logs a user out from the last - * authorization server he logged on.
  • - *
  • authoritiesConverter: an {@link ClaimSetAuthoritiesConverter}. Default instance is a {@link ConfigurableClaimSetAuthoritiesConverter} which reads - * spring-addons {@link SpringAddonsOidcProperties}
  • - *
  • clientAuthorizePostProcessor: a {@link ClientExpressionInterceptUrlRegistryPostProcessor} post processor to fine tune access control from java - * configuration. It applies to all routes not listed in "permit-all" property configuration. Default requires users to be authenticated.
  • - *
  • clientHttpPostProcessor: a {@link ClientSynchronizedHttpSecurityPostProcessor} to override anything from above auto-configuration. It is called just - * before the security filter-chain is returned. Default is a no-op.
  • + *
  • springAddonsClientFilterChain: a {@link SecurityFilterChain}. Instantiated only if + * "com.c4-soft.springaddons.oidc.client.security-matchers" property has at least one entry. If + * defined, it is with highest precedence, to ensure that all routes defined in this security + * matcher property are intercepted by this filter-chain.
  • + *
  • oAuth2AuthorizationRequestResolver: a {@link OAuth2AuthorizationRequestResolver}. Default + * instance is a {@link SpringAddonsOAuth2AuthorizationRequestResolver} which sets the client + * hostname in the redirect URI with {@link SpringAddonsOidcClientProperties#clientUri + * SpringAddonsOidcClientProperties#client-uri}
  • + *
  • logoutRequestUriBuilder: builder for + * RP-Initiated Logout + * queries, taking configuration from properties for OIDC providers which do not strictly comply + * with the spec: logout URI not provided by OIDC conf or non standard parameter names (Auth0 and + * Cognito are samples of such OPs)
  • + *
  • logoutSuccessHandler: a {@link LogoutSuccessHandler}. Default instance is a + * {@link SpringAddonsLogoutSuccessHandler} which logs a user out from the last authorization server + * he logged on.
  • + *
  • authoritiesConverter: an {@link ClaimSetAuthoritiesConverter}. Default instance is a + * {@link ConfigurableClaimSetAuthoritiesConverter} which reads spring-addons + * {@link SpringAddonsOidcProperties}
  • + *
  • clientAuthorizePostProcessor: a {@link ClientExpressionInterceptUrlRegistryPostProcessor} + * post processor to fine tune access control from java configuration. It applies to all routes not + * listed in "permit-all" property configuration. Default requires users to be authenticated.
  • + *
  • clientHttpPostProcessor: a {@link ClientSynchronizedHttpSecurityPostProcessor} to override + * anything from above auto-configuration. It is called just before the security filter-chain is + * returned. Default is a no-op.
  • *
* * @author Jerome Wacongne ch4mp@c4-soft.com @@ -72,67 +91,92 @@ @Slf4j public class SpringAddonsOidcClientWithLoginBeans { - /** - *

- * 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 ClientSynchronizedHttpSecurityPostProcessor} 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. Default is a {@link SpringAddonsOauth2AuthenticationSuccessHandler} - * @param authenticationFailureHandler the authentication failure handler to use. Default is a {@link SpringAddonsOauth2AuthenticationFailureHandler} - * @param logoutSuccessHandler Defaulted to {@link SpringAddonsLogoutSuccessHandler} which can handle "almost" RP Initiated Logout conformant OPs (like - * Auth0 and Cognito). Default is a {@link SpringAddonsLogoutSuccessHandler} - * @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, - ClientSynchronizedHttpSecurityPostProcessor 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 ClientSynchronizedHttpSecurityPostProcessor} 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 authenticationEntryPoint the {@link AuthenticationEntryPoint} to use. Default is + * {@link SpringAddonsAuthenticationEntryPoint} + * @param authenticationSuccessHandler the authentication success handler to use. Default is a + * {@link SpringAddonsOauth2AuthenticationSuccessHandler} + * @param authenticationFailureHandler the authentication failure handler to use. Default is a + * {@link SpringAddonsOauth2AuthenticationFailureHandler} + * @param invalidSessionStrategy default redirects to login, unless another status is set in + * com.c4-soft.springaddons.oidc.client.oauth2-redirections.invalid-session-strategy + * @param logoutSuccessHandler Defaulted to {@link SpringAddonsLogoutSuccessHandler} which can + * handle "almost" RP Initiated Logout conformant OPs (like Auth0 and Cognito). Default is + * a {@link SpringAddonsLogoutSuccessHandler} + * @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} + * @param oidcBackChannelLogoutHandler if present, Back-Channel Logout is enabled. A default + * {@link OidcBackChannelLogoutHandler} is provided if + * com.c4-soft.springaddons.oidc.client.back-channel-logout.enabled is true + * @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, + AuthenticationEntryPoint authenticationEntryPoint, + AuthenticationSuccessHandler authenticationSuccessHandler, + AuthenticationFailureHandler authenticationFailureHandler, + InvalidSessionStrategy invalidSessionStrategy, Optional logoutHandler, + LogoutSuccessHandler logoutSuccessHandler, SpringAddonsOidcProperties addonsProperties, + ClientExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, + ClientSynchronizedHttpSecurityPostProcessor httpPostProcessor, + Optional oidcBackChannelLogoutHandler) throws Exception { + // @formatter:off log.info("Applying client OAuth2 configuration for: {}", addonsProperties.getClient().getSecurityMatchers()); http.securityMatcher(addonsProperties.getClient().getSecurityMatchers().toArray(new String[] {})); + http.sessionManagement(sessions -> { + sessions.invalidSessionStrategy(invalidSessionStrategy); + }); + + http.exceptionHandling(exceptions -> { + exceptions.authenticationEntryPoint(authenticationEntryPoint); + }); + http.oauth2Login(login -> { login.authorizationEndpoint(authorizationEndpoint -> { authorizationEndpoint.authorizationRedirectStrategy(preAuthorizationCodeRedirectStrategy); authorizationEndpoint.authorizationRequestResolver(authorizationRequestResolver); }); - addonsProperties.getClient().getLoginPath().ifPresent(login::loginPage); - authenticationSuccessHandler.ifPresent(login::successHandler); - authenticationFailureHandler.ifPresent(login::failureHandler); + login.successHandler(authenticationSuccessHandler); + login.failureHandler(authenticationFailureHandler); }); http.logout(logout -> { @@ -140,127 +184,191 @@ SecurityFilterChain springAddonsClientFilterChain( }); // @formatter:on - if (addonsProperties.getClient().getBackChannelLogout().isEnabled()) { - http.oidcLogout(ol -> { - ol.backChannel(bc -> { - addonsProperties.getClient().getBackChannelLogout().getInternalLogoutUri().ifPresent(bc::logoutUri); - }); - }); - } + if (oidcBackChannelLogoutHandler.isPresent()) { + http.oidcLogout( + ol -> ol.backChannel(bc -> bc.logoutHandler(oidcBackChannelLogoutHandler.get()))); + } - ServletConfigurationSupport.configureClient(http, serverProperties, addonsProperties, authorizePostProcessor, httpPostProcessor); + ServletConfigurationSupport.configureClient(http, serverProperties, addonsProperties, + authorizePostProcessor, httpPostProcessor); - return http.build(); - } + 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 bootClientProperties "standard" Spring Boot OAuth2 client properties - * @param clientRegistrationRepository - * @param addonsProperties "spring-addons" OAuth2 client properties - * @return {@link SpringAddonsOAuth2AuthorizationRequestResolver} - */ - @ConditionalOnMissingBean - @Bean - OAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver( - OAuth2ClientProperties bootClientProperties, - ClientRegistrationRepository clientRegistrationRepository, - SpringAddonsOidcProperties addonsProperties) { - return new SpringAddonsOAuth2AuthorizationRequestResolver(bootClientProperties, clientRegistrationRepository, addonsProperties.getClient()); - } + /** + * 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 bootClientProperties "standard" Spring Boot OAuth2 client properties + * @param clientRegistrationRepository + * @param addonsProperties "spring-addons" OAuth2 client properties + * @return {@link SpringAddonsOAuth2AuthorizationRequestResolver} + */ + @ConditionalOnMissingBean + @Bean + OAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver( + OAuth2ClientProperties bootClientProperties, + ClientRegistrationRepository clientRegistrationRepository, + SpringAddonsOidcProperties addonsProperties) { + return new SpringAddonsOAuth2AuthorizationRequestResolver(bootClientProperties, + 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()); - } + /** + * 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); - } + /** + * 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 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 - ClientSynchronizedHttpSecurityPostProcessor clientHttpPostProcessor() { - return http -> http; - } + /** + * @return a no-op post processor + */ + @ConditionalOnMissingBean + @Bean + ClientSynchronizedHttpSecurityPostProcessor clientHttpPostProcessor() { + return http -> http; + } - @ConditionalOnMissingBean - @Bean - PreAuthorizationCodeRedirectStrategy authorizationCodeRedirectStrategy(SpringAddonsOidcProperties addonsProperties) { - return new SpringAddonsPreAuthorizationCodeRedirectStrategy(addonsProperties.getClient().getOauth2Redirections().getPreAuthorizationCode()); - } + @ConditionalOnMissingBean + @Bean + PreAuthorizationCodeRedirectStrategy authorizationCodeRedirectStrategy( + SpringAddonsOidcProperties addonsProperties) { + return new SpringAddonsPreAuthorizationCodeRedirectStrategy( + addonsProperties.getClient().getOauth2Redirections().getPreAuthorizationCode()); + } - public static class SpringAddonsPreAuthorizationCodeRedirectStrategy extends SpringAddonsOauth2RedirectStrategy - implements - PreAuthorizationCodeRedirectStrategy { - public SpringAddonsPreAuthorizationCodeRedirectStrategy(HttpStatus defaultStatus) { - super(defaultStatus); - } + public static class SpringAddonsPreAuthorizationCodeRedirectStrategy + extends SpringAddonsOauth2RedirectStrategy implements PreAuthorizationCodeRedirectStrategy { + public SpringAddonsPreAuthorizationCodeRedirectStrategy(HttpStatus defaultStatus) { + super(defaultStatus); } + } - @Conditional(DefaultAuthenticationSuccessHandlerCondition.class) - @Bean - AuthenticationSuccessHandler authenticationSuccessHandler(SpringAddonsOidcProperties addonsProperties) { - return new SpringAddonsOauth2AuthenticationSuccessHandler(addonsProperties); - } + @ConditionalOnMissingBean(InvalidSessionStrategy.class) + @Bean + InvalidSessionStrategy invalidSessionStrategy(SpringAddonsOidcProperties addonsProperties) { + final var location = addonsProperties.getClient().getLoginUri() + .orElse(UriComponentsBuilder.fromUri(addonsProperties.getClient().getClientUri()) + .pathSegment(addonsProperties.getClient().getClientUri().getPath(), "/login").build() + .toUri()) + .toString(); + log.debug("Invalid session. Returning %d and request authentication at %s".formatted( + addonsProperties.getClient().getOauth2Redirections().getInvalidSessionStrategy().value(), + location)); - @Conditional(DefaultAuthenticationFailureHandlerCondition.class) - @Bean - AuthenticationFailureHandler authenticationFailureHandler(SpringAddonsOidcProperties addonsProperties) { - return new SpringAddonsOauth2AuthenticationFailureHandler(addonsProperties); + if (addonsProperties.getClient().getOauth2Redirections() + .getInvalidSessionStrategy() == HttpStatus.FOUND) { + return new SimpleRedirectInvalidSessionStrategy(location); } - /** - * FIXME: use only the new CORS properties at next major release - */ - @Conditional(DefaultCorsFilterCondition.class) - @Bean - CorsFilter corsFilter(SpringAddonsOidcProperties addonsProperties) { - final var corsProps = new ArrayList<>(addonsProperties.getCors()); - final var deprecatedClientCorsProps = addonsProperties.getClient().getCors(); - corsProps.addAll(deprecatedClientCorsProps); + return (HttpServletRequest request, HttpServletResponse response) -> { + response.setStatus( + addonsProperties.getClient().getOauth2Redirections().getInvalidSessionStrategy().value()); + response.setHeader(HttpHeaders.LOCATION, location); + if (addonsProperties.getClient().getOauth2Redirections().getInvalidSessionStrategy() + .is4xxClientError() + || addonsProperties.getClient().getOauth2Redirections().getInvalidSessionStrategy() + .is5xxServerError()) { + response.getOutputStream() + .write("Invalid session. Please authenticate at %s".formatted(location).getBytes()); + } + response.flushBuffer(); + }; + } + + @Conditional(DefaultAuthenticationEntryPointCondition.class) + @Bean + AuthenticationEntryPoint authenticationEntryPoint(SpringAddonsOidcProperties addonsProperties) { + return new SpringAddonsAuthenticationEntryPoint(addonsProperties.getClient()); + } + + @Conditional(DefaultAuthenticationSuccessHandlerCondition.class) + @Bean + AuthenticationSuccessHandler authenticationSuccessHandler( + SpringAddonsOidcProperties addonsProperties) { + return new SpringAddonsOauth2AuthenticationSuccessHandler(addonsProperties); + } + + @Conditional(DefaultAuthenticationFailureHandlerCondition.class) + @Bean + AuthenticationFailureHandler authenticationFailureHandler( + SpringAddonsOidcProperties addonsProperties) { + return new SpringAddonsOauth2AuthenticationFailureHandler(addonsProperties); + } + + /** + * FIXME: use only the new CORS properties at next major release + */ + @Conditional(DefaultCorsFilterCondition.class) + @Bean + CorsFilter corsFilter(SpringAddonsOidcProperties addonsProperties) { + final var corsProps = new ArrayList<>(addonsProperties.getCors()); + final var deprecatedClientCorsProps = addonsProperties.getClient().getCors(); + corsProps.addAll(deprecatedClientCorsProps); + + return ServletConfigurationSupport.getCorsFilterBean(corsProps); + } + + @Conditional(DefaultOidcSessionRegistryCondition.class) + @Bean + OidcSessionRegistry oidcSessionRegistry() { + return new InMemoryOidcSessionRegistry(); + } + + @Conditional(DefaultOidcBackChannelLogoutHandlerCondition.class) + @Bean + OidcBackChannelLogoutHandler oidcBackChannelLogoutHandler(OidcSessionRegistry sessionRegistry, + SpringAddonsOidcProperties addonsProperties) { + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(sessionRegistry); + addonsProperties.getClient().getBackChannelLogout().getInternalLogoutUri() + .ifPresent(logoutHandler::setLogoutUri); + addonsProperties.getClient().getBackChannelLogout().getCookieName() + .ifPresent(logoutHandler::setSessionCookieName); + return logoutHandler; + } - return ServletConfigurationSupport.getCorsFilterBean(corsProps); - } } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/JWTClaimsSetAuthenticationManager.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/JWTClaimsSetAuthenticationManager.java index dbb71a585..a04a10e80 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/JWTClaimsSetAuthenticationManager.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/JWTClaimsSetAuthenticationManager.java @@ -35,7 +35,7 @@ * properties could not be resolved from the JWT claims. *

* - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public class JWTClaimsSetAuthenticationManager implements AuthenticationManager { @@ -77,7 +77,7 @@ public Authentication authenticate(Authentication authentication) throws Authent * properties could not be resolved from the JWT claims. *

* - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ @RequiredArgsConstructor public static class JWTClaimsSetAuthenticationManagerResolver implements AuthenticationManagerResolver { diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtAuthenticationManagerResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtAuthenticationManagerResolver.java index f088a5b5d..c0c42de5a 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtAuthenticationManagerResolver.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtAuthenticationManagerResolver.java @@ -22,7 +22,7 @@ * properties could not be resolved from the JWT claims. *

* - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ public class SpringAddonsJwtAuthenticationManagerResolver implements AuthenticationManagerResolver { private final JWTClaimsSetAuthenticationManager authenticationManager; diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsOidcResourceServerBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsOidcResourceServerBeans.java index 795c3510f..6ed9e5341 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsOidcResourceServerBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsOidcResourceServerBeans.java @@ -1,12 +1,11 @@ package com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver; -import java.sql.Date; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; +import java.util.Date; import java.util.Map; -import java.util.Optional; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; @@ -20,8 +19,6 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.convert.converter.Converter; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationManagerResolver; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -39,9 +36,7 @@ import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionAuthenticatedPrincipal; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; -import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.web.filter.CorsFilter; @@ -64,28 +59,29 @@ /** *

* Usage
- * If not using spring-boot, @Import or @ComponentScan this class. All beans defined here are @ConditionalOnMissingBean => just define your own - * @Beans to override. + * If not using spring-boot, @Import or @ComponentScan this class. All beans defined here are @ConditionalOnMissingBean => + * just define your own @Beans to override. *

*

* Provided @Beans *

*
    - *
  • springAddonsResourceServerSecurityFilterChain: applies CORS, CSRF, anonymous, sessionCreationPolicy, SSL, redirect and 401 instead of redirect to login - * as defined in springAddonsResourceServerSecurityFilterChain: applies CORS, CSRF, anonymous, sessionCreationPolicy, SSL, redirect and 401 instead of + * redirect to login as defined in SpringAddonsSecurityProperties
  • - *
  • authorizePostProcessor: a bean of type {@link ResourceServerExpressionInterceptUrlRegistryPostProcessor} to fine tune access control from java - * configuration. It applies to all routes not listed in "permit-all" property configuration. Default requires users to be authenticated. This is a bean to - * provide in your application configuration if you prefer to define fine-grained access control rules with Java configuration rather than methods - * security.
  • - *
  • httpPostProcessor: a bean of type {@link ResourceServerSynchronizedHttpSecurityPostProcessor} to override anything from above auto-configuration. It is - * called just before the security filter-chain is returned. Default is a no-op.
  • - *
  • jwtAuthenticationConverter: a converter from a {@link Jwt} to something inheriting from {@link AbstractAuthenticationToken}. The default instantiate a - * {@link JwtAuthenticationToken} with username and authorities as configured for the issuer of thi token. The easiest to override the type of - * {@link AbstractAuthenticationToken}, is to provide with an Converter<Jwt, ? extends AbstractAuthenticationToken> bean.
  • + *
  • authorizePostProcessor: a bean of type {@link ResourceServerExpressionInterceptUrlRegistryPostProcessor} to fine tune access control + * from java configuration. It applies to all routes not listed in "permit-all" property configuration. Default requires users to be + * authenticated. This is a bean to provide in your application configuration if you prefer to define fine-grained access control rules + * with Java configuration rather than methods security.
  • + *
  • httpPostProcessor: a bean of type {@link ResourceServerSynchronizedHttpSecurityPostProcessor} to override anything from above + * auto-configuration. It is called just before the security filter-chain is returned. Default is a no-op.
  • + *
  • jwtAuthenticationConverter: a converter from a {@link Jwt} to something inheriting from {@link AbstractAuthenticationToken}. The + * default instantiate a {@link JwtAuthenticationToken} with username and authorities as configured for the issuer of thi token. The easiest + * to override the type of {@link AbstractAuthenticationToken}, is to provide with an Converter<Jwt, ? extends + * AbstractAuthenticationToken> bean.
  • *
  • authenticationManagerResolver: to accept authorities from more than one issuer, the recommended way is to provide an - * {@link AuthenticationManagerResolver} supporting it. Default keeps a {@link JwtAuthenticationProvider} with its own {@link JwtDecoder} - * for each issuer.
  • + * {@link AuthenticationManagerResolver} supporting it. Default keeps a {@link JwtAuthenticationProvider} with its own + * {@link JwtDecoder} for each issuer. *
* * @author Jerome Wacongne ch4mp@c4-soft.com @@ -96,230 +92,213 @@ @AutoConfiguration @ImportAutoConfiguration(SpringAddonsOidcBeans.class) public class SpringAddonsOidcResourceServerBeans { - /** - *

- * Configures a SecurityFilterChain for a resource server with JwtDecoder with @Order(LOWEST_PRECEDENCE). Defining a {@link SecurityWebFilterChain} bean - * with no security matcher and an order higher than LOWEST_PRECEDENCE will hide this filter-chain an disable most of spring-addons auto-configuration for - * OpenID resource-servers. - *

- * - * @param http HTTP security to configure - * @param serverProperties Spring "server" configuration properties - * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties - * @param authorizePostProcessor Hook to override access-control rules for all path that are not listed in "permit-all" - * @param httpPostProcessor Hook to override all or part of HttpSecurity auto-configuration - * @param authenticationManagerResolver Converts successful JWT decoding result into an {@link Authentication} - * @return A {@link SecurityWebFilterChain} for servlet resource-servers with JWT decoder - */ - @Conditional(IsJwtDecoderResourceServerCondition.class) - @Order(Ordered.LOWEST_PRECEDENCE) - @Bean - SecurityFilterChain springAddonsJwtResourceServerSecurityFilterChain( - HttpSecurity http, - ServerProperties serverProperties, - SpringAddonsOidcProperties addonsProperties, - ResourceServerExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, - ResourceServerSynchronizedHttpSecurityPostProcessor httpPostProcessor, - AuthenticationManagerResolver authenticationManagerResolver, - AuthenticationEntryPoint authenticationEntryPoint, - Optional accessDeniedHandler) - throws Exception { - http.oauth2ResourceServer(oauth2 -> { - oauth2.authenticationManagerResolver(authenticationManagerResolver); - oauth2.authenticationEntryPoint(authenticationEntryPoint); - accessDeniedHandler.ifPresent(oauth2::accessDeniedHandler); - }); + /** + *

+ * Configures a SecurityFilterChain for a resource server with JwtDecoder with @Order(LOWEST_PRECEDENCE). Defining a + * {@link SecurityWebFilterChain} bean with no security matcher and an order higher than LOWEST_PRECEDENCE will hide this filter-chain an + * disable most of spring-addons auto-configuration for OpenID resource-servers. + *

+ * + * @param http HTTP security to configure + * @param serverProperties Spring "server" configuration properties + * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties + * @param authorizePostProcessor Hook to override access-control rules for all path that are not listed in "permit-all" + * @param httpPostProcessor Hook to override all or part of HttpSecurity auto-configuration + * @param authenticationManagerResolver Converts successful JWT decoding result into an {@link Authentication} + * @return A {@link SecurityWebFilterChain} for servlet resource-servers with JWT decoder + */ + @Conditional(IsJwtDecoderResourceServerCondition.class) + @Order(Ordered.LOWEST_PRECEDENCE) + @Bean + SecurityFilterChain springAddonsJwtResourceServerSecurityFilterChain( + HttpSecurity http, + ServerProperties serverProperties, + SpringAddonsOidcProperties addonsProperties, + ResourceServerExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, + ResourceServerSynchronizedHttpSecurityPostProcessor httpPostProcessor, + AuthenticationManagerResolver authenticationManagerResolver) + throws Exception { + http.oauth2ResourceServer(oauth2 -> { + oauth2.authenticationManagerResolver(authenticationManagerResolver); + }); - ServletConfigurationSupport.configureResourceServer(http, serverProperties, addonsProperties, authorizePostProcessor, httpPostProcessor); + ServletConfigurationSupport.configureResourceServer(http, serverProperties, addonsProperties, authorizePostProcessor, httpPostProcessor); - return http.build(); - } + return http.build(); + } - /** - *

- * Configures a SecurityFilterChain for a resource server with JwtDecoder with @Order(LOWEST_PRECEDENCE). Defining a {@link SecurityWebFilterChain} bean - * with no security matcher and an order higher than LOWEST_PRECEDENCE will hide this filter-chain an disable most of spring-addons auto-configuration for - * OpenID resource-servers. - *

- * - * @param http HTTP security to configure - * @param serverProperties Spring "server" configuration properties - * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties - * @param authorizePostProcessor Hook to override access-control rules for all path that are not listed in "permit-all" - * @param httpPostProcessor Hook to override all or part of HttpSecurity auto-configuration - * @param introspectionAuthenticationConverter Converts successful introspection result into an {@link Authentication} - * @param opaqueTokenIntrospector the instrospector to use - * @return A {@link SecurityWebFilterChain} for servlet resource-servers with access token introspection - */ - @Conditional(IsIntrospectingResourceServerCondition.class) - @Order(Ordered.LOWEST_PRECEDENCE) - @Bean - SecurityFilterChain springAddonsIntrospectingResourceServerSecurityFilterChain( - HttpSecurity http, - ServerProperties serverProperties, - SpringAddonsOidcProperties addonsProperties, - ResourceServerExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, - ResourceServerSynchronizedHttpSecurityPostProcessor httpPostProcessor, - OpaqueTokenAuthenticationConverter introspectionAuthenticationConverter, - OpaqueTokenIntrospector opaqueTokenIntrospector, - AuthenticationEntryPoint authenticationEntryPoint, - Optional accessDeniedHandler) - throws Exception { - http.oauth2ResourceServer(server -> server.opaqueToken(ot -> { - ot.introspector(opaqueTokenIntrospector); - ot.authenticationConverter(introspectionAuthenticationConverter); - server.authenticationEntryPoint(authenticationEntryPoint); - accessDeniedHandler.ifPresent(server::accessDeniedHandler); - })); + /** + *

+ * Configures a SecurityFilterChain for a resource server with JwtDecoder with @Order(LOWEST_PRECEDENCE). Defining a + * {@link SecurityWebFilterChain} bean with no security matcher and an order higher than LOWEST_PRECEDENCE will hide this filter-chain an + * disable most of spring-addons auto-configuration for OpenID resource-servers. + *

+ * + * @param http HTTP security to configure + * @param serverProperties Spring "server" configuration properties + * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties + * @param authorizePostProcessor Hook to override access-control rules for all path that are not listed in "permit-all" + * @param httpPostProcessor Hook to override all or part of HttpSecurity auto-configuration + * @param introspectionAuthenticationConverter Converts successful introspection result into an {@link Authentication} + * @param opaqueTokenIntrospector the instrospector to use + * @return A {@link SecurityWebFilterChain} for servlet resource-servers with access token + * introspection + */ + @Conditional(IsIntrospectingResourceServerCondition.class) + @Order(Ordered.LOWEST_PRECEDENCE) + @Bean + SecurityFilterChain springAddonsIntrospectingResourceServerSecurityFilterChain( + HttpSecurity http, + ServerProperties serverProperties, + SpringAddonsOidcProperties addonsProperties, + ResourceServerExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor, + ResourceServerSynchronizedHttpSecurityPostProcessor httpPostProcessor, + OpaqueTokenAuthenticationConverter introspectionAuthenticationConverter, + OpaqueTokenIntrospector opaqueTokenIntrospector) + throws Exception { + http.oauth2ResourceServer(server -> server.opaqueToken(ot -> { + ot.introspector(opaqueTokenIntrospector); + ot.authenticationConverter(introspectionAuthenticationConverter); + })); - ServletConfigurationSupport.configureResourceServer(http, serverProperties, addonsProperties, authorizePostProcessor, httpPostProcessor); + ServletConfigurationSupport.configureResourceServer(http, serverProperties, addonsProperties, authorizePostProcessor, httpPostProcessor); - return http.build(); - } + return http.build(); + } - /** - * hook to override security rules for all path that are not listed in "permit-all". Default is isAuthenticated(). - * - * @return a hook to override security rules for all path that are not listed in "permit-all". Default is isAuthenticated(). - */ - @ConditionalOnMissingBean - @Bean - ResourceServerExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor() { - return registry -> registry.anyRequest().authenticated(); - } + /** + * hook to override security rules for all path that are not listed in "permit-all". Default is isAuthenticated(). + * + * @return a hook to override security rules for all path that are not listed in "permit-all". Default is isAuthenticated(). + */ + @ConditionalOnMissingBean + @Bean + ResourceServerExpressionInterceptUrlRegistryPostProcessor authorizePostProcessor() { + return registry -> registry.anyRequest().authenticated(); + } - /** - * Hook to override all or part of HttpSecurity auto-configuration. Called after spring-addons configuration was applied so that you can modify anything - * - * @return a hook to override all or part of HttpSecurity auto-configuration. Called after spring-addons configuration was applied so that you can modify - * anything - */ - @ConditionalOnMissingBean - @Bean - ResourceServerSynchronizedHttpSecurityPostProcessor httpPostProcessor() { - return httpSecurity -> httpSecurity; - } + /** + * Hook to override all or part of HttpSecurity auto-configuration. Called after spring-addons configuration was applied so that you can + * modify anything + * + * @return a hook to override all or part of HttpSecurity auto-configuration. Called after spring-addons configuration was applied so that + * you can modify anything + */ + @ConditionalOnMissingBean + @Bean + ResourceServerSynchronizedHttpSecurityPostProcessor httpPostProcessor() { + return httpSecurity -> httpSecurity; + } - @ConditionalOnMissingBean - @Bean - SpringAddonsJwtDecoderFactory springAddonsJwtDecoderFactory() { - return new DefaultSpringAddonsJwtDecoderFactory(); - } + @ConditionalOnMissingBean + @Bean + SpringAddonsJwtDecoderFactory springAddonsJwtDecoderFactory() { + return new DefaultSpringAddonsJwtDecoderFactory(); + } - /** - * Provides with multi-tenancy: builds a AuthenticationManagerResolver per provided OIDC issuer URI - * - * @param opPropertiesResolver a resolver for OpenID Provider configuration properties - * @param jwtDecoderFactory something to build a JWT decoder from OpenID Provider configuration properties - * @param jwtAuthenticationConverter converts from a {@link Jwt} to an {@link Authentication} implementation - * @return Multi-tenant {@link AuthenticationManagerResolver} (one for each configured issuer) - */ - @Conditional(DefaultAuthenticationManagerResolverCondition.class) - @Bean - AuthenticationManagerResolver authenticationManagerResolver( - OpenidProviderPropertiesResolver opPropertiesResolver, - SpringAddonsJwtDecoderFactory jwtDecoderFactory, - Converter jwtAuthenticationConverter) { - return new SpringAddonsJwtAuthenticationManagerResolver(opPropertiesResolver, jwtDecoderFactory, jwtAuthenticationConverter); - } + /** + * Provides with multi-tenancy: builds a AuthenticationManagerResolver per provided OIDC issuer URI + * + * @param opPropertiesResolver a resolver for OpenID Provider configuration properties + * @param jwtDecoderFactory something to build a JWT decoder from OpenID Provider configuration properties + * @param jwtAuthenticationConverter converts from a {@link Jwt} to an {@link Authentication} implementation + * @return Multi-tenant {@link AuthenticationManagerResolver} (one for each configured + * issuer) + */ + @Conditional(DefaultAuthenticationManagerResolverCondition.class) + @Bean + AuthenticationManagerResolver authenticationManagerResolver( + OpenidProviderPropertiesResolver opPropertiesResolver, + SpringAddonsJwtDecoderFactory jwtDecoderFactory, + Converter jwtAuthenticationConverter) { + return new SpringAddonsJwtAuthenticationManagerResolver(opPropertiesResolver, jwtDecoderFactory, jwtAuthenticationConverter); + } - @ConditionalOnMissingBean - @Bean - AuthenticationEntryPoint authenticationEntryPoint() { - return (request, response, authException) -> { - response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"Restricted Content\""); - response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()); - }; - } + /** + * Converter bean from {@link Jwt} to {@link AbstractAuthenticationToken} + * + * @param authoritiesConverter converts access-token claims into Spring authorities + * @param opPropertiesResolver spring-addons configuration properties + * @return a converter from {@link Jwt} to {@link AbstractAuthenticationToken} + */ + @Conditional(DefaultJwtAbstractAuthenticationTokenConverterCondition.class) + @Bean + JwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return jwt -> new JwtAuthenticationToken( + jwt, + authoritiesConverter.convert(jwt.getClaims()), + new OpenidClaimSet( + jwt.getClaims(), + opPropertiesResolver.resolve(jwt.getClaims()).orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())) + .getUsernameClaim()).getName()); + } - /** - * Converter bean from {@link Jwt} to {@link AbstractAuthenticationToken} - * - * @param authoritiesConverter converts access-token claims into Spring authorities - * @param opPropertiesResolver spring-addons configuration properties - * @return a converter from {@link Jwt} to {@link AbstractAuthenticationToken} - */ - @Conditional(DefaultJwtAbstractAuthenticationTokenConverterCondition.class) - @Bean - JwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter( - Converter, Collection> authoritiesConverter, - OpenidProviderPropertiesResolver opPropertiesResolver) { - return jwt -> new JwtAuthenticationToken( - jwt, - authoritiesConverter.convert(jwt.getClaims()), - new OpenidClaimSet( - jwt.getClaims(), - opPropertiesResolver.resolve(jwt.getClaims()).orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())).getUsernameClaim()) - .getName()); - } + /** + * Converter bean from successful introspection result to an {@link Authentication} instance + * + * @param authoritiesConverter converts access-token claims into Spring authorities + * @param addonsProperties spring-addons configuration properties + * @param resourceServerProperties Spring Boot standard resource server configuration properties + * @return a converter from successful introspection result to an {@link Authentication} instance + */ + @Conditional(DefaultOpaqueTokenAuthenticationConverterCondition.class) + @Bean + @SuppressWarnings("unchecked") + OpaqueTokenAuthenticationConverter introspectionAuthenticationConverter( + Converter, Collection> authoritiesConverter, + SpringAddonsOidcProperties addonsProperties, + OAuth2ResourceServerProperties resourceServerProperties) { + return (String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> { + final var iatClaim = authenticatedPrincipal.getAttribute(OAuth2TokenIntrospectionClaimNames.IAT); + final var expClaim = authenticatedPrincipal.getAttribute(OAuth2TokenIntrospectionClaimNames.EXP); + return new BearerTokenAuthentication( + new OAuth2IntrospectionAuthenticatedPrincipal( + new OpenidClaimSet( + authenticatedPrincipal.getAttributes(), + addonsProperties.getOps().stream() + .filter( + openidProvider -> resourceServerProperties.getOpaquetoken().getIntrospectionUri() + .contains(openidProvider.getIss().toString())) + .findAny().orElse(addonsProperties.getOps().get(0)).getUsernameClaim()).getName(), + authenticatedPrincipal.getAttributes(), + (Collection) authenticatedPrincipal.getAuthorities()), + new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, introspectedToken, toInstant(iatClaim), toInstant(expClaim)), + authoritiesConverter.convert(authenticatedPrincipal.getAttributes())); + }; + } - /** - * Converter bean from successful introspection result to an {@link Authentication} instance - * - * @param authoritiesConverter converts access-token claims into Spring authorities - * @param addonsProperties spring-addons configuration properties - * @param resourceServerProperties Spring Boot standard resource server configuration properties - * @return a converter from successful introspection result to an {@link Authentication} instance - */ - @Conditional(DefaultOpaqueTokenAuthenticationConverterCondition.class) - @Bean - @SuppressWarnings("unchecked") - OpaqueTokenAuthenticationConverter introspectionAuthenticationConverter( - Converter, Collection> authoritiesConverter, - SpringAddonsOidcProperties addonsProperties, - OAuth2ResourceServerProperties resourceServerProperties) { - return (String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> { - final var iatClaim = authenticatedPrincipal.getAttribute(OAuth2TokenIntrospectionClaimNames.IAT); - final var expClaim = authenticatedPrincipal.getAttribute(OAuth2TokenIntrospectionClaimNames.EXP); - return new BearerTokenAuthentication( - new OAuth2IntrospectionAuthenticatedPrincipal( - new OpenidClaimSet( - authenticatedPrincipal.getAttributes(), - addonsProperties - .getOps() - .stream() - .filter( - openidProvider -> resourceServerProperties.getOpaquetoken().getIntrospectionUri().contains(openidProvider.getIss().toString())) - .findAny() - .orElse(addonsProperties.getOps().get(0)) - .getUsernameClaim()).getName(), - authenticatedPrincipal.getAttributes(), - (Collection) authenticatedPrincipal.getAuthorities()), - new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, introspectedToken, toInstant(iatClaim), toInstant(expClaim)), - authoritiesConverter.convert(authenticatedPrincipal.getAttributes())); - }; - } + /** + * FIXME: use only the new CORS properties at next major release + */ + @Conditional(DefaultCorsFilterCondition.class) + @Bean + CorsFilter corsFilter(SpringAddonsOidcProperties addonsProperties) { + final var corsProps = new ArrayList<>(addonsProperties.getCors()); + final var deprecatedResourceServerCorsProps = addonsProperties.getResourceserver().getCors(); + corsProps.addAll(deprecatedResourceServerCorsProps); - /** - * FIXME: use only the new CORS properties at next major release - */ - @Conditional(DefaultCorsFilterCondition.class) - @Bean - CorsFilter corsFilter(SpringAddonsOidcProperties addonsProperties) { - final var corsProps = new ArrayList<>(addonsProperties.getCors()); - final var deprecatedResourceServerCorsProps = addonsProperties.getResourceserver().getCors(); - corsProps.addAll(deprecatedResourceServerCorsProps); + return ServletConfigurationSupport.getCorsFilterBean(corsProps); + } - return ServletConfigurationSupport.getCorsFilterBean(corsProps); - } - - private static final Instant toInstant(Object claim) { - if (claim == null) { - return null; - } - if (claim instanceof Instant i) { - return i; - } - if (claim instanceof Date d) { - return d.toInstant(); - } - if (claim instanceof Integer i) { - return Instant.ofEpochSecond((i).longValue()); - } else if (claim instanceof Long l) { - return Instant.ofEpochSecond(l); - } else { - return null; - } - } + private static final Instant toInstant(Object claim) { + if (claim == null) { + return null; + } + if (claim instanceof Instant i) { + return i; + } + if (claim instanceof Date d) { + return d.toInstant(); + } + if (claim instanceof Integer i) { + return Instant.ofEpochSecond((i).longValue()); + } else if (claim instanceof Long l) { + return Instant.ofEpochSecond(l); + } else { + return null; + } + } } diff --git a/spring-addons-starter-openapi/pom.xml b/spring-addons-starter-openapi/pom.xml index b635df342..be3d72015 100644 --- a/spring-addons-starter-openapi/pom.xml +++ b/spring-addons-starter-openapi/pom.xml @@ -3,7 +3,7 @@ com.c4-soft.springaddons spring-addons - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. spring-addons-starter-openapi diff --git a/starters/spring-addons-starters-recaptcha/README.md b/spring-addons-starter-recaptcha/README.md similarity index 100% rename from starters/spring-addons-starters-recaptcha/README.md rename to spring-addons-starter-recaptcha/README.md diff --git a/spring-addons-starter-recaptcha/pom.xml b/spring-addons-starter-recaptcha/pom.xml new file mode 100644 index 000000000..6eab2e9c9 --- /dev/null +++ b/spring-addons-starter-recaptcha/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + + com.c4-soft.springaddons + spring-addons + 8.0.0-RC2-SNAPSHOT + .. + + spring-addons-starter-recaptcha + + https://github.com/ch4mpy/spring-addons/ + + scm:git:git://github.com/ch4mpy/spring-addons.git + scm:git:git@github.com:ch4mpy/spring-addons.git + https://github.com/ch4mpy/spring-addons + spring-addons-7.8.8 + + + + + org.springframework + spring-web + + + org.springframework.boot + spring-boot + + + org.springframework.boot + spring-boot-autoconfigure + + + com.c4-soft.springaddons + spring-addons-starter-rest + + + + org.slf4j + slf4j-api + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + diff --git a/spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaSettings.java b/spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaSettings.java new file mode 100644 index 000000000..3089932f7 --- /dev/null +++ b/spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaSettings.java @@ -0,0 +1,26 @@ +package com.c4_soft.springaddons.starter.recaptcha; + +import java.net.URL; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.stereotype.Component; +import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties; +import lombok.Data; + +@Data +@Component +@ConfigurationProperties(prefix = "com.c4-soft.springaddons.recaptcha") +public class C4ReCaptchaSettings { + + private String secretKey; + + @Value("${siteverify-url:https://www.google.com/recaptcha/api/siteverify}") + private URL siteverifyUrl; + + private double v3Threshold = .5; + + @NestedConfigurationProperty + private ClientHttpRequestFactoryProperties http; + +} diff --git a/spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaValidationService.java b/spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaValidationService.java new file mode 100644 index 000000000..705e20b21 --- /dev/null +++ b/spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaValidationService.java @@ -0,0 +1,83 @@ +package com.c4_soft.springaddons.starter.recaptcha; + +import java.util.stream.Collectors; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.RestClient; +import com.c4_soft.springaddons.rest.SpringAddonsClientHttpRequestFactory; +import com.c4_soft.springaddons.rest.SystemProxyProperties; +import lombok.extern.slf4j.Slf4j; + +/** + * Usage: + * + *
+ * if (Boolean.FALSE.equals(captcha.checkV2(reCaptcha).block())) {
+ *   throw new RuntimeException("Are you a robot?");
+ * }
+ * 
+ * + * @author Jérôme Wacongne ch4mp@c4-soft.com + */ +@Service +@Slf4j +public class C4ReCaptchaValidationService { + + private final RestClient client; + private final String googleRecaptchaSecret; + private final double v3Threshold; + + public C4ReCaptchaValidationService(C4ReCaptchaSettings settings, + SystemProxyProperties systemProxyProperties) { + final var clientBuilder = RestClient.builder(); + clientBuilder.requestFactory( + new SpringAddonsClientHttpRequestFactory(systemProxyProperties, settings.getHttp())); + clientBuilder.baseUrl(settings.getSiteverifyUrl().toString()); + this.client = clientBuilder.build(); + this.googleRecaptchaSecret = settings.getSecretKey(); + this.v3Threshold = settings.getV3Threshold(); + } + + /** + * Checks a reCaptcha V2 challenge response + * + * @param response answer provided by the client + * @return true / false + */ + public Boolean checkV2(String response) { + final var dto = response(response, V2ValidationResponseDto.class); + log.debug("reCaptcha result : {}", dto); + return dto.isSuccess(); + } + + /** + * Checks a reCaptcha V3 challenge response + * + * @param response answer provided by the client + * @return a score between 0 and 1 + * @throws ReCaptchaValidationException if response wasn't a valid reCAPTCHA token for your site + * or score is below configured threshold + */ + public Double checkV3(String response) throws ReCaptchaValidationException { + final var dto = response(response, V3ValidationResponseDto.class); + log.debug("reCaptcha result : {}", dto); + if (!dto.isSuccess()) { + throw new ReCaptchaValidationException(String.format("Failed to validate reCaptcha: %s %s", + response, dto.getErrorCodes().stream().collect(Collectors.joining("[", ", ", "]")))); + } + if (dto.getScore() < v3Threshold) { + throw new ReCaptchaValidationException( + String.format("Failed to validate reCaptcha: %s. Score is %f", response, dto.getScore())); + } + return dto.getScore(); + } + + private T response(String response, Class dtoType) { + final var formData = new LinkedMultiValueMap<>(); + formData.add("secret", googleRecaptchaSecret); + formData.add("response", response); + return client.post().contentType(MediaType.APPLICATION_FORM_URLENCODED).body(formData) + .retrieve().toEntity(dtoType).getBody(); + } +} diff --git a/starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/ReCaptchaValidationException.java b/spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/ReCaptchaValidationException.java similarity index 100% rename from starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/ReCaptchaValidationException.java rename to spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/ReCaptchaValidationException.java diff --git a/starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/SpringBootAutoConfiguration.java b/spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/SpringBootAutoConfiguration.java similarity index 100% rename from starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/SpringBootAutoConfiguration.java rename to spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/SpringBootAutoConfiguration.java diff --git a/starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/V2ValidationResponseDto.java b/spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/V2ValidationResponseDto.java similarity index 100% rename from starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/V2ValidationResponseDto.java rename to spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/V2ValidationResponseDto.java diff --git a/starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/V3ValidationResponseDto.java b/spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/V3ValidationResponseDto.java similarity index 100% rename from starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/V3ValidationResponseDto.java rename to spring-addons-starter-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/V3ValidationResponseDto.java diff --git a/starters/spring-addons-starters-recaptcha/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-addons-starter-recaptcha/src/main/resources/META-INF/additional-spring-configuration-metadata.json similarity index 100% rename from starters/spring-addons-starters-recaptcha/src/main/resources/META-INF/additional-spring-configuration-metadata.json rename to spring-addons-starter-recaptcha/src/main/resources/META-INF/additional-spring-configuration-metadata.json diff --git a/starters/spring-addons-starters-recaptcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-addons-starter-recaptcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from starters/spring-addons-starters-recaptcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to spring-addons-starter-recaptcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/starters/spring-addons-starters-recaptcha/src/test/resources/application.properties b/spring-addons-starter-recaptcha/src/test/resources/application.properties similarity index 100% rename from starters/spring-addons-starters-recaptcha/src/test/resources/application.properties rename to spring-addons-starter-recaptcha/src/test/resources/application.properties diff --git a/spring-addons-starter-rest/README.md b/spring-addons-starter-rest/README.md index 0d858ba11..f7f1b411f 100644 --- a/spring-addons-starter-rest/README.md +++ b/spring-addons-starter-rest/README.md @@ -1,16 +1,19 @@ # Auto-configure `RestClient` or `WebClient` beans -This starter aims at auto-configuring `RestClient` and `WebClient`. For now, it supports: +This starter aims at auto-configuring `RestClient` and `WebClient` using application properties: - base URL - `Basic` or OAuth2 `Bearer` authorization; for the latter, using either a client registration or forwarding the access token in the security context of a resource server. - proxy settings with consideration of `HTTP_PROXY` and `NO_PROXY` environment variables. Finer-grained configuration or overrides can be achieved with custom properties. - connection and read timeouts -- instantiate `RestClient` in servlets and `WebClient` in WebFlux apps. Any client can be switched to `WebClient` in servlets. -- client bean names are by default the camelCase transformation of the key in the application properties map, with the `Builder` suffix when `expose-builder` is true. It can be set to anything else in properties. -When more is needed than what can be auto-configured, it is possible to have `RestClient.Builder` or `WebClient.Builder` exposed as beans instead of the already built instances. +Instantiated REST clients are `WebClient` in WebFlux apps and `RestClient` in servlets, but any client can be switched to `WebClient` in servlets. -## Usage since `8.0.0-RC1` -### Dependency +Exposed bean names are by default the `camelCase` transformation of the `kebab-case` key in the application properties map, with the `Builder` suffix when `expose-builder` is `true`. It can be set to anything else in properties. + +When more is needed than the provided auto-configuration, it is possible to expose `RestClient.Builder` or `WebClient.Builder` instead of the already built instances. + +There is no adherence to other `spring-addons` starters (`spring-addons-starter-rest` can be used without `spring-addons-starter-oidc`). + +## Dependency ```xml com.c4-soft.springaddons @@ -32,18 +35,9 @@ com: oauth2: forward-bearer: true ``` -The `keycloakAdminClient` bean can be autowired in any `@Component` or `@Configuration`. For instance when generating an `@HttpExchange` proxy: -```java -@Configuration -public class RestConfiguration { - @Bean - KeycloakAdminApi keycloakAdminApi(RestClient keycloakAdminClient) throws Exception { - return new RestClientHttpExchangeProxyFactoryBean<>(KeycloakAdminApi.class, keycloakAdminClient).getObject(); - } -} -``` +This exposes a pre-configured bean named `keycloakAdminClient`. The default type of this bean is `RestClient` in a servlet app and `WebClient` in a Webflux one. -### Advanced configuration sample for 3 different clients +## Advanced configuration samples ```yaml com: c4-soft: @@ -52,8 +46,10 @@ com: client: machin-client: base-url: http://localhost:${machin-api-port} - expose-builder: true + # expose a WebClient instead of a RestClient in a servlet app type: WEB_CLIENT + # expose the WebClient.Builder instead of an already built WebClient + expose-builder: true http: chunk-size: 1000 connect-timeout-millis: 1000 @@ -69,21 +65,25 @@ com: protocol: http authorization: oauth2: + # authorize outgoing requests with the Bearer token in the security (possible only in a resource server app) forward-bearer: true bidule-client: base-url: http://localhost:${bidule-api-port} authorization: oauth2: + # authorize outgoing requests with a Bearer obtained using an OAuth2 client registration oauth2-registration-id: bidule-registration http: proxy: - # Use HTTP_PROXY and NO_PROXY environment variables + # use HTTP_PROXY and NO_PROXY environment variables and add proxy authentication username: spring-backend password: secret chose-client: base-url: http://localhost:${chose-api-port} + # change the bean name to "chose" (default would have bean "choseClient" because of the "chose-client" ID, or "choseClientBuilder" if expose-builder was true) bean-name: chose authorization: + # authorize outgoing requests with Basic auth basic: username: spring-backend password: secret @@ -102,3 +102,19 @@ public class RestConfiguration { } } ``` + +## Exposing a generated `@HttpExchange` proxy as a `@Bean` +Once the REST clients configured, we may use it to generate `@HttpExchange` implementations: +```java +@Configuration +public class RestConfiguration { + /** + * @param machinClient might be auto-configured by spring-addons-starter-rest or a hand-crafted bean + * @return a generated implementation of the {@link MachinApi} {@link HttpExchange @HttpExchange}, exposed as a bean named "machinApi". + */ + @Bean + MachinApi machinApi(RestClient machinClient) throws Exception { + return new RestClientHttpExchangeProxyFactoryBean<>(MachinApi.class, machinClient).getObject(); + } +} +``` \ No newline at end of file diff --git a/spring-addons-starter-rest/pom.xml b/spring-addons-starter-rest/pom.xml index 418d3b14d..75608cd88 100644 --- a/spring-addons-starter-rest/pom.xml +++ b/spring-addons-starter-rest/pom.xml @@ -5,7 +5,7 @@ com.c4-soft.springaddons spring-addons - 7.8.13-SNAPSHOT + 8.0.0-RC2-SNAPSHOT .. spring-addons-starter-rest @@ -44,6 +44,7 @@ org.springframework.security spring-security-oauth2-client + true org.springframework.security diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/AbstractSpringAddonsWebClientSupport.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/AbstractSpringAddonsWebClientSupport.java deleted file mode 100644 index 3f98f5d28..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/AbstractSpringAddonsWebClientSupport.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import java.net.URL; -import java.util.Map; -import java.util.Optional; - -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ExchangeFilterFunction; -import org.springframework.web.reactive.function.client.ExchangeFunction; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClient.Builder; -import org.springframework.web.reactive.function.client.support.WebClientAdapter; -import org.springframework.web.service.annotation.HttpExchange; -import org.springframework.web.service.invoker.HttpServiceProxyFactory; - -import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.AuthorizationProperties; - -import reactor.netty.http.client.HttpClient; -import reactor.netty.transport.ProxyProvider; - -/** - * @author Jerome Wacongne chl4mp@c4-soft.com - */ -public abstract class AbstractSpringAddonsWebClientSupport { - - private final ProxySupport proxySupport; - - private final Map restClientProperties; - - /** - * A {@link BearerProvider} to get the Bearer from the request security context - */ - private final BearerProvider forwardingBearerProvider; - - public AbstractSpringAddonsWebClientSupport( - SystemProxyProperties systemProxyProperties, - SpringAddonsRestProperties addonsRestProperties, - BearerProvider forwardingBearerProvider) { - super(); - this.proxySupport = new ProxySupport(systemProxyProperties, addonsRestProperties); - this.restClientProperties = addonsRestProperties.getClient(); - this.forwardingBearerProvider = forwardingBearerProvider; - } - - public WebClient.Builder client() { - final var clientBuilder = WebClient.builder(); - - httpConnector(proxySupport).ifPresent(clientBuilder::clientConnector); - - return clientBuilder; - } - - /** - * @param clientName key in "com.c4-soft.springaddons.rest.client" entries of {@link SpringAddonsRestProperties} - * @return A {@link WebClient} Builder pre-configured with a base-URI and (optionally) with a Bearer Authorization - */ - public WebClient.Builder client(String clientName) { - final var clientProps = Optional.ofNullable(restClientProperties.get(clientName)).orElseThrow(() -> new RestConfigurationNotFoundException(clientName)); - - final var clientBuilder = client(); - - clientProps.getBaseUrl().map(URL::toString).ifPresent(clientBuilder::baseUrl); - - authorize(clientBuilder, clientProps.getAuthorization(), clientName); - - return clientBuilder; - } - - /** - * Uses the provided {@link WebClient} to proxy the httpServiceClass - * - * @param - * @param client - * @param httpServiceClass class of the #64;Service (with {@link HttpExchange} methods) to proxy with a {@link WebClient} - * @return a #64;Service proxy with a {@link WebClient} - */ - public T service(WebClient client, Class httpServiceClass) { - return HttpServiceProxyFactory.builderFor(WebClientAdapter.create(client)).build().createClient(httpServiceClass); - } - - /** - * Builds a {@link WebClient} with just the provided spring-addons {@link SpringAddonsRestProperties} and uses it to proxy the - * httpServiceClass. - * - * @param - * @param httpServiceClass class of the #64;Service (with {@link HttpExchange} methods) to proxy with a {@link WebClient} - * @param clientName key in "rest" entries of spring-addons client properties - * @return a #64;Service proxy with a {@link WebClient} - */ - public T service(String clientName, Class httpServiceClass) { - return this.service(this.client(clientName).build(), httpServiceClass); - } - - protected Optional httpConnector(ProxySupport proxySupport) { - return proxySupport.getHostname().map(proxyHost -> { - return new ReactorClientHttpConnector( - HttpClient.create().proxy( - proxy -> proxy.type(protocoleToProxyType(proxySupport.getProtocol())).host(proxyHost).port(proxySupport.getPort()) - .username(proxySupport.getUsername()).password(username -> proxySupport.getPassword()) - .nonProxyHosts(proxySupport.getNoProxy()).connectTimeoutMillis(proxySupport.getConnectTimeoutMillis()))); - - }); - } - - protected void authorize(Builder clientBuilder, AuthorizationProperties authProps, String clientName) { - if (authProps.getOauth2().isConfigured() && authProps.getBasic().isConfigured()) { - throw new RestMisconfigurationConfigurationException( - "REST authorization configuration for %s can be made for either OAuth2 or Basic, but not both at a time".formatted(clientName)); - } - if (authProps.getOauth2().isConfigured()) { - oauth2(clientBuilder, authProps.getOauth2(), clientName); - } - if (authProps.getBasic().isConfigured()) { - basic(clientBuilder, authProps.getBasic(), clientName); - } - } - - protected void oauth2(Builder clientBuilder, AuthorizationProperties.OAuth2Properties oauth2Props, String clientName) { - if (!oauth2Props.isConfValid()) { - throw new RestMisconfigurationConfigurationException( - "REST OAuth2 authorization configuration for %s can be made for either a registration-id or resource server Bearer forwarding, but not both at a time" - .formatted(clientName)); - } - oauth2Props.getOauth2RegistrationId().map(this::oauth2RegistrationFilter).ifPresent(clientBuilder::filter); - if (oauth2Props.isForwardBearer()) { - clientBuilder.filter((ClientRequest request, ExchangeFunction next) -> { - final var bearer = forwardingBearerProvider.getBearer(); - if (bearer.isEmpty()) { - return next.exchange(request); - } - final var modified = ClientRequest.from(request); - modified.headers(headers -> headers.setBearerAuth(bearer.get())); - return next.exchange(modified.build()); - }); - } - } - - protected abstract ExchangeFilterFunction oauth2RegistrationFilter(String registrationId); - - protected void basic(Builder clientBuilder, AuthorizationProperties.BasicAuthProperties authProps, String clientName) { - if (authProps.getEncodedCredentials().isPresent()) { - if (authProps.getUsername().isPresent() || authProps.getPassword().isPresent() || authProps.getCharset().isPresent()) { - throw new RestMisconfigurationConfigurationException( - "REST Basic authorization for %s is misconfigured: when encoded-credentials is provided, username, password and charset must be absent." - .formatted(clientName)); - } - } else { - if (authProps.getUsername().isEmpty() || authProps.getPassword().isEmpty()) { - throw new RestMisconfigurationConfigurationException( - "REST Basic authorization for %s is misconfigured: when encoded-credentials is empty, username & password are required." - .formatted(clientName)); - } - } - clientBuilder.filter((ClientRequest request, ExchangeFunction next) -> { - if (authProps.getEncodedCredentials().isEmpty() && authProps.getUsername().isEmpty()) { - return next.exchange(request); - } - final var modified = ClientRequest.from(request); - if (authProps.getEncodedCredentials().isPresent()) { - modified.headers(headers -> headers.setBasicAuth(authProps.getEncodedCredentials().get())); - } else if (authProps.getCharset().isPresent()) { - modified.headers(headers -> headers.setBasicAuth(authProps.getUsername().get(), authProps.getPassword().get(), authProps.getCharset().get())); - } else { - modified.headers(headers -> headers.setBasicAuth(authProps.getUsername().get(), authProps.getPassword().get())); - } - return next.exchange(modified.build()); - - }); - } - - static ProxyProvider.Proxy protocoleToProxyType(String protocol) { - if (protocol == null) { - return null; - } - final var lower = protocol.toLowerCase(); - if (lower.startsWith("http")) { - return ProxyProvider.Proxy.HTTP; - } - if (lower.startsWith("socks4")) { - return ProxyProvider.Proxy.SOCKS4; - } - return ProxyProvider.Proxy.SOCKS5; - } -} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/AbstractWebClientBuilderFactoryBean.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/AbstractWebClientBuilderFactoryBean.java new file mode 100644 index 000000000..79941d6e3 --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/AbstractWebClientBuilderFactoryBean.java @@ -0,0 +1,175 @@ +package com.c4_soft.springaddons.rest; + +import java.net.URL; +import java.time.Duration; +import java.util.Optional; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.lang.Nullable; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClient.Builder; +import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.AuthorizationProperties; +import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties; +import io.netty.channel.ChannelOption; +import lombok.Setter; +import reactor.netty.http.client.HttpClient; +import reactor.netty.transport.ProxyProvider; + +/** + * An abstraction of servlet and server (webflux) {@link FactoryBean} for {@link WebClient.Builder + * WebClient Builder}. + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +@Setter +public abstract class AbstractWebClientBuilderFactoryBean + implements FactoryBean { + private String clientId; + private SystemProxyProperties systemProxyProperties = new SystemProxyProperties(); + private SpringAddonsRestProperties restProperties = new SpringAddonsRestProperties(); + + + @Override + @Nullable + public WebClient.Builder getObject() throws Exception { + final var builder = WebClient.builder(); + final var clientProps = Optional.ofNullable(restProperties.getClient().get(clientId)) + .orElseThrow(() -> new RestConfigurationNotFoundException(clientId)); + + builder.clientConnector(clientConnector(systemProxyProperties, clientProps.getHttp())); + + clientProps.getBaseUrl().map(URL::toString).ifPresent(builder::baseUrl); + + setAuthorizationHeader(builder, clientProps.getAuthorization(), clientId); + + return builder; + } + + @Override + @Nullable + public Class getObjectType() { + return WebClient.Builder.class; + } + + public static ReactorClientHttpConnector clientConnector( + SystemProxyProperties systemProxyProperties, + ClientHttpRequestFactoryProperties addonsProperties) { + + final var client = HttpClient.create(); + + addonsProperties.getConnectTimeoutMillis() + .ifPresent(timeout -> client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, timeout)); + addonsProperties.getReadTimeoutMillis() + .ifPresent(timeout -> client.responseTimeout(Duration.ofMillis(timeout))); + + final var proxySupport = new ProxySupport(systemProxyProperties, addonsProperties.getProxy()); + if (proxySupport.isEnabled()) { + client.proxy(proxy -> proxy.type(protocoleToProxyType(proxySupport.getProtocol())) + .host(proxySupport.getHostname().get()).port(proxySupport.getPort()) + .username(proxySupport.getUsername()).password(username -> proxySupport.getPassword()) + .nonProxyHosts(proxySupport.getNoProxy()) + .connectTimeoutMillis(proxySupport.getConnectTimeoutMillis())); + } + + return new ReactorClientHttpConnector(HttpClient.create()); + } + + static Optional httpConnector(ProxySupport proxySupport) { + return proxySupport.getHostname().map(proxyHost -> { + return new ReactorClientHttpConnector(HttpClient.create() + .proxy(proxy -> proxy.type(protocoleToProxyType(proxySupport.getProtocol())) + .host(proxyHost).port(proxySupport.getPort()).username(proxySupport.getUsername()) + .password(username -> proxySupport.getPassword()) + .nonProxyHosts(proxySupport.getNoProxy()) + .connectTimeoutMillis(proxySupport.getConnectTimeoutMillis()))); + + }); + } + + static ProxyProvider.Proxy protocoleToProxyType(String protocol) { + if (protocol == null) { + return null; + } + final var lower = protocol.toLowerCase(); + if (lower.startsWith("http")) { + return ProxyProvider.Proxy.HTTP; + } + if (lower.startsWith("socks4")) { + return ProxyProvider.Proxy.SOCKS4; + } + return ProxyProvider.Proxy.SOCKS5; + } + + protected void setAuthorizationHeader(WebClient.Builder clientBuilder, + AuthorizationProperties authProps, String clientId) { + if (authProps.getOauth2().isConfigured() && authProps.getBasic().isConfigured()) { + throw new RestMisconfigurationException( + "REST authorization configuration for %s can be made for either OAuth2 or Basic, but not both at a time" + .formatted(clientId)); + } + if (authProps.getOauth2().isConfigured()) { + setBearerAuthorizationHeader(clientBuilder, authProps.getOauth2(), clientId); + } else if (authProps.getBasic().isConfigured()) { + setBasicAuthorizationHeader(clientBuilder, authProps.getBasic(), clientId); + } + } + + protected void setBearerAuthorizationHeader(WebClient.Builder clientBuilder, + AuthorizationProperties.OAuth2Properties oauth2Props, String clientId) { + if (!oauth2Props.isConfValid()) { + throw new RestMisconfigurationException( + "REST OAuth2 authorization configuration for %s can be made for either a registration-id or resource server Bearer forwarding, but not both at a time" + .formatted(clientId)); + } + if (oauth2Props.getOauth2RegistrationId().isPresent()) { + clientBuilder + .filter(registrationExchangeFilterFunction(oauth2Props.getOauth2RegistrationId().get())); + } else if (oauth2Props.isForwardBearer()) { + clientBuilder.filter(forwardingBearerExchangeFilterFunction()); + } + } + + protected abstract ExchangeFilterFunction registrationExchangeFilterFunction( + String Oauth2RegistrationId); + + protected abstract ExchangeFilterFunction forwardingBearerExchangeFilterFunction(); + + protected void setBasicAuthorizationHeader(Builder clientBuilder, + AuthorizationProperties.BasicAuthProperties authProps, String clientName) { + if (authProps.getEncodedCredentials().isPresent()) { + if (authProps.getUsername().isPresent() || authProps.getPassword().isPresent() + || authProps.getCharset().isPresent()) { + throw new RestMisconfigurationException( + "REST Basic authorization for %s is misconfigured: when encoded-credentials is provided, username, password and charset must be absent." + .formatted(clientName)); + } + } else { + if (authProps.getUsername().isEmpty() || authProps.getPassword().isEmpty()) { + throw new RestMisconfigurationException( + "REST Basic authorization for %s is misconfigured: when encoded-credentials is empty, username & password are required." + .formatted(clientName)); + } + } + clientBuilder.filter((ClientRequest request, ExchangeFunction next) -> { + if (authProps.getEncodedCredentials().isEmpty() && authProps.getUsername().isEmpty()) { + return next.exchange(request); + } + final var modified = ClientRequest.from(request); + if (authProps.getEncodedCredentials().isPresent()) { + modified.headers(headers -> headers.setBasicAuth(authProps.getEncodedCredentials().get())); + } else if (authProps.getCharset().isPresent()) { + modified.headers(headers -> headers.setBasicAuth(authProps.getUsername().get(), + authProps.getPassword().get(), authProps.getCharset().get())); + } else { + modified.headers(headers -> headers.setBasicAuth(authProps.getUsername().get(), + authProps.getPassword().get())); + } + return next.exchange(modified.build()); + + }); + } + +} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/AuthorizedClientBearerProvider.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/AuthorizedClientBearerProvider.java deleted file mode 100644 index 9e48630b7..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/AuthorizedClientBearerProvider.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import java.util.List; -import java.util.Optional; - -import org.springframework.http.client.ClientHttpRequestInterceptor; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.core.OAuth2AccessToken; - -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * Used by a {@link ClientHttpRequestInterceptor} to add a Bearer Authorization header (if the {@link OAuth2AuthorizedClientManager} provides one for the - * configured registration ID). - * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ -@Data -@EqualsAndHashCode(callSuper = false) -public class AuthorizedClientBearerProvider implements BearerProvider { - private static final AnonymousAuthenticationToken ANONYMOUS = new AnonymousAuthenticationToken( - "anonymous", - "anonymous", - List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); - - private final OAuth2AuthorizedClientManager authorizedClientManager; - private final String registrationId; - - @Override - public Optional getBearer() { - final var authentication = Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()).orElse(ANONYMOUS); - OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId(registrationId).principal(authentication).build(); - final var authorizedClient = Optional.ofNullable(authorizedClientManager.authorize(authorizeRequest)); - final var token = authorizedClient.map(OAuth2AuthorizedClient::getAccessToken); - return token.map(OAuth2AccessToken::getTokenValue); - } -} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/BearerProvider.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/BearerProvider.java deleted file mode 100644 index 19a093954..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/BearerProvider.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import java.util.Optional; - -import org.springframework.http.client.ClientHttpRequestInterceptor; - -/** - * Used by a {@link ClientHttpRequestInterceptor} to add a Bearer Authorization header - * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ -public interface BearerProvider { - Optional getBearer(); -} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/BearerTokenAuthenticationInterceptor.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/BearerTokenAuthenticationInterceptor.java deleted file mode 100644 index b8bc7d945..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/BearerTokenAuthenticationInterceptor.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import java.io.IOException; - -import org.springframework.http.HttpRequest; -import org.springframework.http.client.ClientHttpRequestExecution; -import org.springframework.http.client.ClientHttpRequestInterceptor; -import org.springframework.http.client.ClientHttpResponse; -import org.springframework.lang.NonNull; - -import lombok.Data; - -/** - * A {@link ClientHttpRequestInterceptor} adding a Bearer Authorization header (if the {@link BearerProvider} provides one). - * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ -@Data -public class BearerTokenAuthenticationInterceptor implements ClientHttpRequestInterceptor { - private final BearerProvider bearerProvider; - - @Override - public @NonNull ClientHttpResponse intercept(@NonNull HttpRequest request, @NonNull byte[] body, @NonNull ClientHttpRequestExecution execution) - throws IOException { - bearerProvider.getBearer().ifPresent(bearer -> { - request.getHeaders().setBearerAuth(bearer); - }); - return execution.execute(request, body); - } -} \ No newline at end of file diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/DefaultBearerProvider.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/DefaultBearerProvider.java deleted file mode 100644 index afbcfa86b..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/DefaultBearerProvider.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import java.util.Optional; - -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; - -public class DefaultBearerProvider implements BearerProvider { - - @Override - public Optional getBearer() { - final var authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication instanceof JwtAuthenticationToken jwt) { - return Optional.of(jwt.getToken().getTokenValue()); - } - if (authentication instanceof BearerTokenAuthentication opaque) { - return Optional.of(opaque.getToken().getTokenValue()); - } - return Optional.empty(); - } - -} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/HttpExchangeProxyFactoryBean.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/HttpExchangeProxyFactoryBean.java new file mode 100644 index 000000000..83e4dae34 --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/HttpExchangeProxyFactoryBean.java @@ -0,0 +1,70 @@ +package com.c4_soft.springaddons.rest; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.lang.Nullable; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import lombok.NoArgsConstructor; + +/** + * Bean factory using {@link HttpServiceProxyFactory} to generate an instance of a + * {@link HttpExchange @HttpExchange} interface + * + * @param {@link HttpExchange @HttpExchange} interface to implement + * @see WebClientHttpExchangeProxyFactoryBean WebClientHttpExchangeProxyFactoryBean when RestClient + * is not on the class-path + * @see RestClientHttpExchangeProxyFactoryBean RestClientHttpExchangeProxyFactoryBean when WebClient + * is not on the class-path + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +@NoArgsConstructor +public class HttpExchangeProxyFactoryBean implements FactoryBean { + private Class httpExchangeClass; + private HttpExchangeAdapter adapter; + + public HttpExchangeProxyFactoryBean(Class httpExchangeClass, RestClient client) { + this.httpExchangeClass = httpExchangeClass; + this.setClient(client); + } + + public HttpExchangeProxyFactoryBean(Class httpExchangeClass, WebClient client) { + this.httpExchangeClass = httpExchangeClass; + this.setClient(client); + } + + @Override + @Nullable + public T getObject() throws Exception { + if (adapter == null || getObjectType() == null) { + throw new RestMisconfigurationException( + "Both of a REST client (RestClient or WebClient) and the @HttpExchange interface to implement must be configured on the HttpExchangeProxyFactoryBean before calling the getObject() method."); + } + return HttpServiceProxyFactory.builderFor(adapter).build().createClient(getObjectType()); + } + + @Override + public Class getObjectType() { + return httpExchangeClass; + } + + public HttpExchangeProxyFactoryBean setHttpExchangeClass(Class httpExchangeClass) { + this.httpExchangeClass = httpExchangeClass; + return this; + } + + public HttpExchangeProxyFactoryBean setClient(RestClient client) { + this.adapter = RestClientAdapter.create(client); + return this; + } + + public HttpExchangeProxyFactoryBean setClient(WebClient client) { + this.adapter = WebClientAdapter.create(client); + return this; + } + +} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/IsServletWithWebClientCondition.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/IsServletWithWebClientCondition.java index cbb56ae47..2eb039e60 100644 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/IsServletWithWebClientCondition.java +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/IsServletWithWebClientCondition.java @@ -6,16 +6,24 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; import org.springframework.web.reactive.function.client.WebClient; +/** + * A conditon to apply @Configuration only if an application is a servlet and if + * {@link WebClient} is on the class-path + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ public class IsServletWithWebClientCondition extends AllNestedConditions { - IsServletWithWebClientCondition() { - super(ConfigurationPhase.REGISTER_BEAN); - } + IsServletWithWebClientCondition() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } - @ConditionalOnWebApplication(type = Type.SERVLET) - static class IsServlet {} + @ConditionalOnWebApplication(type = Type.SERVLET) + static class IsServlet { + } - @ConditionalOnClass(WebClient.class) - static class IsWebClientOnClasspath {} + @ConditionalOnClass(WebClient.class) + static class IsWebClientOnClasspath { + } } diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ProxySupport.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ProxySupport.java index 68d3994e2..cc59b9dbb 100644 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ProxySupport.java +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ProxySupport.java @@ -4,96 +4,105 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; - import org.springframework.util.StringUtils; - +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties.ProxyProperties; import lombok.RequiredArgsConstructor; +/** + * Used when configuring a {@link RestClient} or {@link WebClient} instance to authenticate on an + * HTTP or SOCKS proxy. + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ @RequiredArgsConstructor public class ProxySupport { - private final SystemProxyProperties systemProxyProperties; - private final SpringAddonsRestProperties restProperties; - - public boolean isEnabled() { - return restProperties.getProxy().isEnabled() && getHostname().isPresent(); - } - - public Optional getHostname() { - if (!restProperties.getProxy().isEnabled()) { - return Optional.empty(); - } - return restProperties.getProxy().getHost().or(() -> systemProxyProperties.getHttpProxy().map(URL::getHost)); - } - - public String getProtocol() { - if (!restProperties.getProxy().isEnabled()) { - return null; - } - return restProperties.getProxy().getHost().map(h -> restProperties.getProxy().getProtocol()) - .orElse(systemProxyProperties.getHttpProxy().map(URL::getProtocol).orElse(null)); - } - - public int getPort() { - return restProperties.getProxy().getHost().map(h -> restProperties.getProxy().getPort()) - .orElse(systemProxyProperties.getHttpProxy().map(URL::getPort).orElse(restProperties.getProxy().getPort())); - } - - public String getUsername() { - if (!restProperties.getProxy().isEnabled()) { - return null; - } - return restProperties.getProxy().getHost().map(h -> restProperties.getProxy().getUsername()) - .orElse(systemProxyProperties.getHttpProxy().map(URL::getUserInfo).map(ProxySupport::getUserinfoName).orElse(null)); - } - - public String getPassword() { - if (!restProperties.getProxy().isEnabled()) { - return null; - } - return restProperties.getProxy().getHost().map(h -> restProperties.getProxy().getPassword()) - .orElse(systemProxyProperties.getHttpProxy().map(URL::getUserInfo).map(ProxySupport::getUserinfoPassword).orElse(null)); - } - - public String getNoProxy() { - if (!restProperties.getProxy().isEnabled()) { - return null; - } - return Optional.ofNullable(restProperties.getProxy().getNonProxyHostsPattern()).filter(StringUtils::hasText) - .orElse(getNonProxyHostsPattern(systemProxyProperties.getNoProxy())); - } - - public int getConnectTimeoutMillis() { - return restProperties.getProxy().getConnectTimeoutMillis(); - } - - public SystemProxyProperties getSystemProperties() { - return systemProxyProperties; - } - - public SpringAddonsRestProperties.ProxyProperties getAddonsProperties() { - return restProperties.getProxy(); - } - - static String getUserinfoName(String userinfo) { - if (userinfo == null) { - return null; - } - return userinfo.split(":")[0]; - } - - static String getUserinfoPassword(String userinfo) { - if (userinfo == null) { - return null; - } - final var splits = userinfo.split(":"); - return splits.length < 2 ? null : splits[1]; - } - - static String getNonProxyHostsPattern(List noProxy) { - if (noProxy == null || noProxy.isEmpty()) { - return null; - } - return noProxy.stream().map(host -> host.replace(".", "\\.")).map(host -> host.replace("-", "\\-")) - .map(host -> host.startsWith("\\.") ? ".*" + host : host).collect(Collectors.joining(")|(", "(", ")")); - } + private final SystemProxyProperties systemProxyProperties; + private final ProxyProperties springAddonsProperties; + + public boolean isEnabled() { + return springAddonsProperties.isEnabled() && getHostname().isPresent(); + } + + public Optional getHostname() { + if (!springAddonsProperties.isEnabled()) { + return Optional.empty(); + } + return springAddonsProperties.getHost() + .or(() -> systemProxyProperties.getHttpProxy().map(URL::getHost)); + } + + public String getProtocol() { + if (!springAddonsProperties.isEnabled()) { + return null; + } + return springAddonsProperties.getHost().map(h -> springAddonsProperties.getProtocol()) + .orElse(systemProxyProperties.getHttpProxy().map(URL::getProtocol).orElse(null)); + } + + public int getPort() { + return springAddonsProperties.getHost().map(h -> springAddonsProperties.getPort()).orElse( + systemProxyProperties.getHttpProxy().map(URL::getPort).orElse(springAddonsProperties.getPort())); + } + + public String getUsername() { + if (!springAddonsProperties.isEnabled()) { + return null; + } + return springAddonsProperties.getHost().map(h -> springAddonsProperties.getUsername()) + .orElse(systemProxyProperties.getHttpProxy().map(URL::getUserInfo) + .map(ProxySupport::getUserinfoName).orElse(null)); + } + + public String getPassword() { + if (!springAddonsProperties.isEnabled()) { + return null; + } + return springAddonsProperties.getHost().map(h -> springAddonsProperties.getPassword()) + .orElse(systemProxyProperties.getHttpProxy().map(URL::getUserInfo) + .map(ProxySupport::getUserinfoPassword).orElse(null)); + } + + public String getNoProxy() { + if (!springAddonsProperties.isEnabled()) { + return null; + } + return Optional.ofNullable(springAddonsProperties.getNonProxyHostsPattern()) + .filter(StringUtils::hasText) + .orElse(getNonProxyHostsPattern(systemProxyProperties.getNoProxy())); + } + + public int getConnectTimeoutMillis() { + return springAddonsProperties.getConnectTimeoutMillis(); + } + + public SystemProxyProperties getSystemProperties() { + return systemProxyProperties; + } + + static String getUserinfoName(String userinfo) { + if (userinfo == null) { + return null; + } + return userinfo.split(":")[0]; + } + + static String getUserinfoPassword(String userinfo) { + if (userinfo == null) { + return null; + } + final var splits = userinfo.split(":"); + return splits.length < 2 ? null : splits[1]; + } + + static String getNonProxyHostsPattern(List noProxy) { + if (noProxy == null || noProxy.isEmpty()) { + return null; + } + return noProxy.stream().map(host -> host.replace(".", "\\.")) + .map(host -> host.replace("-", "\\-")) + .map(host -> host.startsWith("\\.") ? ".*" + host : host) + .collect(Collectors.joining(")|(", "(", ")")); + } } diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ReactiveAuthorizedClientBearerProvider.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ReactiveAuthorizedClientBearerProvider.java deleted file mode 100644 index 9813bf887..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ReactiveAuthorizedClientBearerProvider.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import java.util.List; -import java.util.Optional; - -import org.springframework.http.client.ClientHttpRequestInterceptor; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.core.OAuth2AccessToken; - -import lombok.Data; -import lombok.EqualsAndHashCode; -import reactor.core.publisher.Mono; - -/** - * A {@link ClientHttpRequestInterceptor} adding a Bearer Authorization header (if the {@link OAuth2AuthorizedClientManager} provides one for the configured - * registration ID). - * - * @author Jerome Wacongne ch4mp@c4-soft.com - */ -@Data -@EqualsAndHashCode(callSuper = false) -public class ReactiveAuthorizedClientBearerProvider implements ReactiveBearerProvider { - private static final AnonymousAuthenticationToken ANONYMOUS = new AnonymousAuthenticationToken( - "anonymous", - "anonymous", - List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); - - private final ReactiveOAuth2AuthorizedClientManager authorizedClientManager; - private final String registrationId; - - @Override - public Mono getBearer() { - final var authentication = Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()).orElse(ANONYMOUS); - OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId(registrationId).principal(authentication).build(); - final var authorizedClient = authorizedClientManager.authorize(authorizeRequest); - final var token = authorizedClient.map(OAuth2AuthorizedClient::getAccessToken); - return token.map(OAuth2AccessToken::getTokenValue); - } -} \ No newline at end of file diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ReactiveBearerProvider.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ReactiveBearerProvider.java deleted file mode 100644 index c057198c0..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ReactiveBearerProvider.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import reactor.core.publisher.Mono; - -public interface ReactiveBearerProvider { - Mono getBearer(); -} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ReactiveSpringAddonsWebClientSupport.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ReactiveSpringAddonsWebClientSupport.java deleted file mode 100644 index 0b8aa7c22..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/ReactiveSpringAddonsWebClientSupport.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import java.util.Optional; - -import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; -import org.springframework.util.StringUtils; -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ExchangeFilterFunction; -import org.springframework.web.reactive.function.client.ExchangeFunction; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.service.annotation.HttpExchange; - -import reactor.core.publisher.Mono; - -/** - *

- * Provides with {@link WebClient} builder instances pre-configured with: - *

- *
    - *
  • HTTP conector if proxy properties or environment variables are set
  • - *
  • Base URL
  • - *
  • authorization exchange function if Basic or OAuth2 Bearer
  • - *
- *

- *

- * Also provides with helper methods to get {@link HttpExchange @@HttpExchange} proxies with {@link WebClient} - *

- *

- * /!\ Auto-configured only in servlet (WebMVC) applications and only if some {@link SpringAddonsRestProperties} are present /!\ - *

- * - * @author Jerome Wacongne chl4mp@c4-soft.com - * @see ReactiveSpringAddonsWebClientSupport an equivalent for reactive (Webflux) applications - */ -public class ReactiveSpringAddonsWebClientSupport extends AbstractSpringAddonsWebClientSupport { - - private final Optional authorizedClientManager; - - public ReactiveSpringAddonsWebClientSupport( - SystemProxyProperties systemProxyProperties, - SpringAddonsRestProperties addonsProperties, - BearerProvider forwardingBearerProvider, - Optional authorizedClientManager) { - super(systemProxyProperties, addonsProperties, forwardingBearerProvider); - this.authorizedClientManager = authorizedClientManager; - } - - @Override - protected ExchangeFilterFunction oauth2RegistrationFilter(String registrationId) { - return (ClientRequest request, ExchangeFunction next) -> { - final var provider = Mono.justOrEmpty(authorizedClientManager.map(acm -> new ReactiveAuthorizedClientBearerProvider(acm, registrationId))); - return provider.flatMap(ReactiveAuthorizedClientBearerProvider::getBearer).defaultIfEmpty("").flatMap(bearer -> { - if (StringUtils.hasText(bearer)) { - final var modified = ClientRequest.from(request).headers(headers -> headers.setBearerAuth(bearer)).build(); - return next.exchange(modified); - } - return next.exchange(request); - }); - }; - } -} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/RestClientHttpExchangeProxyFactoryBean.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/RestClientHttpExchangeProxyFactoryBean.java new file mode 100644 index 000000000..8fc1ff2e2 --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/RestClientHttpExchangeProxyFactoryBean.java @@ -0,0 +1,59 @@ +package com.c4_soft.springaddons.rest; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.lang.Nullable; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import lombok.NoArgsConstructor; + +/** + * Bean factory using {@link HttpServiceProxyFactory} to generate an instance of a + * {@link HttpExchange @HttpExchange} interface + * + * @param {@link HttpExchange @HttpExchange} interface to implement + * @see WebClientHttpExchangeProxyFactoryBean WebClientHttpExchangeProxyFactoryBean for an + * equivalent accepting only WebClient + * @see HttpExchangeProxyFactoryBean HttpExchangeProxyFactoryBean for an equivalent accepting both + * RestClient an WebClient + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +@NoArgsConstructor +public class RestClientHttpExchangeProxyFactoryBean implements FactoryBean { + private Class httpExchangeClass; + private HttpExchangeAdapter adapter; + + public RestClientHttpExchangeProxyFactoryBean(Class httpExchangeClass, RestClient client) { + this.httpExchangeClass = httpExchangeClass; + this.setClient(client); + } + + @Override + @Nullable + public T getObject() throws Exception { + if (adapter == null || getObjectType() == null) { + throw new RestMisconfigurationException( + "Both of a RestClient and the @HttpExchange interface to implement must be configured on the HttpExchangeProxyFactoryBean before calling the getObject() method."); + } + return HttpServiceProxyFactory.builderFor(adapter).build().createClient(getObjectType()); + } + + @Override + public Class getObjectType() { + return httpExchangeClass; + } + + public RestClientHttpExchangeProxyFactoryBean setHttpExchangeClass( + Class httpExchangeClass) { + this.httpExchangeClass = httpExchangeClass; + return this; + } + + public RestClientHttpExchangeProxyFactoryBean setClient(RestClient client) { + this.adapter = RestClientAdapter.create(client); + return this; + } + +} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/RestMisconfigurationConfigurationException.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/RestMisconfigurationConfigurationException.java deleted file mode 100644 index 6848ea67f..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/RestMisconfigurationConfigurationException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.c4_soft.springaddons.rest; - -public class RestMisconfigurationConfigurationException extends RuntimeException { - private static final long serialVersionUID = 681577983030933423L; - - public RestMisconfigurationConfigurationException(String message) { - super(message); - } -} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/RestMisconfigurationException.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/RestMisconfigurationException.java new file mode 100644 index 000000000..d09603011 --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/RestMisconfigurationException.java @@ -0,0 +1,9 @@ +package com.c4_soft.springaddons.rest; + +public class RestMisconfigurationException extends RuntimeException { + private static final long serialVersionUID = 681577983030933423L; + + public RestMisconfigurationException(String message) { + super(message); + } +} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactory.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactory.java new file mode 100644 index 000000000..dcdcfc23a --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactory.java @@ -0,0 +1,120 @@ +package com.c4_soft.springaddons.rest; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; +import java.util.regex.Pattern; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; +import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties; + +/** + *

+ * A wrapper around {@link SimpleClientHttpRequestFactory} that sends the request through an HTTP or + * SOCKS proxy when it is enabled and when the request URI does not match the NO_PROXY pattern. + *

+ *

+ * When going through a proxy, the Proxy-Authorization header is set if username and password are + * non-empty. + *

+ * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +public class SpringAddonsClientHttpRequestFactory implements ClientHttpRequestFactory { + private final Optional nonProxyHostsPattern; + private final ClientHttpRequestFactory proxyDelegate; + private final ClientHttpRequestFactory noProxyDelegate; + + public SpringAddonsClientHttpRequestFactory(SystemProxyProperties systemProperties, + ClientHttpRequestFactoryProperties addonsProperties) { + final var proxySupport = new ProxySupport(systemProperties, addonsProperties.getProxy()); + + this.nonProxyHostsPattern = proxySupport.isEnabled() + ? Optional.ofNullable(proxySupport.getNoProxy()).map(Pattern::compile) + : Optional.empty(); + + this.noProxyDelegate = from(addonsProperties); + + if (proxySupport.isEnabled()) { + this.proxyDelegate = new ProxyAwareClientHttpRequestFactory(proxySupport, addonsProperties); + } else { + this.proxyDelegate = this.noProxyDelegate; + } + } + + @Override + public @NonNull ClientHttpRequest createRequest(@NonNull URI uri, @NonNull HttpMethod httpMethod) + throws IOException { + final var delegate = nonProxyHostsPattern.filter(pattern -> { + final var matcher = pattern.matcher(uri.getHost()); + return matcher.matches(); + }).map(isNoProxy -> { + return noProxyDelegate; + }).orElse(proxyDelegate); + + return delegate.createRequest(uri, httpMethod); + } + + static Proxy.Type protocolToProxyType(String protocol) { + if (protocol == null) { + return null; + } + final var lower = protocol.toLowerCase(); + if (lower.startsWith("http")) { + return Proxy.Type.HTTP; + } + if (lower.startsWith("socks")) { + return Proxy.Type.SOCKS; + } + return null; + } + + private static SimpleClientHttpRequestFactory from( + ClientHttpRequestFactoryProperties properties) { + final var requestFactory = new SimpleClientHttpRequestFactory(); + properties.getConnectTimeoutMillis().ifPresent(requestFactory::setConnectTimeout); + properties.getReadTimeoutMillis().ifPresent(requestFactory::setReadTimeout); + properties.getChunkSize().ifPresent(requestFactory::setChunkSize); + return requestFactory; + } + + public static class ProxyAwareClientHttpRequestFactory implements ClientHttpRequestFactory { + private final SimpleClientHttpRequestFactory delegate; + private final @Nullable String username; + private final @Nullable String password; + + public ProxyAwareClientHttpRequestFactory(ProxySupport proxySupport, + ClientHttpRequestFactoryProperties properties) { + this.username = proxySupport.getUsername(); + this.password = proxySupport.getPassword(); + this.delegate = SpringAddonsClientHttpRequestFactory.from(properties); + final var address = + new InetSocketAddress(proxySupport.getHostname().get(), proxySupport.getPort()); + final var proxy = new Proxy(protocolToProxyType(proxySupport.getProtocol()), address); + this.delegate.setProxy(proxy); + } + + @SuppressWarnings("null") + @Override + public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException { + final var request = delegate.createRequest(uri, httpMethod); + if (StringUtils.hasText(username) && StringUtils.hasText(password)) { + final var base64 = Base64.getEncoder() + .encodeToString((username + ':' + password).getBytes(StandardCharsets.UTF_8)); + request.getHeaders().set(HttpHeaders.PROXY_AUTHORIZATION, "Basic %s".formatted(base64)); + } + return request; + } + } + +} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestBeans.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestBeans.java deleted file mode 100644 index 3251bf666..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestBeans.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import java.util.Optional; - -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Conditional; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; - -@AutoConfiguration -public class SpringAddonsRestBeans { - - @ConditionalOnMissingBean - @Bean - BearerProvider bearerProvider() { - return new DefaultBearerProvider(); - } - - @ConditionalOnWebApplication(type = Type.SERVLET) - @Bean - SpringAddonsRestClientSupport restClientSupport( - SystemProxyProperties systemProxyProperties, - SpringAddonsRestProperties restProperties, - BearerProvider forwardingBearerProvider, - Optional authorizedClientManager) { - return new SpringAddonsRestClientSupport(systemProxyProperties, restProperties, forwardingBearerProvider, authorizedClientManager); - } - - @Conditional(IsServletWithWebClientCondition.class) - @Bean - SpringAddonsWebClientSupport webClientSupport( - SystemProxyProperties systemProxyProperties, - SpringAddonsRestProperties addonsProperties, - BearerProvider forwardingBearerProvider, - Optional authorizedClientManager) { - return new SpringAddonsWebClientSupport(systemProxyProperties, addonsProperties, forwardingBearerProvider, authorizedClientManager); - } - - @ConditionalOnWebApplication(type = Type.REACTIVE) - @Bean - ReactiveSpringAddonsWebClientSupport reactiveWebClientSupport( - SystemProxyProperties systemProxyProperties, - SpringAddonsRestProperties addonsProperties, - BearerProvider forwardingBearerProvider, - Optional authorizedClientManager) { - return new ReactiveSpringAddonsWebClientSupport(systemProxyProperties, addonsProperties, forwardingBearerProvider, authorizedClientManager); - } -} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestClientBeans.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestClientBeans.java new file mode 100644 index 000000000..cdf30c9e5 --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestClientBeans.java @@ -0,0 +1,269 @@ +package com.c4_soft.springaddons.rest; + +import java.net.URL; +import java.util.List; +import java.util.Optional; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.web.client.RestClient; +import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.AuthorizationProperties; +import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientType; +import lombok.Data; +import lombok.Setter; + +/** + * Applied only in servlet applications. + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +@ConditionalOnWebApplication(type = Type.SERVLET) +@AutoConfiguration +public class SpringAddonsRestClientBeans { + + @Bean + SpringAddonsRestClientBeanDefinitionRegistryPostProcessor springAddonsRestClientBeanDefinitionRegistryPostProcessor( + Environment environment) { + return new SpringAddonsRestClientBeanDefinitionRegistryPostProcessor(environment); + } + + /** + *

+ * Post process the {@link BeanDefinitionRegistry} to add a {@link RestClient} (or + * {@link RestClient.builder}) bean definitions for each entry in + * "com.c4-soft.springaddons.rest.client". + *

+ * + *

+ * The bean names are by default the camelCase transformation of the client-id, suffixed with + * "Builder" if the expose-builder property is true. + *

+ * + * @author ch4mp@c4-soft.com + */ + static class SpringAddonsRestClientBeanDefinitionRegistryPostProcessor + implements BeanDefinitionRegistryPostProcessor { + + private final SpringAddonsRestProperties restProperties; + private final SystemProxyProperties systemProxyProperties; + + @SuppressWarnings("unchecked") + public SpringAddonsRestClientBeanDefinitionRegistryPostProcessor(Environment environment) { + this.restProperties = Binder.get(environment) + .bind("com.c4-soft.springaddons.rest", SpringAddonsRestProperties.class) + .orElseThrow(() -> new RestMisconfigurationException( + "Could not read spring-addons REST properties")); + + final var httpProxy = Optional + .ofNullable(Binder.get(environment).bind("http-proxy", String.class).orElse(null)); + final var noProxy = Binder.get(environment).bind("no-proxy", List.class).orElse(List.of()); + this.systemProxyProperties = new SystemProxyProperties(httpProxy, noProxy); + } + + @Override + public void postProcessBeanDefinitionRegistry(@NonNull BeanDefinitionRegistry registry) + throws BeansException { + + restProperties.getClient().entrySet().stream() + .filter(e -> ClientType.REST_CLIENT.equals(e.getValue().getType()) + || ClientType.DEFAULT.equals(e.getValue().getType())) + .forEach(e -> { + final var builder = e.getValue().isExposeBuilder() + ? BeanDefinitionBuilder.genericBeanDefinition(RestClientBuilderFactoryBean.class) + : BeanDefinitionBuilder.genericBeanDefinition(RestClientFactoryBean.class); + builder.addPropertyValue("systemProxyProperties", systemProxyProperties); + builder.addPropertyValue("restProperties", restProperties); + builder.addAutowiredProperty("authorizedClientManager"); + builder.addAutowiredProperty("authorizedClientRepository"); + builder.addPropertyValue("clientId", e.getKey()); + registry.registerBeanDefinition(restProperties.getClientBeanName(e.getKey()), + builder.getBeanDefinition()); + }); + + /* + * FIXME: for some reason, this doesn't work: the clientRegistrationRepo is initialized before + * OAuth2 client properties are resolved and the HttpExchangeProxyFactoryBean named beans + * are not resolved when injecting T in components + */ + // restProperties.getService().entrySet().stream().forEach(e -> { + // final var builder = + // BeanDefinitionBuilder.genericBeanDefinition(HttpExchangeProxyFactoryBean.class); + // try { + // builder.addConstructorArgValue(Class.forName(e.getValue().getHttpExchangeClass())); + // } catch (ClassNotFoundException e1) { + // throw new RestMisconfigurationConfigurationException( + // "Unknown class %s for REST service to auto-configure" + // .formatted(e.getValue().getHttpExchangeClass())); + // } + // builder.addConstructorArgReference(e.getValue().getClientBeanName()); + // final var beanName = e.getValue().getBeanName().orElse(toCamelCase(e.getKey())); + // registry.registerBeanDefinition(beanName, builder.getBeanDefinition()); + // }); + + } + } + + @Setter + public static class RestClientFactoryBean implements FactoryBean { + private String clientId; + private SystemProxyProperties systemProxyProperties; + private SpringAddonsRestProperties restProperties; + private Optional authorizedClientManager = Optional.empty(); + private Optional authorizedClientRepository = + Optional.empty(); + + @Override + @Nullable + public RestClient getObject() throws Exception { + final var builderFactoryBean = new RestClientBuilderFactoryBean(); + builderFactoryBean.setClientId(clientId); + builderFactoryBean.setSystemProxyProperties(systemProxyProperties); + builderFactoryBean.setRestProperties(restProperties); + builderFactoryBean.setAuthorizedClientManager(authorizedClientManager); + builderFactoryBean.setAuthorizedClientRepository(authorizedClientRepository); + return Optional.ofNullable(builderFactoryBean.getObject()).map(RestClient.Builder::build) + .orElse(null); + } + + @Override + @Nullable + public Class getObjectType() { + return RestClient.class; + } + } + + @Data + public static class RestClientBuilderFactoryBean implements FactoryBean { + private String clientId; + private SystemProxyProperties systemProxyProperties = new SystemProxyProperties(); + private SpringAddonsRestProperties restProperties = new SpringAddonsRestProperties(); + private Optional authorizedClientManager; + private Optional authorizedClientRepository; + + + @Override + @Nullable + public RestClient.Builder getObject() throws Exception { + final var clientProps = Optional.ofNullable(restProperties.getClient().get(clientId)) + .orElseThrow(() -> new RestConfigurationNotFoundException(clientId)); + + final var builder = RestClient.builder(); + + // Handle HTTP or SOCK proxy and set timeouts & chunck-size + builder.requestFactory( + new SpringAddonsClientHttpRequestFactory(systemProxyProperties, clientProps.getHttp())); + + clientProps.getBaseUrl().map(URL::toString).ifPresent(builder::baseUrl); + + setAuthorizationHeader(builder, clientProps.getAuthorization(), clientId); + + return builder; + } + + @Override + @Nullable + public Class getObjectType() { + return RestClient.Builder.class; + } + + protected void setAuthorizationHeader(RestClient.Builder clientBuilder, + AuthorizationProperties authProps, String clientId) { + if (authProps.getOauth2().isConfigured() && authProps.getBasic().isConfigured()) { + throw new RestMisconfigurationException( + "REST authorization configuration for %s can be made for either OAuth2 or Basic, but not both at a time" + .formatted(clientId)); + } + if (authProps.getOauth2().isConfigured()) { + setBearerAuthorizationHeader(clientBuilder, authProps.getOauth2(), clientId); + } else if (authProps.getBasic().isConfigured()) { + setBasicAuthorizationHeader(clientBuilder, authProps.getBasic(), clientId); + } + } + + protected void setBearerAuthorizationHeader(RestClient.Builder clientBuilder, + AuthorizationProperties.OAuth2Properties oauth2Props, String clientId) { + if (!oauth2Props.isConfValid()) { + throw new RestMisconfigurationException( + "REST OAuth2 authorization configuration for %s can be made for either a registration-id or resource server Bearer forwarding, but not both at a time" + .formatted(clientId)); + } + if (oauth2Props.getOauth2RegistrationId().isPresent()) { + clientBuilder.requestInterceptor( + registrationClientHttpRequestInterceptor(oauth2Props.getOauth2RegistrationId().get())); + } else if (oauth2Props.isForwardBearer()) { + clientBuilder.requestInterceptor(forwardingClientHttpRequestInterceptor()); + } + } + + protected ClientHttpRequestInterceptor forwardingClientHttpRequestInterceptor() { + return (HttpRequest request, byte[] body, ClientHttpRequestExecution execution) -> { + final var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof OAuth2Token oauth2Token) { + request.getHeaders().setBearerAuth(oauth2Token.getTokenValue()); + } + return execution.execute(request, body); + }; + } + + protected ClientHttpRequestInterceptor registrationClientHttpRequestInterceptor( + String registrationId) { + if (authorizedClientManager.isEmpty()) { + throw new RestMisconfigurationException( + "OAuth2 client missconfiguration. Can't setup an OAuth2 Bearer request interceptor because there is no authorizedClientManager bean."); + } + final var interceptor = new OAuth2ClientHttpRequestInterceptor(authorizedClientManager.get()); + interceptor.setClientRegistrationIdResolver((HttpRequest request) -> registrationId); + authorizedClientRepository + .map(OAuth2ClientHttpRequestInterceptor::authorizationFailureHandler) + .ifPresent(interceptor::setAuthorizationFailureHandler); + return interceptor; + } + + protected void setBasicAuthorizationHeader(RestClient.Builder clientBuilder, + AuthorizationProperties.BasicAuthProperties authProps, String clientId) { + if (authProps.getEncodedCredentials().isPresent()) { + if (authProps.getUsername().isPresent() || authProps.getPassword().isPresent() + || authProps.getCharset().isPresent()) { + throw new RestMisconfigurationException( + "REST Basic authorization for %s is misconfigured: when encoded-credentials is provided, username, password and charset must be absent." + .formatted(clientId)); + } + } else { + if (authProps.getUsername().isEmpty() || authProps.getPassword().isEmpty()) { + throw new RestMisconfigurationException( + "REST Basic authorization for %s is misconfigured: when encoded-credentials is empty, username & password are required." + .formatted(clientId)); + } + } + clientBuilder.requestInterceptor((request, body, execution) -> { + authProps.getEncodedCredentials().ifPresent(request.getHeaders()::setBasicAuth); + authProps.getCharset().ifPresentOrElse( + charset -> request.getHeaders().setBasicAuth(authProps.getUsername().get(), + authProps.getPassword().get(), charset), + () -> request.getHeaders().setBasicAuth(authProps.getUsername().get(), + authProps.getPassword().get())); + return execution.execute(request, body); + }); + } + + } + +} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestClientSupport.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestClientSupport.java deleted file mode 100644 index ba07217bf..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestClientSupport.java +++ /dev/null @@ -1,244 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.Proxy; -import java.net.URI; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Pattern; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.client.ClientHttpRequest; -import org.springframework.http.client.ClientHttpRequestInterceptor; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.lang.NonNull; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.util.StringUtils; -import org.springframework.web.client.RestClient; -import org.springframework.web.client.support.RestClientAdapter; -import org.springframework.web.service.annotation.HttpExchange; -import org.springframework.web.service.invoker.HttpServiceProxyFactory; - -import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.AuthorizationProperties; - -import lombok.Data; -import lombok.extern.slf4j.Slf4j; - -/** - *

- * Helps building {@link RestClient} instances. Main features are: - *

- *
    - *
  • providing with builders pre-configured for OAuth2: add a Bearer Authorization header provided by the - * {@link OAuth2AuthorizedClientManager} for a given registration-id or by a {@link BearerProvider} (taking the Bearer from the security - * context to forward it)
  • - *
  • providing with helper methods to get a HTTP service from the {@link HttpServiceProxyFactory} and application properties
  • - *
- *

- *

- * When spring-addons {@link SpringAddonsRestProperties.RestClientProperties.AuthorizationProperties.OAuth2Properties#forwardBearer} is - * true, the Bearer is taken from the {@link BearerProvider} in the context, {@link DefaultBearerProvider} by default which works only with - * {@link JwtAuthenticationToken} or {@link BearerTokenAuthentication}. You must provide with your own {@link BearerProvider} bean if your - * security configuration populates the security context with something else. - *

- *

- * /!\ Auto-configured only in servlet (WebMVC) applications and only if some {@link SpringAddonsRestProperties} are present /!\ - *

- * - * @author Jerome Wacongne chl4mp@c4-soft.com - */ -@Data -@Slf4j -public class SpringAddonsRestClientSupport { - - private final ProxySupport proxySupport; - - private final Map restClientProperties; - - /** - * A {@link BearerProvider} to get the Bearer from the request security context - */ - private final BearerProvider forwardingBearerProvider; - - private final Optional authorizedClientManager; - - public SpringAddonsRestClientSupport( - SystemProxyProperties systemProxyProperties, - SpringAddonsRestProperties restProperties, - BearerProvider forwardingBearerProvider, - Optional authorizedClientManager) { - super(); - this.proxySupport = new ProxySupport(systemProxyProperties, restProperties); - this.restClientProperties = restProperties.getClient(); - this.forwardingBearerProvider = forwardingBearerProvider; - this.authorizedClientManager = authorizedClientManager; - } - - public RestClient.Builder client() { - final var builder = RestClient.builder(); - proxySupport.getHostname().map(proxyHostname -> new SpringAddonsClientHttpRequestFactory(proxySupport)).ifPresent(builder::requestFactory); - if (proxySupport.getAddonsProperties().isEnabled() - && StringUtils.hasText(proxySupport.getAddonsProperties().getUsername()) - && StringUtils.hasText(proxySupport.getAddonsProperties().getPassword())) { - final var base64 = Base64.getEncoder().encodeToString( - (proxySupport.getAddonsProperties().getUsername() + ':' + proxySupport.getAddonsProperties().getPassword()) - .getBytes(StandardCharsets.UTF_8)); - builder.defaultHeader(HttpHeaders.PROXY_AUTHORIZATION, "Basic %s".formatted(base64)); - } - - return builder; - } - - /** - * @param clientName key in "client" entries of {@link SpringAddonsRestProperties} - * @return A {@link RestClient} Builder pre-configured with a base-URI and (optionally) with a Bearer Authorization - */ - public RestClient.Builder client(String clientName) { - final var clientProps = Optional.ofNullable(restClientProperties.get(clientName)).orElseThrow(() -> new RestConfigurationNotFoundException(clientName)); - - final var clientBuilder = client(); - - clientProps.getBaseUrl().map(URL::toString).ifPresent(clientBuilder::baseUrl); - - authorize(clientBuilder, clientProps.getAuthorization(), clientName); - - return clientBuilder; - } - - /** - * Uses the provided {@link RestClient} to proxy the httpServiceClass - * - * @param - * @param client - * @param httpServiceClass class of the #64;Service (with {@link HttpExchange} methods) to proxy with a {@link RestClient} - * @return a #64;Service proxy with a {@link RestClient} - */ - public T service(RestClient client, Class httpServiceClass) { - return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(client)).build().createClient(httpServiceClass); - } - - /** - * Builds a {@link RestClient} with just the provided spring-addons {@link SpringAddonsRestProperties} and uses it to proxy the - * httpServiceClass. - * - * @param - * @param httpServiceClass class of the #64;Service (with {@link HttpExchange} methods) to proxy with a {@link RestClient} - * @param clientName key in "client" entries of {@link SpringAddonsRestProperties} - * @return a #64;Service proxy with a {@link RestClient} - */ - public T service(String clientName, Class httpServiceClass) { - return this.service(this.client(clientName).build(), httpServiceClass); - } - - protected void authorize(RestClient.Builder clientBuilder, AuthorizationProperties authProps, String clientName) { - if (authProps.getOauth2().isConfigured() && authProps.getBasic().isConfigured()) { - throw new RestMisconfigurationConfigurationException( - "REST authorization configuration for %s can be made for either OAuth2 or Basic, but not both at a time".formatted(clientName)); - } - if (authProps.getOauth2().isConfigured()) { - oauth2(clientBuilder, authProps.getOauth2(), clientName); - } else if (authProps.getBasic().isConfigured()) { - basic(clientBuilder, authProps.getBasic(), clientName); - } - } - - protected void oauth2(RestClient.Builder clientBuilder, AuthorizationProperties.OAuth2Properties oauth2Props, String clientName) { - if (!oauth2Props.isConfValid()) { - throw new RestMisconfigurationConfigurationException( - "REST OAuth2 authorization configuration for %s can be made for either a registration-id or resource server Bearer forwarding, but not both at a time" - .formatted(clientName)); - } - oauth2Props.getOauth2RegistrationId().flatMap(this::oauth2RequestInterceptor).ifPresent(clientBuilder::requestInterceptor); - if (oauth2Props.isForwardBearer()) { - clientBuilder.requestInterceptor((request, body, execution) -> { - forwardingBearerProvider.getBearer().ifPresent(bearer -> { - request.getHeaders().setBearerAuth(bearer); - }); - return execution.execute(request, body); - }); - } - } - - protected Optional oauth2RequestInterceptor(String registrationId) { - if (authorizedClientManager.isEmpty()) { - log.warn("OAuth2 client missconfiguration. Can't setup an OAuth2 Bearer request interceptor because there is no authorizedClientManager bean."); - } - return authorizedClientManager.map(acm -> (request, body, execution) -> { - final var provider = new AuthorizedClientBearerProvider(acm, registrationId); - provider.getBearer().ifPresent(bearer -> { - request.getHeaders().setBearerAuth(bearer); - }); - return execution.execute(request, body); - }); - } - - protected void basic(RestClient.Builder clientBuilder, AuthorizationProperties.BasicAuthProperties authProps, String clientName) { - if (authProps.getEncodedCredentials().isPresent()) { - if (authProps.getUsername().isPresent() || authProps.getPassword().isPresent() || authProps.getCharset().isPresent()) { - throw new RestMisconfigurationConfigurationException( - "REST Basic authorization for %s is misconfigured: when encoded-credentials is provided, username, password and charset must be absent." - .formatted(clientName)); - } - } else { - if (authProps.getUsername().isEmpty() || authProps.getPassword().isEmpty()) { - throw new RestMisconfigurationConfigurationException( - "REST Basic authorization for %s is misconfigured: when encoded-credentials is empty, username & password are required." - .formatted(clientName)); - } - } - clientBuilder.requestInterceptor((request, body, execution) -> { - authProps.getEncodedCredentials().ifPresent(request.getHeaders()::setBasicAuth); - authProps.getCharset().ifPresentOrElse( - charset -> request.getHeaders().setBasicAuth(authProps.getUsername().get(), authProps.getPassword().get(), charset), - () -> request.getHeaders().setBasicAuth(authProps.getUsername().get(), authProps.getPassword().get())); - return execution.execute(request, body); - }); - } - - static Proxy.Type protocoleToProxyType(String protocol) { - if (protocol == null) { - return null; - } - final var lower = protocol.toLowerCase(); - if (lower.startsWith("http")) { - return Proxy.Type.HTTP; - } - if (lower.startsWith("socks")) { - return Proxy.Type.SOCKS; - } - return null; - } - - static class SpringAddonsClientHttpRequestFactory extends SimpleClientHttpRequestFactory { - private final Optional nonProxyHostsPattern; - private final Optional proxyOpt; - - public SpringAddonsClientHttpRequestFactory(ProxySupport proxySupport) { - super(); - this.nonProxyHostsPattern = Optional.ofNullable(proxySupport.getNoProxy()).map(Pattern::compile); - - this.proxyOpt = proxySupport.getHostname().map(proxyHostname -> { - final var address = new InetSocketAddress(proxyHostname, proxySupport.getPort()); - return new Proxy(protocoleToProxyType(proxySupport.getProtocol()), address); - }); - - setConnectTimeout(proxySupport.getConnectTimeoutMillis()); - } - - @Override - public @NonNull ClientHttpRequest createRequest(@NonNull URI uri, @NonNull HttpMethod httpMethod) throws IOException { - super.setProxy(proxyOpt.filter(proxy -> { - return nonProxyHostsPattern.map(pattern -> !pattern.matcher(uri.getHost()).matches()).orElse(true); - }).orElse(null)); - return super.createRequest(uri, httpMethod); - } - - } -} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java index 66fdaaa66..80f975e4c 100644 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java @@ -6,138 +6,280 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; - import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; import org.springframework.web.reactive.function.client.WebClient; - +import org.springframework.web.service.annotation.HttpExchange; import lombok.Data; /** - *

- * Configuration for HTTP or SOCKS proxy. - *

- *

- * HTTP_PROXY and NO_PROXY standard environment variable are used only if com.c4-soft.springaddons.rest.proxy.hostname is left empty and - * com.c4-soft.springaddons.rest.proxy.enabled is TRUE or null. - *

- * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ @Data @AutoConfiguration @ConfigurationProperties(prefix = "com.c4-soft.springaddons.rest") public class SpringAddonsRestProperties { - private ProxyProperties proxy = new ProxyProperties(); - - private Map client = new HashMap<>(); - - @Data - @ConfigurationProperties - public static class ProxyProperties { - private boolean enabled = true; - private String protocol = "http"; - private int port = 8080; - private String username; - private String password; - private int connectTimeoutMillis = 10000; - - private Optional host = Optional.empty(); - - private String nonProxyHostsPattern; - } - - @Data - @ConfigurationProperties - public static class RestClientProperties { - /** - * Base URI used to build the REST client ({@link RestClient} or {@link WebClient}) - */ - private Optional baseUrl = Optional.empty(); - - private AuthorizationProperties authorization = new AuthorizationProperties(); - - public Optional getBaseUrl() { - return baseUrl.map(t -> { - try { - return new URL(t); - } catch (MalformedURLException e) { - throw new RuntimeException(e); - } - }); - } - - @Data - @ConfigurationProperties - public static class AuthorizationProperties { - - private OAuth2Properties oauth2 = new OAuth2Properties(); - - private BasicAuthProperties basic = new BasicAuthProperties(); - - boolean isConfigured() { - return oauth2.isConfigured() || basic.isConfigured(); - } - - boolean isConfValid() { - return oauth2.isConfValid() && basic.isConfValid() && (!oauth2.isConfigured() || !basic.isConfigured()); - } - - @Data - @ConfigurationProperties - public static class OAuth2Properties { - /** - *

- * If provided, it is used to get an access token from the {@link OAuth2AuthorizedClientManager}. - *

- *

- * Must reference a valid entry under spring.security.oauth2.client.registration - *

- *

- * Mutually exclusive with forward-bearer property. - *

- */ - private Optional oauth2RegistrationId = Optional.empty(); - - /** - *

- * If true, a {@link BearerProvider} is used to retrieve a Bearer token from the {@link Authentication} in the security context. - *

- *

- * Mutually exclusive with auth2-registration-id property. - *

- * - * @see DefaultBearerProvider - */ - private boolean forwardBearer = false; - - boolean isConfigured() { - return forwardBearer || oauth2RegistrationId.isPresent(); - } - - boolean isConfValid() { - return !forwardBearer || oauth2RegistrationId.isEmpty(); - } - } - - @Data - @ConfigurationProperties - public static class BasicAuthProperties { - private Optional username = Optional.empty(); - private Optional password = Optional.empty(); - private Optional charset = Optional.empty(); - private Optional encodedCredentials = Optional.empty(); - - boolean isConfigured() { - return encodedCredentials.isPresent() || username.isPresent(); - } - - boolean isConfValid() { - return encodedCredentials.isEmpty() || (username.isEmpty() && password.isEmpty()); - } - } - } - } + + /** + * Expose {@link RestClient} or {@link WebClient} instances as named beans + */ + private Map client = new HashMap<>(); + + // FIXME: enable when a way is found to generate and register service proxies as beans. + // For instance, have the HttpExchangeProxyFactoryBean definitions registered with a + // BeanDefinitionRegistryPostProcessor + + // /** + // * Expose {@link HttpExchange @HttpExchange} proxies as named beans (generated using + // * {@link HttpServiceProxyFactory}) + // */ + // private Map service = new HashMap<>(); + + public String getClientBeanName(String clientId) { + if (!client.containsKey(clientId)) { + return null; + } + final var clientProperties = client.get(clientId); + return clientProperties.getBeanName() + .orElse(clientProperties.isExposeBuilder() ? toCamelCase(clientId) + "Builder" + : toCamelCase(clientId)); + } + + private static String toCamelCase(String in) { + if (in == null) { + return null; + } + if (!StringUtils.hasText(in)) { + return ""; + } + String[] words = in.split("[\\W_]+"); + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < words.length; i++) { + String word = words[i]; + if (i == 0) { + word = word.isEmpty() ? word : word.toLowerCase(); + } else { + word = word.isEmpty() ? word + : Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase(); + } + builder.append(word); + } + return builder.toString(); + } + + @Data + public static class RestClientProperties { + /** + * Base URI used to build the REST client ({@link RestClient} or {@link WebClient}) + */ + private Optional baseUrl = Optional.empty(); + + /** + * Configure a {@link ClientHttpRequestInterceptor} or {@link ExchangeFilterFunction} to + * authorize requests (add a Basic or Bearer header to each request) + */ + private AuthorizationProperties authorization = new AuthorizationProperties(); + + /** + * Configure the internal {@link SimpleClientHttpRequestFactory} with timeouts and HTTP or SOCKS + * proxy + */ + private ClientHttpRequestFactoryProperties http = new ClientHttpRequestFactoryProperties(); + + /** + * Defines the type of the REST client. Default is {@link RestClient} in servlet applications + * and {@link WebClient} in reactive ones. + */ + private ClientType type = ClientType.DEFAULT; + + /** + * If true, what is exposed as a bean is the pre-configured {@link RestClient.Builder} or + * {@link WebClient.Builder}. This allows to add some more configuration. Don't forget to expose + * the resulting {@link RestClient} or {@link WebClient} as a named bean if you intend to use it + * as the REST client in an auto-configured {@link HttpExchange @HttpExchange} proxy. + */ + private boolean exposeBuilder = false; + + /** + *

+ * Override the auto-configured bean name which defaults to the camelCase version of the + * client-id, with the "Builder" suffix if expose-builder is true. + *

+ *

+ * For instance, "com.c4-soft.springaddons.rest.client.machin-client" will create a bean named + * machinClient or machinClientBuilder depending on + * "com.c4-soft.springaddons.rest.client.machin-client.expose-builder" value. + *

+ */ + private Optional beanName = Optional.empty(); + + public Optional getBaseUrl() { + return baseUrl.map(t -> { + try { + return new URL(t); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + }); + } + + @Data + public static class AuthorizationProperties { + + private OAuth2Properties oauth2 = new OAuth2Properties(); + + private BasicAuthProperties basic = new BasicAuthProperties(); + + boolean isConfigured() { + return oauth2.isConfigured() || basic.isConfigured(); + } + + boolean isConfValid() { + return oauth2.isConfValid() && basic.isConfValid() + && (!oauth2.isConfigured() || !basic.isConfigured()); + } + + @Data + public static class OAuth2Properties { + /** + *

+ * If provided, it is used to get an access token from the + * {@link OAuth2AuthorizedClientManager}. + *

+ *

+ * Must reference a valid entry under spring.security.oauth2.client.registration + *

+ *

+ * Mutually exclusive with forward-bearer property. + *

+ */ + private Optional oauth2RegistrationId = Optional.empty(); + + /** + *

+ * If true, the access token is taken from the {@link Authentication} in the security + * context. + *

+ *

+ * Mutually exclusive with auth2-registration-id property. + *

+ */ + private boolean forwardBearer = false; + + boolean isConfigured() { + return forwardBearer || oauth2RegistrationId.isPresent(); + } + + boolean isConfValid() { + return !forwardBearer || oauth2RegistrationId.isEmpty(); + } + } + + @Data + public static class BasicAuthProperties { + private Optional username = Optional.empty(); + private Optional password = Optional.empty(); + private Optional charset = Optional.empty(); + private Optional encodedCredentials = Optional.empty(); + + boolean isConfigured() { + return encodedCredentials.isPresent() || username.isPresent(); + } + + boolean isConfValid() { + return encodedCredentials.isEmpty() || (username.isEmpty() && password.isEmpty()); + } + } + } + + @Data + public static class ClientHttpRequestFactoryProperties { + /** + *

+ * Configure Proxy-Authorization header for authentication on a HTTP or SOCKS proxy. This + * header auto-configuration can be disable on each client. + *

+ *

+ * HTTP_PROXY and NO_PROXY standard environment variable are used only if + * "com.c4-soft.springaddons.rest.proxy.hostname" is left empty and + * "com.c4-soft.springaddons.rest.proxy.enabled" is TRUE or null. In other words, if the + * standard environment variables are correctly set, leaving "proxy" properties empty here is + * probably the best option. + *

+ */ + private ProxyProperties proxy = new ProxyProperties(); + + /** + * Connection timeout in milliseconds. + */ + private Optional connectTimeoutMillis = Optional.empty(); + + /** + * Read timeout in milliseconds. + */ + private Optional readTimeoutMillis = Optional.empty(); + + /** + * Supported only with {@link RestClient}. Set the number of bytes to write in each chunk. + */ + private Optional chunkSize = Optional.empty(); + + @Data + public static class ProxyProperties { + private boolean enabled = true; + private String protocol = "http"; + private int port = 8080; + private String username; + private String password; + private int connectTimeoutMillis = 10000; + + private Optional host = Optional.empty(); + + private String nonProxyHostsPattern; + } + + } + + public static enum ClientType { + DEFAULT, REST_CLIENT, WEB_CLIENT; + } + } + + @Data + public static class RestServiceProperties { + /** + *

+ * Name of a {@link RestClient} or {@link WebClient} bean. + *

+ * Note that: + *
    + *
  • This bean does not have to be one of the auto-generated REST clients.
  • + *
  • The value is a REST client bean name, not a "com.c4-soft.springaddons.rest.client" + * key, which is the ID of for an auto-generated REST client (or builder) bean.
  • + *
  • As a reminder, auto-generated REST client beans hare named with a camel-case version of + * their ID. For instance "com.c4-soft.springaddons.rest.client.machin-client" properties would + * create a bean named "machinClient"
  • + *
+ */ + private String clientBeanName; + + /** + * Fully qualified class name of the {@link HttpExchange} to implement + */ + private String httpExchangeClass; + + /** + *

+ * Override the auto-configured bean name which defaults to the camelCase version of the + * client-id. + *

+ */ + private Optional beanName = Optional.empty(); + } } diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServerWebClientBeans.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServerWebClientBeans.java new file mode 100644 index 000000000..890fe4803 --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServerWebClientBeans.java @@ -0,0 +1,159 @@ +package com.c4_soft.springaddons.rest; + +import java.util.List; +import java.util.Optional; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientType; +import lombok.Setter; + +/** + * Applied only in reactive (WebFlux) applications. + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +@ConditionalOnWebApplication(type = Type.REACTIVE) +@AutoConfiguration +public class SpringAddonsServerWebClientBeans { + + @Bean + SpringAddonsServerWebClientBeanDefinitionRegistryPostProcessor springAddonsWebClientBeanDefinitionRegistryPostProcessor( + Environment environment) { + return new SpringAddonsServerWebClientBeanDefinitionRegistryPostProcessor(environment); + } + + /** + *

+ * Post process the {@link BeanDefinitionRegistry} to add a {@link WebClient} (or + * {@link WebClient.builder}) bean definitions for each entry in + * "com.c4-soft.springaddons.rest.client". + *

+ * + *

+ * Bean definitions include a base URI, header and {@link ReactorClientHttpConnector} for HTTP or + * SOCKS proxy, as well as exchange function for Basic or OAuth2 (Bearer) authorization. + *

+ * + *

+ * The bean names are by default the camelCase transformation of the client-id, suffixed with + * "Builder" if the expose-builder property is true. + *

+ * + * @author ch4mp@c4-soft.com + */ + static class SpringAddonsServerWebClientBeanDefinitionRegistryPostProcessor + implements BeanDefinitionRegistryPostProcessor { + + private final SpringAddonsRestProperties restProperties; + private final SystemProxyProperties systemProxyProperties; + + @SuppressWarnings("unchecked") + public SpringAddonsServerWebClientBeanDefinitionRegistryPostProcessor(Environment environment) { + this.restProperties = Binder.get(environment) + .bind("com.c4-soft.springaddons.rest", SpringAddonsRestProperties.class) + .orElseThrow(() -> new RestMisconfigurationException( + "Could not read spring-addons REST properties")); + + final var httpProxy = Optional + .ofNullable(Binder.get(environment).bind("http-proxy", String.class).orElse(null)); + final var noProxy = Binder.get(environment).bind("no-proxy", List.class).orElse(List.of()); + this.systemProxyProperties = new SystemProxyProperties(httpProxy, noProxy); + } + + @Override + public void postProcessBeanDefinitionRegistry(@NonNull BeanDefinitionRegistry registry) + throws BeansException { + + restProperties.getClient().entrySet().stream() + .filter(e -> ClientType.WEB_CLIENT.equals(e.getValue().getType()) + || ClientType.DEFAULT.equals(e.getValue().getType())) + .forEach(e -> { + final var builder = e.getValue().isExposeBuilder() + ? BeanDefinitionBuilder + .genericBeanDefinition(ServerWebClientBuilderFactoryBean.class) + : BeanDefinitionBuilder.genericBeanDefinition(ServerWebClientFactoryBean.class); + builder.addPropertyValue("systemProxyProperties", systemProxyProperties); + builder.addPropertyValue("restProperties", restProperties); + builder.addAutowiredProperty("clientRegistrationRepository"); + builder.addAutowiredProperty("authorizedClientRepository"); + builder.addPropertyValue("clientId", e.getKey()); + registry.registerBeanDefinition(restProperties.getClientBeanName(e.getKey()), + builder.getBeanDefinition()); + }); + + /* + * FIXME: for some reason, registering HttpExchangeProxyFactoryBean definitions doesn't + * work: the clientRegistrationRepo is initialized before OAuth2 client properties are + * resolved and the HttpExchangeProxyFactoryBean named beans are not resolved when + * injecting T in components + */ + + } + } + + @Setter + public static class ServerWebClientBuilderFactoryBean + extends AbstractWebClientBuilderFactoryBean { + private Optional clientRegistrationRepository; + private Optional authorizedClientRepository; + + @Override + protected ExchangeFilterFunction registrationExchangeFilterFunction( + String Oauth2RegistrationId) { + return SpringAddonsServerWebClientSupport.registrationExchangeFilterFunction( + clientRegistrationRepository.get(), authorizedClientRepository.get(), + Oauth2RegistrationId); + } + + @Override + protected ExchangeFilterFunction forwardingBearerExchangeFilterFunction() { + return SpringAddonsServerWebClientSupport.forwardingBearerExchangeFilterFunction(); + } + } + + @Setter + public static class ServerWebClientFactoryBean implements FactoryBean { + private String clientId; + private SystemProxyProperties systemProxyProperties; + private SpringAddonsRestProperties restProperties; + private Optional clientRegistrationRepository = + Optional.empty(); + private Optional authorizedClientRepository = + Optional.empty(); + + @Override + @Nullable + public WebClient getObject() throws Exception { + final var builderFactoryBean = new ServerWebClientBuilderFactoryBean(); + builderFactoryBean.setClientId(clientId); + builderFactoryBean.setSystemProxyProperties(systemProxyProperties); + builderFactoryBean.setRestProperties(restProperties); + builderFactoryBean.setClientRegistrationRepository(clientRegistrationRepository); + builderFactoryBean.setAuthorizedClientRepository(authorizedClientRepository); + return Optional.ofNullable(builderFactoryBean.getObject()).map(WebClient.Builder::build) + .orElse(null); + } + + @Override + @Nullable + public Class getObjectType() { + return WebClient.class; + } + } +} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServerWebClientSupport.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServerWebClientSupport.java new file mode 100644 index 000000000..47c0ff286 --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServerWebClientSupport.java @@ -0,0 +1,59 @@ +package com.c4_soft.springaddons.rest; + +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.AbstractOAuth2Token; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +public class SpringAddonsServerWebClientSupport { + + /** + * @return Filter function to add Bearer authorization to {@link WebClient} requests in a WebFlux + * application. The access token being retrieved from the security context, the + * application must be a resource server. If the context is anonymous (the parent request + * is not authorized), then the child request is anonymous too (no authorization header is + * set). + */ + public static ExchangeFilterFunction forwardingBearerExchangeFilterFunction() { + return (ClientRequest request, ExchangeFunction next) -> { + return ReactiveSecurityContextHolder.getContext().map(sch -> { + final var auth = sch.getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof AbstractOAuth2Token oauth2Token) { + return ClientRequest.from(request) + .headers(headers -> headers.setBearerAuth(oauth2Token.getTokenValue())).build(); + } + return request; + }).or(Mono.just(request)).flatMap(next::exchange); + }; + } + + /** + * + * @param clientRegistrationRepository + * @param authorizedClientRepository + * @param registrationId the registration ID to use (a key in + * "spring.security.oauth2.client.registration" properties) + * @return Filter function to add Bearer authorization to {@link WebClient} requests in a WebFlux + * application. The access token being retrieved from an OAuth2 client registration, with + * client credentials in a resource server application, or any flow in an app is + * oauth2Login. + */ + public static ExchangeFilterFunction registrationExchangeFilterFunction( + ReactiveClientRegistrationRepository clientRegistrationRepository, + ServerOAuth2AuthorizedClientRepository authorizedClientRepository, String registrationId) { + final var delegate = new ServerOAuth2AuthorizedClientExchangeFilterFunction( + clientRegistrationRepository, authorizedClientRepository); + delegate.setDefaultClientRegistrationId(registrationId); + return delegate; + } +} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServletWebClientBeans.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServletWebClientBeans.java new file mode 100644 index 000000000..91e9dc9db --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServletWebClientBeans.java @@ -0,0 +1,156 @@ +package com.c4_soft.springaddons.rest; + +import java.util.List; +import java.util.Optional; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.core.env.Environment; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientType; +import lombok.Setter; + +/** + * Applied only in servlet applications and only if {@link WebClient} is on the classpath. + * + * @author ch4mp@c4-soft.com + */ +@Conditional(IsServletWithWebClientCondition.class) +@AutoConfiguration +public class SpringAddonsServletWebClientBeans { + + @Bean + SpringAddonsServletWebClientBeanDefinitionRegistryPostProcessor springAddonsWebClientBeanDefinitionRegistryPostProcessor( + Environment environment) { + return new SpringAddonsServletWebClientBeanDefinitionRegistryPostProcessor(environment); + } + + /** + *

+ * Post process the {@link BeanDefinitionRegistry} to add a {@link WebClient} (or + * {@link WebClient.builder}) bean definitions for each entry in + * "com.c4-soft.springaddons.rest.client". + *

+ * + *

+ * Bean definitions include a base URI, header and {@link ReactorClientHttpConnector} for HTTP or + * SOCKS proxy, as well as exchange function for Basic or OAuth2 (Bearer) authorization. + *

+ * + *

+ * The bean names are by default the camelCase transformation of the client-id, suffixed with + * "Builder" if the expose-builder property is true. + *

+ * + * @author ch4mp@c4-soft.com + */ + static class SpringAddonsServletWebClientBeanDefinitionRegistryPostProcessor + implements BeanDefinitionRegistryPostProcessor { + + private final SpringAddonsRestProperties restProperties; + private final SystemProxyProperties systemProxyProperties; + + @SuppressWarnings("unchecked") + public SpringAddonsServletWebClientBeanDefinitionRegistryPostProcessor( + Environment environment) { + this.restProperties = Binder.get(environment) + .bind("com.c4-soft.springaddons.rest", SpringAddonsRestProperties.class) + .orElseThrow(() -> new RestMisconfigurationException( + "Could not read spring-addons REST properties")); + + final var httpProxy = Optional + .ofNullable(Binder.get(environment).bind("http-proxy", String.class).orElse(null)); + final var noProxy = Binder.get(environment).bind("no-proxy", List.class).orElse(List.of()); + this.systemProxyProperties = new SystemProxyProperties(httpProxy, noProxy); + } + + @Override + public void postProcessBeanDefinitionRegistry(@NonNull BeanDefinitionRegistry registry) + throws BeansException { + + restProperties.getClient().entrySet().stream() + .filter(e -> ClientType.WEB_CLIENT.equals(e.getValue().getType())).forEach(e -> { + final var builder = e.getValue().isExposeBuilder() + ? BeanDefinitionBuilder + .genericBeanDefinition(ServletWebClientBuilderFactoryBean.class) + : BeanDefinitionBuilder.genericBeanDefinition(ServletWebClientFactoryBean.class); + builder.addPropertyValue("systemProxyProperties", systemProxyProperties); + builder.addPropertyValue("restProperties", restProperties); + builder.addAutowiredProperty("clientRegistrationRepository"); + builder.addAutowiredProperty("authorizedClientRepository"); + builder.addPropertyValue("clientId", e.getKey()); + registry.registerBeanDefinition(restProperties.getClientBeanName(e.getKey()), + builder.getBeanDefinition()); + }); + + /* + * FIXME: for some reason, registering HttpExchangeProxyFactoryBean definitions doesn't + * work: the clientRegistrationRepo is initialized before OAuth2 client properties are + * resolved and the HttpExchangeProxyFactoryBean named beans are not resolved when + * injecting T in components + */ + + } + } + + @Setter + public static class ServletWebClientBuilderFactoryBean + extends AbstractWebClientBuilderFactoryBean { + private Optional clientRegistrationRepository; + private Optional authorizedClientRepository; + + @Override + protected ExchangeFilterFunction registrationExchangeFilterFunction( + String Oauth2RegistrationId) { + return SpringAddonsServletWebClientSupport.registrationExchangeFilterFunction( + clientRegistrationRepository.get(), authorizedClientRepository.get(), + Oauth2RegistrationId); + } + + @Override + protected ExchangeFilterFunction forwardingBearerExchangeFilterFunction() { + return SpringAddonsServletWebClientSupport.forwardingBearerExchangeFilterFunction(); + } + } + + @Setter + public static class ServletWebClientFactoryBean implements FactoryBean { + private String clientId; + private SystemProxyProperties systemProxyProperties; + private SpringAddonsRestProperties restProperties; + private Optional clientRegistrationRepository = Optional.empty(); + private Optional authorizedClientRepository = + Optional.empty(); + + @Override + @Nullable + public WebClient getObject() throws Exception { + final var builderFactoryBean = new ServletWebClientBuilderFactoryBean(); + builderFactoryBean.setClientId(clientId); + builderFactoryBean.setSystemProxyProperties(systemProxyProperties); + builderFactoryBean.setRestProperties(restProperties); + builderFactoryBean.setClientRegistrationRepository(clientRegistrationRepository); + builderFactoryBean.setAuthorizedClientRepository(authorizedClientRepository); + return Optional.ofNullable(builderFactoryBean.getObject()).map(WebClient.Builder::build) + .orElse(null); + } + + @Override + @Nullable + public Class getObjectType() { + return WebClient.class; + } + } +} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServletWebClientSupport.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServletWebClientSupport.java new file mode 100644 index 000000000..883d91aaf --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsServletWebClientSupport.java @@ -0,0 +1,55 @@ +package com.c4_soft.springaddons.rest; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.security.oauth2.core.AbstractOAuth2Token; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +public class SpringAddonsServletWebClientSupport { + /** + * @return Filter function to add Bearer authorization to {@link WebClient} requests in a servlet + * application. The access token being retrieved from the security context, the + * application must be a resource server. If the context is anonymous (the parent request + * is not authorized), then the child request is anonymous too (no authorization header is + * set). + */ + public static ExchangeFilterFunction forwardingBearerExchangeFilterFunction() { + return (ClientRequest request, ExchangeFunction next) -> { + final var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof AbstractOAuth2Token oauth2Token) { + return next.exchange(ClientRequest.from(request) + .headers(headers -> headers.setBearerAuth(oauth2Token.getTokenValue())).build()); + } + return next.exchange(request); + }; + } + + /** + * + * @param clientRegistrationRepository + * @param authorizedClientRepository + * @param registrationId the registration ID to use (a key in + * "spring.security.oauth2.client.registration" properties) + * @return Filter function to add Bearer authorization to {@link WebClient} requests in a servlet + * application. The access token being retrieved from an OAuth2 client registration, with + * client credentials in a resource server application, or any flow in an app is + * oauth2Login. + */ + public static ExchangeFilterFunction registrationExchangeFilterFunction( + ClientRegistrationRepository clientRegistrationRepository, + OAuth2AuthorizedClientRepository authorizedClientRepository, String registrationId) { + final var delegate = new ServletOAuth2AuthorizedClientExchangeFilterFunction( + clientRegistrationRepository, authorizedClientRepository); + delegate.setDefaultClientRegistrationId(registrationId); + return delegate; + } +} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsWebClientSupport.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsWebClientSupport.java deleted file mode 100644 index 318c379a6..000000000 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsWebClientSupport.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.c4_soft.springaddons.rest; - -import java.util.Optional; - -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ExchangeFilterFunction; -import org.springframework.web.reactive.function.client.ExchangeFunction; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.service.annotation.HttpExchange; - -/** - *

- * Provides with {@link WebClient} builder instances pre-configured with: - *

- *
    - *
  • HTTP conector if proxy properties or environment variables are set
  • - *
  • Base URL
  • - *
  • authorization exchange function if Basic or OAuth2 Bearer
  • - *
- *

- *

- * Also provides with helper methods to get {@link HttpExchange @@HttpExchange} proxies with {@link WebClient} - *

- *

- * /!\ Auto-configured only in servlet (WebMVC) applications and only if some {@link SpringAddonsRestProperties} are present /!\ - *

- * - * @author Jerome Wacongne chl4mp@c4-soft.com - * @see ReactiveSpringAddonsWebClientSupport an equivalent for reactive (Webflux) applications - */ -public class SpringAddonsWebClientSupport extends AbstractSpringAddonsWebClientSupport { - - private final Optional authorizedClientManager; - - public SpringAddonsWebClientSupport( - SystemProxyProperties systemProxyProperties, - SpringAddonsRestProperties addonsProperties, - BearerProvider forwardingBearerProvider, - Optional authorizedClientManager) { - super(systemProxyProperties, addonsProperties, forwardingBearerProvider); - this.authorizedClientManager = authorizedClientManager; - } - - @Override - protected ExchangeFilterFunction oauth2RegistrationFilter(String registrationId) { - return (ClientRequest request, ExchangeFunction next) -> { - final var provider = authorizedClientManager.map(acm -> new AuthorizedClientBearerProvider(acm, registrationId)); - if (provider.flatMap(AuthorizedClientBearerProvider::getBearer).isPresent()) { - final var modified = ClientRequest.from(request).headers(headers -> headers.setBearerAuth(provider.get().getBearer().get())).build(); - return next.exchange(modified); - } - return next.exchange(request); - }; - } -} diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SystemProxyProperties.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SystemProxyProperties.java index cbfe6dd25..219cf8058 100644 --- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SystemProxyProperties.java +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SystemProxyProperties.java @@ -4,43 +4,46 @@ import java.net.URL; import java.util.List; import java.util.Optional; - import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.context.properties.ConfigurationProperties; - +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; /** *

* Configuration for HTTP or SOCKS proxy. *

*

- * HTTP_PROXY and NO_PROXY standard environment variable are used only if com.c4-soft.springaddons.rest.proxy.hostname is left empty and + * HTTP_PROXY and NO_PROXY standard environment variable are used only if + * com.c4-soft.springaddons.rest.proxy.hostname is left empty and * com.c4-soft.springaddons.rest.proxy.enabled is TRUE or null. *

* - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + * @author Jérôme Wacongne <ch4mp@c4-soft.com> */ @Data @AutoConfiguration @ConfigurationProperties +@AllArgsConstructor +@NoArgsConstructor public class SystemProxyProperties { - /* also parse standard environment variables */ - @Value("${http_proxy:#{null}}") - private Optional httpProxy = Optional.empty(); + /* also parse standard environment variables */ + @Value("${http_proxy:#{null}}") + private Optional httpProxy = Optional.empty(); - @Value("${no_proxy:}") - private List noProxy = List.of(); + @Value("${no_proxy:}") + private List noProxy = List.of(); - public Optional getHttpProxy() { - return httpProxy.map(t -> { - try { - return new URL(t); - } catch (MalformedURLException e) { - throw new RuntimeException(e); - } - }); - } + public Optional getHttpProxy() { + return httpProxy.map(t -> { + try { + return new URL(t); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + }); + } } diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/WebClientHttpExchangeProxyFactoryBean.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/WebClientHttpExchangeProxyFactoryBean.java new file mode 100644 index 000000000..6535d059e --- /dev/null +++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/WebClientHttpExchangeProxyFactoryBean.java @@ -0,0 +1,58 @@ +package com.c4_soft.springaddons.rest; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.lang.Nullable; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import lombok.NoArgsConstructor; + +/** + * Bean factory using {@link HttpServiceProxyFactory} to generate an instance of a + * {@link HttpExchange @HttpExchange} interface + * + * @param {@link HttpExchange @HttpExchange} interface to implement + * @see RestClientHttpExchangeProxyFactoryBean RestClientHttpExchangeProxyFactoryBean for an + * equivalent accepting only RestClient + * @see HttpExchangeProxyFactoryBean HttpExchangeProxyFactoryBean for an equivalent accepting both + * RestClient an WebClient + * @author Jérôme Wacongne <ch4mp@c4-soft.com> + */ +@NoArgsConstructor +public class WebClientHttpExchangeProxyFactoryBean implements FactoryBean { + private Class httpExchangeClass; + private HttpExchangeAdapter adapter; + + public WebClientHttpExchangeProxyFactoryBean(Class httpExchangeClass, WebClient client) { + this.httpExchangeClass = httpExchangeClass; + this.setClient(client); + } + + @Override + @Nullable + public T getObject() throws Exception { + if (adapter == null || getObjectType() == null) { + throw new RestMisconfigurationException( + "Both of a WebClient and the @HttpExchange interface to implement must be configured on the HttpExchangeProxyFactoryBean before calling the getObject() method."); + } + return HttpServiceProxyFactory.builderFor(adapter).build().createClient(getObjectType()); + } + + @Override + public Class getObjectType() { + return httpExchangeClass; + } + + public WebClientHttpExchangeProxyFactoryBean setHttpExchangeClass(Class httpExchangeClass) { + this.httpExchangeClass = httpExchangeClass; + return this; + } + + public WebClientHttpExchangeProxyFactoryBean setClient(WebClient client) { + this.adapter = WebClientAdapter.create(client); + return this; + } + +} diff --git a/spring-addons-starter-rest/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-addons-starter-rest/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 85935af2c..a45703eb0 100644 --- a/spring-addons-starter-rest/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-addons-starter-rest/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,5 @@ com.c4_soft.springaddons.rest.SystemProxyProperties com.c4_soft.springaddons.rest.SpringAddonsRestProperties -com.c4_soft.springaddons.rest.SpringAddonsRestBeans \ No newline at end of file +com.c4_soft.springaddons.rest.SpringAddonsRestClientBeans +com.c4_soft.springaddons.rest.SpringAddonsServerWebClientBeans +com.c4_soft.springaddons.rest.SpringAddonsServletWebClientBeans \ No newline at end of file diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/AbstractSpringAddonsClientHttpRequestFactoryTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/AbstractSpringAddonsClientHttpRequestFactoryTest.java new file mode 100644 index 000000000..fc4c0a7b4 --- /dev/null +++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/AbstractSpringAddonsClientHttpRequestFactoryTest.java @@ -0,0 +1,31 @@ +package com.c4_soft.springaddons.rest; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(classes = StubBootApplication.class) +@ActiveProfiles("minimal") +class AbstractSpringAddonsClientHttpRequestFactoryTest { + @Autowired + SpringAddonsClientHttpRequestFactory requestFactory; + + protected HttpURLConnection getConnection(ClientHttpRequest request) throws NoSuchFieldException, + SecurityException, IllegalArgumentException, IllegalAccessException { + final var connectionField = request.getClass().getDeclaredField("connection"); + connectionField.setAccessible(true); + return (HttpURLConnection) connectionField.get(request); + } + + protected boolean isUsingProxy(String uri) throws NoSuchFieldException, SecurityException, + IllegalArgumentException, IllegalAccessException, IOException { + final var connection = + getConnection(requestFactory.createRequest(URI.create(uri), HttpMethod.GET)); + return connection.usingProxy(); + } +} diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryDisabledTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryDisabledTest.java new file mode 100644 index 000000000..c4ca8b4fd --- /dev/null +++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryDisabledTest.java @@ -0,0 +1,23 @@ +package com.c4_soft.springaddons.rest; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import java.io.IOException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(classes = StubBootApplication.class) +@ActiveProfiles("disabled") +class SpringAddonsClientHttpRequestFactoryDisabledTest + extends AbstractSpringAddonsClientHttpRequestFactoryTest { + + @Test + void test() throws IOException, IllegalArgumentException, IllegalAccessException, + NoSuchFieldException, SecurityException { + assertFalse(isUsingProxy("http://server.external.com/foo")); + assertFalse(isUsingProxy("http://localhost/foo")); + assertFalse(isUsingProxy("http://bravo-ch4mp/foo")); + assertFalse(isUsingProxy("http://server.corporate-domain.pf/foo")); + } + +} diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryFullTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryFullTest.java new file mode 100644 index 000000000..5887ce6fd --- /dev/null +++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryFullTest.java @@ -0,0 +1,23 @@ +package com.c4_soft.springaddons.rest; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(classes = StubBootApplication.class) +@ActiveProfiles("full") +class SpringAddonsClientHttpRequestFactoryFullTest + extends AbstractSpringAddonsClientHttpRequestFactoryTest { + + @Test + void test() throws IOException, IllegalArgumentException, IllegalAccessException, + NoSuchFieldException, SecurityException { + assertTrue(isUsingProxy("http://server.external.com/foo")); + assertFalse(isUsingProxy("http://localhost/foo")); + assertFalse(isUsingProxy("http://bravo-ch4mp/foo")); + assertFalse(isUsingProxy("http://server.corporate-domain.pf/foo")); + } +} diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryMinimalTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryMinimalTest.java new file mode 100644 index 000000000..7b0b62636 --- /dev/null +++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryMinimalTest.java @@ -0,0 +1,22 @@ +package com.c4_soft.springaddons.rest; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(classes = StubBootApplication.class) +@ActiveProfiles("minimal") +class SpringAddonsClientHttpRequestFactoryMinimalTest + extends AbstractSpringAddonsClientHttpRequestFactoryTest { + + @Test + void test() throws IOException, IllegalArgumentException, IllegalAccessException, + NoSuchFieldException, SecurityException { + assertTrue(isUsingProxy("http://server.external.com/foo")); + assertTrue(isUsingProxy("http://localhost/foo")); + assertTrue(isUsingProxy("http://bravo-ch4mp/foo")); + assertTrue(isUsingProxy("http://server.corporate-domain.pf/foo")); + } +} diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryStdEnvVarsTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryStdEnvVarsTest.java new file mode 100644 index 000000000..8e2ac376f --- /dev/null +++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryStdEnvVarsTest.java @@ -0,0 +1,23 @@ +package com.c4_soft.springaddons.rest; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(classes = StubBootApplication.class) +@ActiveProfiles("std-env-vars") +class SpringAddonsClientHttpRequestFactoryStdEnvVarsTest + extends AbstractSpringAddonsClientHttpRequestFactoryTest { + + @Test + void test() throws IOException, IllegalArgumentException, IllegalAccessException, + NoSuchFieldException, SecurityException { + assertTrue(isUsingProxy("http://server.external.com/foo")); + assertFalse(isUsingProxy("http://localhost/foo")); + assertFalse(isUsingProxy("http://bravo-ch4mp/foo")); + assertFalse(isUsingProxy("http://server.corporate-domain.pf/foo")); + } +} diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/StubBootApplication.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/StubBootApplication.java new file mode 100644 index 000000000..7bdabf512 --- /dev/null +++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/StubBootApplication.java @@ -0,0 +1,14 @@ +package com.c4_soft.springaddons.rest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication class StubBootApplication { + + @Bean + SpringAddonsClientHttpRequestFactory springAddonsClientHttpRequestFactory( + SystemProxyProperties systemProperties, SpringAddonsRestProperties addonsProperties) { + return new SpringAddonsClientHttpRequestFactory(systemProperties, + addonsProperties.getClient().get("test").getHttp()); + } +} \ No newline at end of file diff --git a/spring-addons-starter-rest/src/test/resources/application.properties b/spring-addons-starter-rest/src/test/resources/application.properties index 6ed717db3..e9d922247 100644 --- a/spring-addons-starter-rest/src/test/resources/application.properties +++ b/spring-addons-starter-rest/src/test/resources/application.properties @@ -1,25 +1,28 @@ server.ssl.enabled=false #--- -spring.config.activate.on-profile=host-port -com.c4-soft.springaddons.proxy.host=mini-proxy -com.c4-soft.springaddons.proxy.port=7080 +spring.config.activate.on-profile=minimal +com.c4-soft.springaddons.rest.client.test.http.proxy.host=mini-proxy +com.c4-soft.springaddons.rest.client.test.http.proxy.port=7080 #--- -spring.config.activate.on-profile=addons -com.c4-soft.springaddons.proxy.type=socks5 -com.c4-soft.springaddons.proxy.host=corp-proxy -com.c4-soft.springaddons.proxy.port=8080 -com.c4-soft.springaddons.proxy.username=toto -com.c4-soft.springaddons.proxy.password=abracadabra -com.c4-soft.springaddons.proxy.nonProxyHostsPattern=(localhost)|(bravo\\-ch4mp)|(.*\\.corporate\\-domain\\.com) -com.c4-soft.springaddons.proxy.connect-timeout-millis=500 +spring.config.activate.on-profile=full +com.c4-soft.springaddons.rest.client.test.http.proxy.enabled=true +com.c4-soft.springaddons.rest.client.test.http.proxy.protocol=http +com.c4-soft.springaddons.rest.client.test.http.proxy.host=corp-proxy +com.c4-soft.springaddons.rest.client.test.http.proxy.port=8080 +com.c4-soft.springaddons.rest.client.test.http.proxy.username=toto +com.c4-soft.springaddons.rest.client.test.http.proxy.password=abracadabra +com.c4-soft.springaddons.rest.client.test.http.proxy.nonProxyHostsPattern=(localhost)|(bravo\\-ch4mp)|(.*\\.corporate\\-domain\\.pf) +com.c4-soft.springaddons.rest.client.test.http.proxy.connect-timeout-millis=500 #--- -spring.config.activate.on-profile=disabled-proxy -com.c4-soft.springaddons.proxy.enabled=false +spring.config.activate.on-profile=disabled +com.c4-soft.springaddons.rest.client.test.http.proxy.enabled=false +com.c4-soft.springaddons.rest.client.test.http.proxy.host=mini-proxy +com.c4-soft.springaddons.rest.client.test.http.proxy.port=7080 #--- spring.config.activate.on-profile=std-env-vars -http_proxy=https://machin:truc@env-proxy:8080 -no_proxy=localhost,bravo-ch4mp,.env-domain.pf \ No newline at end of file +http-proxy=https://machin:truc@env-proxy:8080 +no-proxy=localhost,bravo-ch4mp,.corporate-domain.pf \ No newline at end of file diff --git a/starters/pom.xml b/starters/pom.xml deleted file mode 100644 index 99921adfb..000000000 --- a/starters/pom.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - 4.0.0 - - com.c4-soft.springaddons - spring-addons - 7.8.13-SNAPSHOT - .. - - starters - pom - - - spring-addons-starters-webclient - spring-addons-starters-recaptcha - - - - - - com.c4-soft.springaddons.starter - spring-addons-starters-webclient - ${project.version} - - - - - diff --git a/starters/spring-addons-starters-recaptcha/pom.xml b/starters/spring-addons-starters-recaptcha/pom.xml deleted file mode 100644 index 1fd81d453..000000000 --- a/starters/spring-addons-starters-recaptcha/pom.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - 4.0.0 - - - com.c4-soft.springaddons - starters - 7.8.13-SNAPSHOT - .. - - com.c4-soft.springaddons.starter - spring-addons-starters-recaptcha - - https://github.com/ch4mpy/spring-addons/ - - scm:git:git://github.com/ch4mpy/spring-addons.git - scm:git:git@github.com:ch4mpy/spring-addons.git - https://github.com/ch4mpy/spring-addons - spring-addons-7.8.8 - - - - - org.springframework - spring-web - - - org.springframework.boot - spring-boot - - - org.springframework.boot - spring-boot-autoconfigure - - - com.c4-soft.springaddons - spring-addons-starter-rest - - - - org.slf4j - slf4j-api - - - org.projectlombok - lombok - true - - - - org.springframework.boot - spring-boot-configuration-processor - true - - - diff --git a/starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaSettings.java b/starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaSettings.java deleted file mode 100644 index 93459b155..000000000 --- a/starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaSettings.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.c4_soft.springaddons.starter.recaptcha; - -import java.net.URL; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import lombok.Data; - -@Data -@Component -@ConfigurationProperties(prefix = "com.c4-soft.springaddons.recaptcha") -public class C4ReCaptchaSettings { - private String secretKey; - @Value("${siteverify-url:https://www.google.com/recaptcha/api/siteverify}") - private URL siteverifyUrl; - private double v3Threshold = .5; -} diff --git a/starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaValidationService.java b/starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaValidationService.java deleted file mode 100644 index 7dbd8f8ab..000000000 --- a/starters/spring-addons-starters-recaptcha/src/main/java/com/c4_soft/springaddons/starter/recaptcha/C4ReCaptchaValidationService.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.c4_soft.springaddons.starter.recaptcha; - -import java.util.stream.Collectors; - -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.web.client.RestClient; - -import com.c4_soft.springaddons.rest.SpringAddonsRestClientSupport; - -import lombok.extern.slf4j.Slf4j; - -/** - * Usage: - * - *
- * if (Boolean.FALSE.equals(captcha.checkV2(reCaptcha).block())) {
- *     throw new RuntimeException("Are you a robot?");
- * }
- * 
- * - * @author Jérôme Wacongne ch4mp@c4-soft.com - */ -@Service -@Slf4j -public class C4ReCaptchaValidationService { - - private final RestClient client; - private final String googleRecaptchaSecret; - private final double v3Threshold; - - public C4ReCaptchaValidationService(C4ReCaptchaSettings settings, SpringAddonsRestClientSupport clientSupport) { - this.client = clientSupport.client().baseUrl(settings.getSiteverifyUrl().toString()).build(); - this.googleRecaptchaSecret = settings.getSecretKey(); - this.v3Threshold = settings.getV3Threshold(); - } - - /** - * Checks a reCaptcha V2 challenge response - * - * @param response answer provided by the client - * @return true / false - */ - public Boolean checkV2(String response) { - final var dto = response(response, V2ValidationResponseDto.class); - log.debug("reCaptcha result : {}", dto); - return dto.isSuccess(); - } - - /** - * Checks a reCaptcha V3 challenge response - * - * @param response answer provided by the client - * @return a score between 0 and 1 - * @throws ReCaptchaValidationException if response wasn't a valid reCAPTCHA token for your site or score is below configured threshold - */ - public Double checkV3(String response) throws ReCaptchaValidationException { - final var dto = response(response, V3ValidationResponseDto.class); - log.debug("reCaptcha result : {}", dto); - if (!dto.isSuccess()) { - throw new ReCaptchaValidationException( - String.format("Failed to validate reCaptcha: %s %s", response, dto.getErrorCodes().stream().collect(Collectors.joining("[", ", ", "]")))); - } - if (dto.getScore() < v3Threshold) { - throw new ReCaptchaValidationException(String.format("Failed to validate reCaptcha: %s. Score is %f", response, dto.getScore())); - } - return dto.getScore(); - } - - private T response(String response, Class dtoType) { - final var formData = new LinkedMultiValueMap<>(); - formData.add("secret", googleRecaptchaSecret); - formData.add("response", response); - return client.post().contentType(MediaType.APPLICATION_FORM_URLENCODED).body(formData).retrieve().toEntity(dtoType).getBody(); - } -} diff --git a/starters/spring-addons-starters-webclient/README.md b/starters/spring-addons-starters-webclient/README.md deleted file mode 100644 index 58ebf1010..000000000 --- a/starters/spring-addons-starters-webclient/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# spring-boot starter for `C4WebClientBuilderFactoryService` -Tiny lib exposing a factory service for WebClient builders with proxy configuration from properties - -## Usage -Thanks to `@AutoConfiguration` magic, only 3 very simple steps are needed: -### Put this library on your classpath -```xml - - com.c4-soft.springaddons.starter - spring-addons-starters-webclient - ${spring-addons.version} - -``` - -### Configuration -Two sources of configuration properties are evaluated: -- `com.c4-soft.springaddons.proxy.*` -- `http_proxy` and `no_proxy` - -`com.c4-soft.springaddons.proxy.*` have precedence if `host` is not empty. This means that the standard `HTTP_PROXY` and `NO_PROXY` environment variables will be used only if: -- `com.c4-soft.springaddons.proxy.host` is left empty -- `com.c4-soft.springaddons.proxy.enabled` is left empty or is explicitly set to `true` - -There is a noteworthy difference between the two possible properties for configuring proxy bypass: -- `com.c4-soft.springaddons.proxy.non-proxy-hosts-pattern` expects Java RegEx (for instance `(localhost)|(bravo\\-ch4mp)|(.*\\.corporate\\-domain\\.com)`) -- `no_proxy` expects comma separated list of hosts / domains (for instance `localhost,bravo-ch4mp,.env-domain.pf`) - -### Inject `C4WebClientBuilderFactoryService` where you need it -```java -@RestController -@RequestMapping("/") -@RequiredArgsConstructor -public class GreetingController { - private final C4WebClientBuilderFactoryService webClientBuilderFactory; - ... -} -``` - -## Sample -You might refer to unit-tests for a sample spring-boot app: -```java -@SpringBootApplication -public class WebClientSampleApp { - public static void main(String[] args) { - new SpringApplicationBuilder(WebClientSampleApp.class).web(WebApplicationType.REACTIVE).run(args); - } - - @RestController - @RequestMapping("/sample") - @RequiredArgsConstructor - public static class SampleController { - private final C4WebClientBuilderFactoryService webClientBuilderFactory; - - @GetMapping("/delegating") - public Mono calling() throws MalformedURLException { - return webClientBuilderFactory.get(new URL("http://localhost:8080")).build().get().uri("/sample/delegate").retrieve().bodyToMono(String.class); - } - - @GetMapping("/delegate") - public Mono remote() { - return Mono.just("Hello!"); - } - } -} -``` -Properties file uses profiles to try various configuration scenarios: -```properties -server.port=8080 -server.ssl.enabled=false - -#--- -spring.config.activate.on-profile=host-port -com.c4-soft.springaddons.proxy.host=mini-proxy -com.c4-soft.springaddons.proxy.port=7080 - -#--- -spring.config.activate.on-profile=addons -com.c4-soft.springaddons.proxy.type=socks5 -com.c4-soft.springaddons.proxy.host=corp-proxy -com.c4-soft.springaddons.proxy.port=8080 -com.c4-soft.springaddons.proxy.username=toto -com.c4-soft.springaddons.proxy.password=abracadabra -com.c4-soft.springaddons.proxy.nonProxyHostsPattern=(localhost)|(bravo\\-ch4mp)|(.*\\.corporate\\-domain\\.com) -com.c4-soft.springaddons.proxy.connect-timeout-millis=500 - -#--- -spring.config.activate.on-profile=disabled-proxy -com.c4-soft.springaddons.proxy.enabled=false - -#--- -spring.config.activate.on-profile=std-env-vars -http_proxy=https://machin:truc@env-proxy:8080 -no_proxy=localhost,bravo-ch4mp,.env-domain.pf -``` \ No newline at end of file diff --git a/starters/spring-addons-starters-webclient/pom.xml b/starters/spring-addons-starters-webclient/pom.xml deleted file mode 100644 index d9c57d47f..000000000 --- a/starters/spring-addons-starters-webclient/pom.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - 4.0.0 - - - com.c4-soft.springaddons - starters - 7.8.13-SNAPSHOT - .. - - com.c4-soft.springaddons.starter - spring-addons-starters-webclient - - https://github.com/ch4mpy/spring-addons/ - - scm:git:git://github.com/ch4mpy/spring-addons.git - scm:git:git@github.com:ch4mpy/spring-addons.git - https://github.com/ch4mpy/spring-addons - spring-addons-7.8.8 - - - - - org.springframework - spring-context - - - org.springframework.boot - spring-boot - - - org.springframework.boot - spring-boot-autoconfigure - - - org.springframework.boot - spring-boot-starter-webflux - - - - org.slf4j - slf4j-api - - - org.projectlombok - lombok - true - - - - org.springframework.boot - spring-boot-configuration-processor - true - - - - org.springframework.boot - spring-boot-starter-test - test - - - diff --git a/starters/spring-addons-starters-webclient/src/main/java/com/c4_soft/springaddons/starter/webclient/C4ProxySettings.java b/starters/spring-addons-starters-webclient/src/main/java/com/c4_soft/springaddons/starter/webclient/C4ProxySettings.java deleted file mode 100644 index a00fe0c08..000000000 --- a/starters/spring-addons-starters-webclient/src/main/java/com/c4_soft/springaddons/starter/webclient/C4ProxySettings.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.c4_soft.springaddons.starter.webclient; - -import java.net.MalformedURLException; -import java.net.URL; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; - -import lombok.AccessLevel; -import lombok.Data; -import lombok.Getter; -import reactor.netty.transport.ProxyProvider; - -/** - *

- * Configuration for HTTP or SOCKS proxy. - *

- *

- * HTTP_PROXY and NO_PROXY standard environment variable are used only if com.c4-soft.springaddons.proxy.hostname is left empty and - * com.c4-soft.springaddons.proxy.enabled is TRUE or null. - *

- * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> - */ -@Data -@Component -@ConfigurationProperties(prefix = "com.c4-soft.springaddons.proxy") -public class C4ProxySettings { - private Boolean enabled; - private ProxyProvider.Proxy type = ProxyProvider.Proxy.HTTP; - @Getter(AccessLevel.NONE) - private Optional host; - private Integer port; - private String username; - private String password; - @Getter(AccessLevel.NONE) - private String nonProxyHostsPattern; - private long connectTimeoutMillis = 10000; - - /* also parse standard environment variables */ - @Getter(AccessLevel.NONE) - private Optional httpProxy; - - @Getter(AccessLevel.NONE) - @Value("${no_proxy:#{T(java.util.List).of()}}") - private List noProxy = List.of(); - - @Value("${com.c4-soft.springaddons.proxy.host:#{null}}") - public void setHost(String host) { - this.host = StringUtils.hasText(host) ? Optional.of(host) : Optional.empty(); - } - - @Value("${http_proxy:#{null}}") - public void setHttpProxy(String url) throws MalformedURLException { - this.httpProxy = StringUtils.hasText(url) ? Optional.of(new URL(url)) : Optional.empty(); - } - - public String getHostname() { - if (Boolean.FALSE.equals(enabled)) { - return null; - } - return host.orElse(httpProxy.map(URL::getHost).orElse(null)); - } - - public ProxyProvider.Proxy getType() { - if (Boolean.FALSE.equals(enabled)) { - return null; - } - return host.map(h -> type).orElse(httpProxy.map(URL::getProtocol).map(C4ProxySettings::getProtocoleType).orElse(null)); - } - - public Integer getPort() { - if (Boolean.FALSE.equals(enabled)) { - return null; - } - return host.map(h -> port).orElse(httpProxy.map(URL::getPort).orElse(null)); - } - - public String getUsername() { - if (Boolean.FALSE.equals(enabled)) { - return null; - } - return host.map(h -> username).orElse(httpProxy.map(URL::getUserInfo).map(C4ProxySettings::getUserinfoName).orElse(null)); - } - - public String getPassword() { - if (Boolean.FALSE.equals(enabled)) { - return null; - } - return host.map(h -> password).orElse(httpProxy.map(URL::getUserInfo).map(C4ProxySettings::getUserinfoPassword).orElse(null)); - } - - public String getNoProxy() { - if (Boolean.FALSE.equals(enabled)) { - return null; - } - return host.map(h -> nonProxyHostsPattern).orElse(getNonProxyHostsPattern(noProxy)); - } - - static ProxyProvider.Proxy getProtocoleType(String protocol) { - if (protocol == null) { - return null; - } - final var lower = protocol.toLowerCase(); - if (lower.startsWith("http")) { - return ProxyProvider.Proxy.HTTP; - } - if (lower.startsWith("socks4")) { - return ProxyProvider.Proxy.SOCKS4; - } - return ProxyProvider.Proxy.SOCKS5; - } - - static String getUserinfoName(String userinfo) { - if (userinfo == null) { - return null; - } - return userinfo.split(":")[0]; - } - - static String getUserinfoPassword(String userinfo) { - if (userinfo == null) { - return null; - } - final var splits = userinfo.split(":"); - return splits.length < 2 ? null : splits[1]; - } - - static String getNonProxyHostsPattern(List noProxy) { - if (noProxy == null || noProxy.isEmpty()) { - return null; - } - return noProxy.stream().map(host -> host.replace(".", "\\.")).map(host -> host.replace("-", "\\-")) - .map(host -> host.startsWith("\\.") ? ".*" + host : host).collect(Collectors.joining(")|(", "(", ")")); - } -} diff --git a/starters/spring-addons-starters-webclient/src/main/java/com/c4_soft/springaddons/starter/webclient/C4WebClientBuilderFactoryService.java b/starters/spring-addons-starters-webclient/src/main/java/com/c4_soft/springaddons/starter/webclient/C4WebClientBuilderFactoryService.java deleted file mode 100644 index 24fb567ef..000000000 --- a/starters/spring-addons-starters-webclient/src/main/java/com/c4_soft/springaddons/starter/webclient/C4WebClientBuilderFactoryService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.c4_soft.springaddons.starter.webclient; - -import java.net.URL; -import java.util.Optional; - -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; -import org.springframework.web.reactive.function.client.WebClient; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import reactor.netty.http.client.HttpClient; - -/** - * @author Jérôme Wacongne ch4mp@c4-soft.com - */ -@Service -@Slf4j -@RequiredArgsConstructor -public class C4WebClientBuilderFactoryService { - - private final C4ProxySettings settings; - - public WebClient.Builder get() { - return get(null); - } - - public WebClient.Builder get(URL baseUrl) { - final var builder = WebClient.builder(); - Optional.ofNullable(baseUrl).map(URL::toString).ifPresent(builder::baseUrl); - if (Boolean.FALSE.equals(settings.getEnabled()) || !StringUtils.hasText(settings.getHostname())) { - return builder; - } - log.debug("Building ReactorClientHttpConnector with {}", settings); - final var connector = new ReactorClientHttpConnector( - HttpClient.create().proxy( - proxy -> proxy.type(settings.getType()).host(settings.getHostname()).port(settings.getPort()).username(settings.getUsername()) - .password(username -> settings.getPassword()).nonProxyHosts(settings.getNoProxy()) - .connectTimeoutMillis(settings.getConnectTimeoutMillis()))); - - return builder.clientConnector(connector); - } -} diff --git a/starters/spring-addons-starters-webclient/src/main/java/com/c4_soft/springaddons/starter/webclient/SpringBootAutoConfiguration.java b/starters/spring-addons-starters-webclient/src/main/java/com/c4_soft/springaddons/starter/webclient/SpringBootAutoConfiguration.java deleted file mode 100644 index 53b0acd89..000000000 --- a/starters/spring-addons-starters-webclient/src/main/java/com/c4_soft/springaddons/starter/webclient/SpringBootAutoConfiguration.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.c4_soft.springaddons.starter.webclient; - -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.context.annotation.Import; - -@AutoConfiguration -@Import({ C4ProxySettings.class, C4WebClientBuilderFactoryService.class }) -public class SpringBootAutoConfiguration { -} diff --git a/starters/spring-addons-starters-webclient/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/starters/spring-addons-starters-webclient/src/main/resources/META-INF/additional-spring-configuration-metadata.json deleted file mode 100644 index 2b2d45424..000000000 --- a/starters/spring-addons-starters-webclient/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "groups": [ - { - "name": "com.c4-soft.springaddons.proxy", - "type": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "description": "Proxy settings set to org.springframework.http.client.reactive.ReactorClientHttpConnector injected into built org.springframework.web.reactive.function.client.WebClient" - } - ], - "properties": [ - { - "name": "com.c4-soft.springaddons.proxy.enabled", - "type": "java.lang.Boolean", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "defaultValue": null, - "description": "If false, WebClient proxy configuration is disabled" - }, - { - "name": "com.c4-soft.springaddons.proxy.connect-timeout-millis", - "type": "java.lang.Long", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "defaultValue": 10000, - "description": "Delay in ms to connect to proxy before timeout" - }, - { - "name": "com.c4-soft.springaddons.proxy.host", - "type": "java.lang.String", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "description": "The proxy host to connect to." - }, - { - "name": "com.c4-soft.springaddons.proxy.non-proxy-hosts-pattern", - "type": "java.lang.String", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "description": "Regular expression (using java.util.regex) for a configuredlist of hosts that should be reached directly, bypassing the proxy." - }, - { - "name": "com.c4-soft.springaddons.proxy.password", - "type": "java.lang.String", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "description": "The proxy password for provided username." - }, - { - "name": "com.c4-soft.springaddons.proxy.port", - "type": "java.lang.Short", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "description": "The proxy port." - }, - { - "name": "com.c4-soft.springaddons.proxy.type", - "type": "reactor.netty.transport.ProxyProvider$Proxy", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "defaultValue": "reactor.netty.transport.ProxyProvider.Proxy.HTTP", - "description": "The proxy type." - }, - { - "name": "com.c4-soft.springaddons.proxy.username", - "type": "java.lang.String", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "description": "The proxy username." - }, - { - "name": "http_proxy", - "type": "java.lang.String", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "description": "The complete proxy URL as used in standard HTTP_PROXY environment variable." - }, - { - "name": "no_proxy", - "type": "java.lang.String", - "sourceType": "com.c4_soft.springaddons.starter.webclient.C4ProxySettings", - "description": "A list of hosts / domains for which a direct connection should be applied. The format is NO_PROXY standard environment variable one." - } - ], - "hints": [ - { - "name": "com.c4-soft.springaddons.proxy.non-proxy-hosts-pattern", - "values": [ - { - "value": "(localhost)|(bravo\\-ch4mp)|(.*\\.corporate\\-domain\\.com)", - "description": "Regular expression (using java.util.regex) for a configuredlist of hosts that should be reached directly, bypassing the proxy." - } - ] - }, - { - "name": "http_proxy", - "values": [ - { - "value": "http://username:password@proxy-host:8080", - "description": "Full URL with protocol, username, password and port" - } - ] - }, - { - "name": "no_proxy", - "values": [ - { - "value": "host.corporate.com", - "description": "Exact match on domain / host" - }, - { - "value": ".corporate.com", - "description": "All sub-domains / hosts" - } - ] - } - ] -} \ No newline at end of file diff --git a/starters/spring-addons-starters-webclient/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/starters/spring-addons-starters-webclient/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index 245be42dc..000000000 --- a/starters/spring-addons-starters-webclient/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -com.c4_soft.springaddons.starter.webclient.SpringBootAutoConfiguration \ No newline at end of file diff --git a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/AddonsTest.java b/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/AddonsTest.java deleted file mode 100644 index 810f1b414..000000000 --- a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/AddonsTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.c4_soft.springaddons.starter.webclient; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -import reactor.netty.transport.ProxyProvider; - -@SpringBootTest -@ActiveProfiles("addons") -class AddonsTest { - - @Autowired - C4ProxySettings settings; - - @Autowired - C4WebClientBuilderFactoryService service; - - @Test - void testSettings() { - assertEquals(500, settings.getConnectTimeoutMillis()); - assertNull(settings.getEnabled()); - assertEquals("corp-proxy", settings.getHostname()); - assertEquals("(localhost)|(bravo\\-ch4mp)|(.*\\.corporate\\-domain\\.com)", settings.getNoProxy()); - assertEquals("abracadabra", settings.getPassword()); - assertEquals(8080, settings.getPort()); - assertEquals(ProxyProvider.Proxy.SOCKS5, settings.getType()); - assertEquals("toto", settings.getUsername()); - } - - @Test - void testService() { - final var actual = service.get(); - assertNotNull(actual); - } - -} diff --git a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/HostPortTest.java b/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/HostPortTest.java deleted file mode 100644 index 93bb16c94..000000000 --- a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/HostPortTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.c4_soft.springaddons.starter.webclient; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -import reactor.netty.transport.ProxyProvider; - -@SpringBootTest -@ActiveProfiles("host-port") -class HostPortTest { - - @Autowired - C4ProxySettings settings; - - @Autowired - C4WebClientBuilderFactoryService service; - - @Test - void testSettings() { - assertEquals(10000, settings.getConnectTimeoutMillis()); - assertNull(settings.getEnabled()); - assertEquals("mini-proxy", settings.getHostname()); - assertNull(settings.getNoProxy()); - assertNull(settings.getPassword()); - assertEquals(7080, settings.getPort()); - assertEquals(ProxyProvider.Proxy.HTTP, settings.getType()); - assertNull(settings.getUsername()); - } - - @Test - void testService() { - final var actual = service.get(); - assertNotNull(actual); - } - -} diff --git a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/NoPropertiesTest.java b/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/NoPropertiesTest.java deleted file mode 100644 index 05e19cad9..000000000 --- a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/NoPropertiesTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.c4_soft.springaddons.starter.webclient; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class NoPropertiesTest { - - @Autowired - C4ProxySettings settings; - - @Autowired - C4WebClientBuilderFactoryService service; - - @Test - void testSettings() { - assertEquals(10000, settings.getConnectTimeoutMillis()); - assertNull(settings.getEnabled()); - assertNull(settings.getHostname()); - assertNull(settings.getNoProxy()); - assertNull(settings.getPassword()); - assertNull(settings.getPort()); - assertNull(settings.getType()); - assertNull(settings.getUsername()); - } - - @Test - void testService() { - final var actual = service.get(); - assertNotNull(actual); - } - -} diff --git a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/StdEnvVarsDisabledProxyTest.java b/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/StdEnvVarsDisabledProxyTest.java deleted file mode 100644 index aeaf8fd4c..000000000 --- a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/StdEnvVarsDisabledProxyTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.c4_soft.springaddons.starter.webclient; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles({ "std-env-vars", "disabled-proxy" }) -class StdEnvVarsDisabledProxyTest { - - @Autowired - C4ProxySettings settings; - - @Autowired - C4WebClientBuilderFactoryService service; - - @Test - void testSettings() { - assertEquals(10000, settings.getConnectTimeoutMillis()); - assertEquals(Boolean.FALSE, settings.getEnabled()); - assertNull(settings.getHostname()); - assertNull(settings.getNoProxy()); - assertNull(settings.getPassword()); - assertNull(settings.getPort()); - assertNull(settings.getType()); - assertNull(settings.getUsername()); - } - - @Test - void testService() { - final var actual = service.get(); - assertNotNull(actual); - } - -} diff --git a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/StdEnvVarsTest.java b/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/StdEnvVarsTest.java deleted file mode 100644 index bb3d0a400..000000000 --- a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/StdEnvVarsTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.c4_soft.springaddons.starter.webclient; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -import reactor.netty.transport.ProxyProvider; - -@SpringBootTest -@ActiveProfiles("std-env-vars") -class StdEnvVarsTest { - - @Autowired - C4ProxySettings settings; - - @Autowired - C4WebClientBuilderFactoryService service; - - @Test - void testSettings() { - assertEquals(10000, settings.getConnectTimeoutMillis()); - assertNull(settings.getEnabled()); - assertEquals("env-proxy", settings.getHostname()); - assertEquals("(localhost)|(bravo\\-ch4mp)|(.*\\.env\\-domain\\.pf)", settings.getNoProxy()); - assertEquals("truc", settings.getPassword()); - assertEquals(8080, settings.getPort()); - assertEquals(ProxyProvider.Proxy.HTTP, settings.getType()); - assertEquals("machin", settings.getUsername()); - } - - @Test - void testService() { - final var actual = service.get(); - assertNotNull(actual); - } - -} diff --git a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/WebClientSampleApp.java b/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/WebClientSampleApp.java deleted file mode 100644 index 766259886..000000000 --- a/starters/spring-addons-starters-webclient/src/test/java/com/c4_soft/springaddons/starter/webclient/WebClientSampleApp.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.c4_soft.springaddons.starter.webclient; - -import java.net.MalformedURLException; -import java.net.URL; - -import org.springframework.boot.WebApplicationType; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.builder.SpringApplicationBuilder; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import lombok.RequiredArgsConstructor; -import reactor.core.publisher.Mono; - -@SpringBootApplication -public class WebClientSampleApp { - public static void main(String[] args) { - new SpringApplicationBuilder(WebClientSampleApp.class).web(WebApplicationType.REACTIVE).run(args); - } - - @RestController - @RequestMapping("/sample") - @RequiredArgsConstructor - public static class SampleController { - private final C4WebClientBuilderFactoryService webClientBuilderFactory; - - @GetMapping("/delegating") - public Mono calling() throws MalformedURLException { - return webClientBuilderFactory.get(new URL("http://localhost:8080")).build().get().uri("/sample/delegate").retrieve().bodyToMono(String.class); - } - - @GetMapping("/delegate") - public Mono remote() { - return Mono.just("Hello!"); - } - } -} diff --git a/starters/spring-addons-starters-webclient/src/test/resources/application.properties b/starters/spring-addons-starters-webclient/src/test/resources/application.properties deleted file mode 100644 index 6ed717db3..000000000 --- a/starters/spring-addons-starters-webclient/src/test/resources/application.properties +++ /dev/null @@ -1,25 +0,0 @@ -server.ssl.enabled=false - -#--- -spring.config.activate.on-profile=host-port -com.c4-soft.springaddons.proxy.host=mini-proxy -com.c4-soft.springaddons.proxy.port=7080 - -#--- -spring.config.activate.on-profile=addons -com.c4-soft.springaddons.proxy.type=socks5 -com.c4-soft.springaddons.proxy.host=corp-proxy -com.c4-soft.springaddons.proxy.port=8080 -com.c4-soft.springaddons.proxy.username=toto -com.c4-soft.springaddons.proxy.password=abracadabra -com.c4-soft.springaddons.proxy.nonProxyHostsPattern=(localhost)|(bravo\\-ch4mp)|(.*\\.corporate\\-domain\\.com) -com.c4-soft.springaddons.proxy.connect-timeout-millis=500 - -#--- -spring.config.activate.on-profile=disabled-proxy -com.c4-soft.springaddons.proxy.enabled=false - -#--- -spring.config.activate.on-profile=std-env-vars -http_proxy=https://machin:truc@env-proxy:8080 -no_proxy=localhost,bravo-ch4mp,.env-domain.pf \ No newline at end of file