From e549331c28a6721c00593b46fa062a3d409a75e6 Mon Sep 17 00:00:00 2001 From: ch4mpy Date: Wed, 27 Nov 2024 19:25:43 -1000 Subject: [PATCH] Refresh tutorials READMEs --- .../src/main/resources/application.yml | 3 +- .../README.md | 142 +++---- .../tutorials/SecurityConfig.java | 10 +- .../README.md | 69 ++-- .../src/main/resources/application.yml | 12 +- .../README.md | 53 +-- .../README.md | 373 +++++++----------- .../tutorials/ProxiesAuthentication.java | 5 +- .../c4soft/springaddons/tutorials/Proxy.java | 55 ++- .../tutorials/SecurityConfig.java | 4 +- .../resource-server_with_ui/README.md | 230 ++++------- .../src/main/resources/application.yml | 20 +- 12 files changed, 372 insertions(+), 604 deletions(-) diff --git a/samples/tutorials/resource-server_multitenant_dynamic/src/main/resources/application.yml b/samples/tutorials/resource-server_multitenant_dynamic/src/main/resources/application.yml index f347a02da..0e6490a0f 100644 --- a/samples/tutorials/resource-server_multitenant_dynamic/src/main/resources/application.yml +++ b/samples/tutorials/resource-server_multitenant_dynamic/src/main/resources/application.yml @@ -1,5 +1,6 @@ scheme: http keycloak-port: 8080 +keycloak-host: ${scheme}://localhost:${keycloak-port} server: error: @@ -17,7 +18,7 @@ com: springaddons: oidc: ops: - - iss: ${scheme}://localhost:${keycloak-port} + - iss: ${keycloak-host} username-claim: preferred_username authorities: - path: $.realm_access.roles diff --git a/samples/tutorials/resource-server_with_additional-header/README.md b/samples/tutorials/resource-server_with_additional-header/README.md index e38324c20..04ba7d5dc 100644 --- a/samples/tutorials/resource-server_with_additional-header/README.md +++ b/samples/tutorials/resource-server_with_additional-header/README.md @@ -16,8 +16,8 @@ Following dependencies will be needed: - Lombok Then add dependencies to: -- [`spring-addons-webmvc-jwt-resource-server`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-jwt-resource-server/6.1.5) -- [`spring-addons-webmvc-jwt-test`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-jwt-test/6.1.5) with `test` scope +- [`spring-addons-starter-oidc`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc) +- [`spring-addons-starter-oidc-test`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc-test) with `test` scope ```xml com.c4-soft.springaddons @@ -35,127 +35,91 @@ Then add dependencies to: If for whatever reason you don't want to use `spring-addons-starter-oidc`, see [`servlet-resource-server`](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials/servlet-resource-server) or [`reactive-resource-server`](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials/reactive-resource-server) for basic configuration with `spring-boot-starter-oauth2-resource-server`. Spoiler, it is quite more verbose and error-prone. ## 3. Web-Security Configuration -This configuration will use the pretty convenient [`com.c4_soft.springaddons.security.oauth2.config.synchronised.HttpServletRequestSupport`](https://github.com/ch4mpy/spring-addons/blob/master/webmvc/spring-addons-webmvc-core/src/main/java/com/c4_soft/springaddons/security/oauth2/config/synchronised/HttpServletRequestSupport.java) which provides tooling to access the current request, and in our case, its headers. If we were writing a WebFlux application, we'd use is reactive equivalent: [`com.c4_soft.springaddons.security.oauth2.config.reactive.ServerHttpRequestSupport`](https://github.com/ch4mpy/spring-addons/blob/master/webflux/spring-addons-webflux-core/src/main/java/com/c4_soft/springaddons/security/oauth2/config/reactive/ServerHttpRequestSupport.java). If you don't use `spring-addons-starter-oidc`, you might need to copy some code from one of this support classes. +This configuration will use the pretty convenient [`HttpServletRequestSupport`](https://github.com/ch4mpy/spring-addons/blob/master/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/HttpServletRequestSupport.java) which provides tooling to access the current request, and in our case, its headers. If we were writing a WebFlux application, we'd use is reactive equivalent: [`ServerHttpRequestSupport`](https://github.com/ch4mpy/spring-addons/blob/master/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ServerHttpRequestSupport.java). If you don't use `spring-addons-starter-oidc`, you might need to copy some code from one of this support classes. `spring-oauth2-addons` comes with `@AutoConfiguration` for web-security config adapted to REST API projects. We'll just add: - `@EnableMethodSecurity` to activate `@PreAuthorize` on components methods. -- create an authentication of our own designed to hold ID token string and claims in addition to access token ones: `MyAuth` - +- an authentication of our own designed to hold ID token string and claims in addition to access token ones: ```java @Data @EqualsAndHashCode(callSuper = true) -public static class MyAuth extends OAuthentication { - private static final long serialVersionUID = 1734079415899000362L; - private final String idTokenString; - private final OpenidClaimSet idClaims; - - public MyAuth(Collection authorities, String accessTokenString, - OpenidClaimSet accessClaims, String idTokenString, OpenidClaimSet idClaims) { - super(accessClaims, authorities, accessTokenString); - this.idTokenString = idTokenString; - this.idClaims = idClaims; - } +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(new OpenidToken(accessClaims, accessTokenString), authorities); + this.idToken = new OpenidToken(idClaims, idTokenString); + } } ``` -- provide a `JwtAbstractAuthenticationTokenConverter` bean to switch `Authentication` implementation from `JwtAuthenticationToken` to our `MyAuth` - +- a `JwtAbstractAuthenticationTokenConverter` bean to switch `Authentication` implementation from `JwtAuthenticationToken` to `MyAuth` +```java +@Bean +JwtAbstractAuthenticationTokenConverter authenticationConverter( + // Inject a converter to turn token claims into Spring authorities. A default one is provided by spring-addons-starter-oidc, if you haven't define one + Converter, Collection> authoritiesConverter) { + return jwt -> { + try { + // Resolve the JWT decoder based on token claims (more on that below) + 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); + } + }; +} +``` +- a cash for ID tokens JWT decoders (instantiate only one decoder per ID token issuer). For that, we add the following to the configuration class: ```java -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); -} - -@BeanJwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter(Converter, Collection> authoritiesConverter) { - return jwt -> { - try { - final var jwtDecoder = getJwtDecoder(jwt.getClaims()); - final var authorities = authoritiesConverter.convert(jwt.getClaims()); - final var idTokenString = HttpServletRequestSupport.getUniqueHeader(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); - } - }; + 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); } ``` -If you don't use `spring-addons-starter-oidc`, you'll have to provide the authentication converter (or an authentication manager resolver) when configuring `http.resourceServer()`. - ## 4. Application Properties Nothing really special here, just the usual Spring Boot and spring-addons configuration (accepting identities from 3 different trusted issuers): ```yaml -scheme: http -origins: ${scheme}://localhost:4200 -keycloak-port: 8442 -keycloak-issuer: ${scheme}://localhost:${keycloak-port}/realms/master -cognito-issuer: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl -auth0-issuer: https://dev-ch4mpy.eu.auth0.com/ - -server: - error: - include-message: always - ssl: - enabled: false - -spring: - lifecycle: - timeout-per-shutdown-phase: 30s - com: c4-soft: springaddons: oidc: - cors: - - path: /** - allowed-origins: ${origins} - issuers: - - location: ${keycloak-issuer} + ops: + - iss: ${keycloak-issuer} username-claim: preferred_username authorities: - path: $.realm_access.roles - path: $.resource_access.*.roles - - location: ${cognito-issuer} + - iss: ${cognito-issuer} username-claim: username authorities: - path: cognito:groups - - location: ${auth0-issuer} + - iss: ${auth0-issuer} username-claim: $['https://c4-soft.com/user']['name'] authorities: - path: $['https://c4-soft.com/user']['roles'] - path: $.permissions - ---- -scheme: https -keycloak-port: 8443 - -server: - ssl: - enabled: true - -spring: - config: - activate: - on-profile: ssl ``` ## 5. Sample `@RestController` 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 bec4c9e70..f756f1efd 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 @@ -70,11 +70,11 @@ JwtAbstractAuthenticationTokenConverter authenticationConverter( @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 + 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 diff --git a/samples/tutorials/resource-server_with_introspection/README.md b/samples/tutorials/resource-server_with_introspection/README.md index 155cb3c05..2c12759ce 100644 --- a/samples/tutorials/resource-server_with_introspection/README.md +++ b/samples/tutorials/resource-server_with_introspection/README.md @@ -26,26 +26,22 @@ Following dependencies will be needed: - Lombok Then add dependencies to spring-addons: -- [`spring-addons-webmvc-jwt-resource-server`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-jwt-resource-server/6.1.5) -- [`spring-addons-webmvc-jwt-test`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-jwt-test/6.1.5) with `test` scope +- [`spring-addons-starter-oidc`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc) +- [`spring-addons-starter-oidc-test`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc-test) with `test` scope ```xml com.c4-soft.springaddons - - spring-addons-webmvc-introspecting-resource-server + spring-addons-starter-oidc ${spring-addons.version} com.c4-soft.springaddons - - spring-addons-webmvc-introspecting-test + spring-addons-starter-oidc-test ${spring-addons.version} test ``` -If for whatever reason you don't want to use `spring-addons-starter-oidc`, see [`servlet-resource-server`](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials/servlet-resource-server) or [`reactive-resource-server`](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials/reactive-resource-server) for basic configuration with `spring-boot-starter-oauth2-resource-server`. Spoiler, it is quite more verbose and error-prone. - ## 5. Application Properties Let's first define some constants later used in configuration, and sometimes overridden in profiles: ```yaml @@ -85,20 +81,21 @@ Next is some spring-addons configuration with: com: c4-soft: springaddons: - security: - cors: - - path: /** - allowed-origins: ${origins} - issuers: - - location: ${keycloak-issuer} + oidc: + ops: + - iss: ${keycloak-issuer} username-claim: preferred_username authorities: - path: $.realm_access.roles - path: $.resource_access.*.roles - permit-all: - - "/actuator/health/readiness" - - "/actuator/health/liveness" - - "/v3/api-docs/**" + resourceserver: + cors: + - path: /** + allowed-origin-patterns: ${origins} + permit-all: + - "/actuator/health/readiness" + - "/actuator/health/liveness" + - "/v3/api-docs/**" ``` Last is profile to enable SSL on this server and when talking to the local Keycloak instance: ```yaml @@ -118,28 +115,7 @@ spring: ``` ## 4. Web-Security Configuration -`spring-addons-webmvc-introspecting-resource-server` auto-configures a security filter-chain for resource server with token introspection and there is nothing we have to do beyond activating method security. - -Optionally, we can switch the type of `Authentication` at runtime by providing an`OpaqueTokenAuthenticationConverter`. To press on the fact that this bean is not mandatory, we'll activate it with a profile: -```java -@Configuration -@EnableMethodSecurity -public class WebSecurityConfig { - - @Bean - @Profile("oauthentication") - //This bean is optional as a default one is provided (building a BearerTokenAuthentication) - OpaqueTokenAuthenticationConverter introspectionAuthenticationConverter( - Converter, Collection> authoritiesConverter) { - return (String introspectedToken, - OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> new OAuthentication<>( - new OpenidClaimSet(authenticatedPrincipal.getAttributes()), - authoritiesConverter.convert(authenticatedPrincipal.getAttributes()), - introspectedToken); - } -} -``` -The reasons why we could prefer `OAuthentication` over `BearerTokenAuthentication` are its improved API to access OpenID claims and its versatility (compatible with JWT decoding too). +`spring-addons-starter-oidc` auto-configures a security filter-chain for resource server with token introspection and there is nothing we have to do beyond activating method security. ## 6. Non-Standard Introspection Endpoint The token introspection we have works just fine with OIDC Providers exposing an `introspection_endpoint` in their OpenID configuration (like Keycloak does), but some just don't provide one (like Auth0 and Amazon Cognito). Hopefully, almost any OP exposes a `/userinfo` endpoint returning the OpenID claims of the user for whom was issued the access token in the Authorization header. @@ -151,9 +127,9 @@ Let's first define spring profiles with `introspection-uri` settled with userinf com: c4-soft: springaddons: - security: - issuers: - - location: ${auth0-issuer} + oidc: + ops: + - iss: ${auth0-issuer} username-claim: $['https://c4-soft.com/user']['name'] authorities: - path: $['https://c4-soft.com/user']['roles'] @@ -173,9 +149,9 @@ spring: com: c4-soft: springaddons: - security: - issuers: - - location: ${cognito-issuer} + oidc: + ops: + - iss: ${cognito-issuer} username-claim: $.username authorities: - path: $.cognito:groups @@ -219,6 +195,7 @@ public static class UserEndpointOpaqueTokenIntrospector implements OpaqueTokenIn } ``` +Exposing such an `OpaqueTokenIntrospector` and exposing it as a bean is enough with `spring-addons-starter-oidc` which will pick it instead of defining a default one. ## 7. Sample `@RestController` ``` java diff --git a/samples/tutorials/resource-server_with_introspection/src/main/resources/application.yml b/samples/tutorials/resource-server_with_introspection/src/main/resources/application.yml index 3d7ecef88..53c12e14d 100644 --- a/samples/tutorials/resource-server_with_introspection/src/main/resources/application.yml +++ b/samples/tutorials/resource-server_with_introspection/src/main/resources/application.yml @@ -69,9 +69,9 @@ management: com: c4-soft: springaddons: - security: - issuers: - - location: ${auth0-issuer} + oidc: + ops: + - iss: ${auth0-issuer} username-claim: $['https://c4-soft.com/user']['name'] authorities: - path: $['https://c4-soft.com/user']['roles'] @@ -91,9 +91,9 @@ spring: com: c4-soft: springaddons: - security: - issuers: - - location: ${cognito-issuer} + oidc: + ops: + - iss: ${cognito-issuer} username-claim: $.username authorities: - path: $.cognito:groups diff --git a/samples/tutorials/resource-server_with_oauthentication/README.md b/samples/tutorials/resource-server_with_oauthentication/README.md index 2375c5703..81e560286 100644 --- a/samples/tutorials/resource-server_with_oauthentication/README.md +++ b/samples/tutorials/resource-server_with_oauthentication/README.md @@ -16,19 +16,17 @@ Following dependencies will be needed: - Lombok Then add dependencies to spring-addons: -- [`spring-addons-webmvc-jwt-resource-server`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-jwt-resource-server/6.1.5) -- [`spring-addons-webmvc-jwt-test`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-jwt-test/6.1.5) +- [`spring-addons-starter-oidc`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc) +- [`spring-addons-starter-oidc-test`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc-test) with `test` scope ```xml com.c4-soft.springaddons - - spring-addons-webmvc-jwt-resource-server + spring-addons-starter-oidc ${spring-addons.version} com.c4-soft.springaddons - - spring-addons-webmvc-jwt-test + spring-addons-starter-oidc-test ${spring-addons.version} test @@ -37,18 +35,24 @@ Then add dependencies to spring-addons: ## 3. Web-Security Configuration `spring-oauth2-addons` comes with `@AutoConfiguration` for web-security config adapted to REST API projects. We'll just add: - `@EnableMethodSecurity` to activate `@PreAuthorize` on components methods. -- provide an `Converter` bean to switch `Authentication` implementation from `JwtAuthenticationToken` to `OAuthentication` +- provide a `JwtAbstractAuthenticationTokenConverter` bean to switch `Authentication` implementation from `JwtAuthenticationToken` to `OAuthentication` ```java -@Configuration -@EnableMethodSecurity -public static class SecurityConfig { - @Bean - Converter> authenticationFactory(Converter, Collection> authoritiesConverter) { - return jwt -> new OAuthentication<>(new OpenidClaimSet(jwt.getClaims()), - authoritiesConverter.convert(jwt.getClaims()), jwt.getTokenValue()); - } +@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); + }; } ``` +Here, we kept `spring-addons` default authorities converter in charge of extracting Spring authorities from token claims. This converter needs configuration properties resolved by an `OpenidProviderPropertiesResolver` (`spring-addons` default one resolves properties using by matching the token `iss` claim with the `iss` property in YAML. ## 4. Application Properties Most security configuration is controlled from properties. Please refer to [spring-addons starter introduction tutorial](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials/servlet-resource-server) for the details about the properties we set here: @@ -74,25 +78,28 @@ spring: com: c4-soft: springaddons: - security: - cors: - - path: /** - allowed-origins: ${origins} - issuers: - - location: ${keycloak-issuer} + oidc: + ops: + - iss: ${keycloak-issuer} username-claim: preferred_username authorities: - path: $.realm_access.roles - path: $.resource_access.*.roles - - location: ${cognito-issuer} + - iss: ${cognito-issuer} username-claim: username authorities: - path: cognito:groups - - location: ${auth0-issuer} + - iss: ${auth0-issuer} username-claim: $['https://c4-soft.com/user']['name'] authorities: - path: $['https://c4-soft.com/user']['roles'] - path: $.permissions + resourceserver: + permit-all: + - "/actuator/health/readiness" + - "/actuator/health/liveness" + - "/v3/api-docs/**" + - "/swagger-ui/**" --- scheme: https diff --git a/samples/tutorials/resource-server_with_specialized_oauthentication/README.md b/samples/tutorials/resource-server_with_specialized_oauthentication/README.md index b9ad203dd..b1f5c4dc6 100644 --- a/samples/tutorials/resource-server_with_specialized_oauthentication/README.md +++ b/samples/tutorials/resource-server_with_specialized_oauthentication/README.md @@ -18,28 +18,22 @@ Following dependencies will be needed: - lombok Then add dependencies to spring-addons: -- [`spring-addons-webmvc-jwt-resource-server`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-jwt-resource-server/6.1.5) -- [`spring-addons-webmvc-jwt-test`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-jwt-test/6.1.5) +- [`spring-addons-starter-oidc`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc) +- [`spring-addons-starter-oidc-test`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc-test) with `test` scope ```xml - - org.springframework.security - spring-security-config - com.c4-soft.springaddons - spring-addons-webmvc-jwt-resource-server + spring-addons-starter-oidc ${spring-addons.version} com.c4-soft.springaddons - spring-addons-webmvc-jwt-test + spring-addons-starter-oidc-test ${spring-addons.version} test ``` -Another option would be to use one of `com.c4-soft.springaddons` archetypes (for instance [`spring-addons-archetypes-webmvc-singlemodule`](https://github.com/ch4mpy/spring-addons/tree/master/archetypes/spring-addons-archetypes-webmvc-singlemodule) or [`spring-addons-archetypes-webflux-singlemodule`](https://github.com/ch4mpy/spring-addons/tree/master/archetypes/spring-addons-archetypes-webflux-singlemodule)) - ## 3. Web-Security Configuration ### 3.1. `ProxiesClaimSet` and `ProxiesAuthentication` @@ -47,57 +41,53 @@ Let's first define what a `Proxy` is: ```java @Data public class Proxy implements Serializable { - private static final long serialVersionUID = 8853377414305913148L; + private static final long serialVersionUID = 8853377414305913148L; - private final String proxiedUsername; - private final String tenantUsername; - private final Set permissions; + private final String proxiedUsername; + private final String tenantUsername; + private final Set permissions; - public Proxy(String proxiedUsername, String tenantUsername, Collection permissions) { - this.proxiedUsername = proxiedUsername; - this.tenantUsername = tenantUsername; - this.permissions = Collections.unmodifiableSet(new HashSet<>(permissions)); - } + public Proxy(String proxiedUsername, String tenantUsername, Collection permissions) { + this.proxiedUsername = proxiedUsername; + this.tenantUsername = tenantUsername; + this.permissions = Collections.unmodifiableSet(new HashSet<>(permissions)); + } - public boolean can(String permission) { - return permissions.contains(permission); - } + public boolean can(String permission) { + return permissions.contains(permission); + } } ``` -Now, we'll extend `OpenidClaimSet` to add `proxies` private-claim parsing +Now, we'll extend `OpenidToken` to add `proxies` private-claim parsing ```java @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())); +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(); } - - private static final Converter> proxiesConverter = claims -> { - if (claims == null) { - return Map.of(); - } - @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)); - }; + return proxiesClaim.entrySet().stream() + .map(e -> new Proxy(e.getKey(), claims.getPreferredUsername(), e.getValue())) + .collect(Collectors.toMap(Proxy::getProxiedUsername, p -> p)); + }; } ``` And finally extend `OAuthentication` to @@ -106,32 +96,26 @@ And finally extend `OAuthentication` to ```java @Data @EqualsAndHashCode(callSuper = true) -public class ProxiesAuthentication extends OAuthentication { - private static final long serialVersionUID = -6247121748050239792L; +public class ProxiesAuthentication extends OAuthentication { + private static final long serialVersionUID = 447991554788295331L; - public ProxiesAuthentication(ProxiesClaimSet claims, Collection authorities, String tokenString) { - super(claims, authorities, tokenString); - } + public ProxiesAuthentication(ProxiesToken token, + Collection authorities) { + super(token, authorities); + } - @Override - public String getName() { - return super.getClaims().getPreferredUsername(); - } - - public boolean hasName(String username) { - return Objects.equals(getName(), username); - } - - public Proxy getProxyFor(String username) { - return getClaims().getProxyFor(username); - } + public boolean hasName(String username) { + return Objects.equals(getName(), username); + } + public Proxy getProxyFor(String username) { + return getAttributes().getProxyFor(username); + } } ``` ### 3.2. Security @Beans -We'll rely on `spring-addons-webmvc-jwt-resource-server` `@AutoConfiguration` and just force authentication converter. -See [`AddonsSecurityBeans`](https://github.com/ch4mpy/spring-addons/blob/master/webmvc/spring-addons-webmvc-jwt-resource-server/src/main/java/com/c4_soft/springaddons/security/oauth2/config/synchronised/AddonsSecurityBeans.java) for provided `@Autoconfiguration` +We'll rely on `spring-addons-starter-oidc` `@AutoConfiguration` and just force authentication converter. We'll also extend security SpEL with a few methods to: - compare current user's username to provided one @@ -141,54 +125,69 @@ We'll also extend security SpEL with a few methods to: ```java @Configuration @EnableMethodSecurity -public class WebSecurityConfig { +public class SecurityConfig { + + @Bean + JwtAbstractAuthenticationTokenConverter authenticationConverter( + Converter, Collection> authoritiesConverter) { + return jwt -> { + final var token = new ProxiesToken(jwt.getClaims(), jwt.getTokenValue()); + return new ProxiesAuthentication(token, authoritiesConverter.convert(token)); + }; + } - @Bean - OAuth2ClaimsConverter claimsConverter() { - return claims -> new ProxiesClaimSet(claims); - } + @Bean + static MethodSecurityExpressionHandler methodSecurityExpressionHandler() { + return new SpringAddonsMethodSecurityExpressionHandler( + ProxiesMethodSecurityExpressionRoot::new); + } - @Bean - Converter authenticationFactory(Converter, Collection> authoritiesConverter) { - return jwt -> { - final var claimSet = new ProxiesClaimSet(jwt.getClaims()); - return new ProxiesAuthentication(claimSet, authoritiesConverter.convert(claimSet), jwt.getTokenValue()); - }; - } + static final class ProxiesMethodSecurityExpressionRoot + extends SpringAddonsMethodSecurityExpressionRoot { - @Bean - static MethodSecurityExpressionHandler methodSecurityExpressionHandler() { - return new C4MethodSecurityExpressionHandler(ProxiesMethodSecurityExpressionRoot::new); + public boolean is(String preferredUsername) { + return Objects.equals(preferredUsername, getAuthentication().getName()); } - static final class ProxiesMethodSecurityExpressionRoot extends C4MethodSecurityExpressionRoot { - - 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 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"); - } + public boolean isNice() { + return hasAnyAuthority("NICE", "SUPER_COOL"); } + } } ``` ### 3.3. Configuration Properties -`application.properties`: -``` -# shoud be set to where your authorization-server is -com.c4-soft.springaddons.security.issuers[0].location=https://localhost:8443/realms/master - -# shoud be configured with a list of private-claims this authorization-server puts user roles into -# below is default Keycloak conf for a `spring-addons` client with client roles mapper enabled -com.c4-soft.springaddons.security.issuers[0].authorities.claims=realm_access.roles,resource_access.spring-addons-public.roles,resource_access.spring-addons-confidential.roles - -# use IDE auto-completion or see SpringAddonsSecurityProperties javadoc for complete configuration properties list +`application.yml`: +```yaml +com: + c4-soft: + springaddons: + oidc: + ops: + - iss: ${keycloak-issuer} + username-claim: preferred_username + authorities: + - path: $.realm_access.roles + - path: $.resource_access.*.roles + - iss: ${cognito-issuer} + username-claim: username + authorities: + - path: cognito:groups + - iss: ${auth0-issuer} + username-claim: $['https://c4-soft.com/user']['name'] + authorities: + - path: $['https://c4-soft.com/user']['roles'] + - path: $.permissions + resourceserver: + cors: + - path: /** + allowed-origin-patterns: ${origins} + permit-all: + - "/greet/public" ``` ## 4. Sample `@RestController` @@ -200,18 +199,15 @@ Note the `@PreAuthorize("is(#username) or isNice() or onBehalfOf(#username).can( ``` java @RestController @RequestMapping("/greet") -@PreAuthorize("isAuthenticated()") public class GreetingController { @GetMapping() @PreAuthorize("hasAuthority('NICE')") public String getGreeting(ProxiesAuthentication auth) { - return String - .format( - "Hi %s! You are granted with: %s and can proxy: %s.", - auth.getClaims().getPreferredUsername(), - auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(", ", "[", "]")), - auth.getClaims().getProxies().keySet().stream().collect(Collectors.joining(", ", "[", "]"))); + return "Hi %s! You are granted with: %s and can proxy: %s.".formatted( + auth.getName(), + auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(", ", "[", "]")), + auth.getClaims().getProxies().keySet().stream().collect(Collectors.joining(", ", "[", "]"))); } @GetMapping("/public") @@ -221,87 +217,21 @@ public class GreetingController { @GetMapping("/on-behalf-of/{username}") @PreAuthorize("is(#username) or isNice() or onBehalfOf(#username).can('greet')") - public String getGreetingFor(@PathVariable("username") String username, Authentication auth) { - return String.format("Hi %s from %s!", username, auth.getName()); + public String getGreetingFor(@PathVariable(name = "username") String username, Authentication auth) { + return "Hi %s from %s!".formatted(username, auth.getName()); } } ``` ## 5. Unit-Tests -### 5.1. `@ProxiesAuth` -`@OpenId` populates test security-context with an instance of `OicAuthentication`. -Let's create a `@ProxiesAuth` annotation to inject an instance of `ProxiesAuthentication` instead (with configurable proxies) -```java -@Target({ ElementType.METHOD, ElementType.TYPE }) -@Retention(RetentionPolicy.RUNTIME) -@Inherited -@Documented -@WithSecurityContext(factory = ProxiesAuth.ProxiesAuthenticationFactory.class) -public @interface ProxiesAuth { - - @AliasFor("authorities") - String[] value() default {}; - - @AliasFor("value") - String[] authorities() default {}; - - OpenIdClaims claims() default @OpenIdClaims(); - - Proxy[] proxies() default {}; - - String bearerString() default "machin.truc.chose"; - - @AliasFor(annotation = WithSecurityContext.class) - TestExecutionEvent setupBefore() - - default TestExecutionEvent.TEST_METHOD; - - @Target({ ElementType.METHOD, ElementType.TYPE }) - @Retention(RetentionPolicy.RUNTIME) - public static @interface Proxy { - String onBehalfOf(); +The authentication factory behind `@WithJwt` uses the authentication converter in the security context if it finds any. - String[] can() default {}; - } - - public static final class ProxiesAuthenticationFactory extends AbstractAnnotatedAuthenticationBuilder { - @Override - public ProxiesAuthentication authentication(ProxiesAuth annotation) { - final var openidClaims = super.claims(annotation.claims()); - @SuppressWarnings("unchecked") - final var proxiesClaim = (HashMap>) openidClaims.getOrDefault("proxies", new HashMap<>()); - Stream.of(annotation.proxies()).forEach(proxy -> { - proxiesClaim.put(proxy.onBehalfOf(), Stream.of(proxy.can()).toList()); - }); - openidClaims.put("proxies", proxiesClaim); - - return new ProxiesAuthentication(new ProxiesClaimSet(openidClaims), super.authorities(annotation.authorities()), annotation.bearerString()); - } - } -} -``` - -### 5.2. Controller Unit-Tests +As we exposed ours as a bean, `@WithJwt` will populate the test security context with `ProxiesAuthentication` instances. But be careful that mutators from `spring-security-tests` (like `.jwt()`) wouldn't do so. ```java -package com.c4soft.springaddons.tutorials; - -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.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; - -import com.c4_soft.springaddons.security.oauth2.test.annotations.OpenIdClaims; -import com.c4_soft.springaddons.security.oauth2.test.mockmvc.MockMvcSupport; -import com.c4_soft.springaddons.security.oauth2.test.mockmvc.jwt.AutoConfigureAddonsSecurity; -import com.c4soft.springaddons.tutorials.ProxiesAuth.Proxy; - -@WebMvcTest(GreetingController.class) -@AutoConfigureAddonsSecurity -@Import({ WebSecurityConfig.class }) +@WebMvcTest(controllers = GreetingController.class) +@AutoConfigureAddonsWebmvcResourceServerSecurity +@Import({ SecurityConfig.class }) class GreetingControllerTest { @Autowired @@ -309,75 +239,68 @@ class GreetingControllerTest { // @formatter:off @Test + @WithAnonymousUser void givenRequestIsAnonymous_whenGreet_thenUnauthorized() throws Exception { - mockMvc - .get("/greet") - .andExpect(status().isUnauthorized()); + mockMvc.get("/greet") + .andExpect(status().isUnauthorized()); } @Test + @WithAnonymousUser void givenRequestIsAnonymous_whenGreetPublic_thenOk() throws Exception { - mockMvc - .get("/greet/public") - .andExpect(status().isOk()) - .andExpect(content().string("Hello world")); + mockMvc.get("/greet/public") + .andExpect(status().isOk()) + .andExpect(content().string("Hello world")); } @Test - @ProxiesAuth( - authorities = { "NICE", "AUTHOR" }, - claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"), - proxies = { - @Proxy(onBehalfOf = "machin", can = { "truc", "bidule" }), - @Proxy(onBehalfOf = "chose") }) + @WithJwt("ch4mp.json") void givenUserIsGrantedWithNice_whenGreet_thenOk() throws Exception { - mockMvc - .get("/greet") - .andExpect(status().isOk()) - .andExpect(content().string("Hi Tonton Pirate! You are granted with: [NICE, AUTHOR] and can proxy: [chose, machin].")); + mockMvc.get("/greet") + .andExpect(status().isOk()) + .andExpect(content().string("Hi ch4mp! You are granted with: [NICE, AUTHOR] and can proxy: [chose, machin].")); } @Test - @ProxiesAuth(authorities = { "AUTHOR" }) + @WithJwt("tonton_proxy_ch4mp.json") void givenUserIsNotGrantedWithNice_whenGreet_thenForbidden() throws Exception { - mockMvc.get("/greet").andExpect(status().isForbidden()); + mockMvc.get("/greet") + .andExpect(status().isForbidden()); } @Test - @ProxiesAuth( - authorities = { "AUTHOR" }, - claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"), - proxies = { @Proxy(onBehalfOf = "ch4mpy", can = { "greet" }) }) + @WithJwt("tonton_proxy_ch4mp.json") void givenUserIsNotGrantedWithNiceButHasProxyForGreetedUser_whenGreetOnBehalfOf_thenOk() throws Exception { - mockMvc.get("/greet/on-behalf-of/ch4mpy").andExpect(status().isOk()).andExpect(content().string("Hi ch4mpy from Tonton Pirate!")); + mockMvc.get("/greet/on-behalf-of/ch4mp") + .andExpect(status().isOk()) + .andExpect(content().string("Hi ch4mp from Tonton Pirate!")); } @Test - @ProxiesAuth( - authorities = { "AUTHOR", "ROLE_NICE_GUY" }, - claims = @OpenIdClaims(preferredUsername = "Tonton Pirate")) + @WithJwt("ch4mp.json") void givenUserIsGrantedWithNice_whenGreetOnBehalfOf_thenOk() throws Exception { - mockMvc.get("/greet/on-behalf-of/ch4mpy").andExpect(status().isOk()).andExpect(content().string("Hi ch4mpy from Tonton Pirate!")); + mockMvc.get("/greet/on-behalf-of/Tonton Pirate") + .andExpect(status().isOk()) + .andExpect(content().string("Hi Tonton Pirate from ch4mp!")); } @Test - @ProxiesAuth( - authorities = { "AUTHOR" }, - claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"), - proxies = { @Proxy(onBehalfOf = "jwacongne", can = { "greet" }) }) + @WithJwt("tonton_proxy_ch4mp.json") void givenUserIsNotGrantedWithNiceAndHasNoProxyForGreetedUser_whenGreetOnBehalfOf_thenForbidden() throws Exception { - mockMvc.get("/greet/on-behalf-of/greeted").andExpect(status().isForbidden()); + mockMvc.get("/greet/on-behalf-of/greeted") + .andExpect(status().isForbidden()); } @Test - @ProxiesAuth( - authorities = { "AUTHOR" }, - claims = @OpenIdClaims(preferredUsername = "Tonton Pirate")) + @WithJwt("tonton_proxy_ch4mp.json") void givenUserIsGreetingHimself_whenGreetOnBehalfOf_thenOk() throws Exception { - mockMvc.get("/greet/on-behalf-of/Tonton Pirate").andExpect(status().isOk()).andExpect(content().string("Hi Tonton Pirate from Tonton Pirate!")); + mockMvc.get("/greet/on-behalf-of/Tonton Pirate") + .andExpect(status().isOk()) + .andExpect(content().string("Hi Tonton Pirate from Tonton Pirate!")); } // @formatter:on } + ``` # 6. Conclusion 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 4d58c5751..3d46aab1f 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 @@ -12,9 +12,9 @@ public class ProxiesAuthentication extends OAuthentication { private static final long serialVersionUID = 447991554788295331L; - public ProxiesAuthentication(ProxiesToken claims, + public ProxiesAuthentication(ProxiesToken token, Collection authorities) { - super(claims, authorities); + super(token, authorities); } public boolean hasName(String username) { @@ -24,5 +24,4 @@ public boolean hasName(String 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/Proxy.java b/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/Proxy.java index 105baea96..3756769c5 100644 --- a/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/Proxy.java +++ b/samples/tutorials/resource-server_with_specialized_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/Proxy.java @@ -1,28 +1,27 @@ -package com.c4soft.springaddons.tutorials; - -import java.io.Serializable; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -import lombok.Data; - -@Data -public class Proxy implements Serializable { - private static final long serialVersionUID = 8853377414305913148L; - - private final String proxiedUsername; - private final String tenantUsername; - private final Set permissions; - - public Proxy(String proxiedUsername, String tenantUsername, Collection permissions) { - this.proxiedUsername = proxiedUsername; - this.tenantUsername = tenantUsername; - this.permissions = Collections.unmodifiableSet(new HashSet<>(permissions)); - } - - public boolean can(String permission) { - return permissions.contains(permission); - } -} \ No newline at end of file +package com.c4soft.springaddons.tutorials; + +import java.io.Serializable; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import lombok.Data; + +@Data +public class Proxy implements Serializable { + private static final long serialVersionUID = 8853377414305913148L; + + private final String proxiedUsername; + private final String tenantUsername; + private final Set permissions; + + public Proxy(String proxiedUsername, String tenantUsername, Collection permissions) { + this.proxiedUsername = proxiedUsername; + this.tenantUsername = tenantUsername; + this.permissions = Collections.unmodifiableSet(new HashSet<>(permissions)); + } + + public boolean can(String permission) { + return permissions.contains(permission); + } +} 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 22c71b970..48062c0d8 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 @@ -22,8 +22,8 @@ public class SecurityConfig { JwtAbstractAuthenticationTokenConverter authenticationConverter( Converter, Collection> authoritiesConverter) { return jwt -> { - final var claimSet = new ProxiesToken(jwt.getClaims(), jwt.getTokenValue()); - return new ProxiesAuthentication(claimSet, authoritiesConverter.convert(claimSet)); + final var token = new ProxiesToken(jwt.getClaims(), jwt.getTokenValue()); + return new ProxiesAuthentication(token, authoritiesConverter.convert(token)); }; } diff --git a/samples/tutorials/resource-server_with_ui/README.md b/samples/tutorials/resource-server_with_ui/README.md index f16d9ccba..4ce75d82b 100644 --- a/samples/tutorials/resource-server_with_ui/README.md +++ b/samples/tutorials/resource-server_with_ui/README.md @@ -50,195 +50,117 @@ We'll start a spring-boot 3 project from https://start.spring.io/ with these dep - spring-boot-starter-actuator And then add those dependencies: -- [`spring-addons-webmvc-jwt-resource-server`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-jwt-resource-server/6.1.5) -- [`spring-addons-webmvc-client`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-client/6.1.5) it is a thin wrapper around `spring-boot-starter-oauth2-client` which pushes auto-configuration from properties one step further. It provides with: - * a `SecurityWebFilterChain` with high precedence which intercepts all requests matched by `com.c4-soft.springaddons.security.client.security-matchers` - * CORS configuration from properties - * an authorization requests resolver with the hostname and port resolved from properties (necessary as soon as you enable SSL) - * a logout request URI builder configured from properties for "almost" OIDC compliant providers (Auth0 and Cognito do not implement standard RP-Initiated Logout) - * a logout success handler using the above logout request URI builder - * an authorities mapper configurable per issuer (and source claim) - * AOP to support multi-tenancy on OAuth2 client (Spring Security is designed to allow a user to be authenticated against only one OpenID Provider at a time) -- [`springdoc-openapi-starter-webmvc-ui`](https://central.sonatype.com/artifact/org.springdoc/springdoc-openapi-starter-webmvc-ui/2.0.2) -- [`spring-addons-webmvc-jwt-test`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-webmvc-jwt-test/6.1.5) +- [`spring-addons-starter-oidc`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc) +- [`spring-addons-starter-rest`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-rest) +- [`spring-addons-starter-oidc-test`](https://central.sonatype.com/artifact/com.c4-soft.springaddons/spring-addons-starter-oidc-test) with `test` scope +```xml + + com.c4-soft.springaddons + spring-addons-starter-oidc + ${spring-addons.version} + + + com.c4-soft.springaddons + spring-addons-starter-rest + ${spring-addons.version} + + + com.c4-soft.springaddons + spring-addons-starter-oidc-test + ${spring-addons.version} + test + +``` ## 4. Web-Security Configuration -This tutorial uses `spring-addons-webmvc-jwt-resource-server` and `spring-addons-webmvc-client` Spring Boot starters, which both auto-configure `SecurityFilterChain` based on properties file. **These security filter-chains are not explicitly defined in security-conf, but are there!** - -### 4.1. Resource Server configuration -As exposed, we rely mostly on auto-configuration to secure REST end-points. The only access-control rules that we have to insert in our Java configuration are those restricting access to actuator (OpenAPI specification is public as per application properties). With `spring-addons-webmvc-jwt-resource-server`, this is done as follow: -```java@Bean -ExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { - return (AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry) -> registry - .requestMatchers(HttpMethod.GET, "/actuator/**").hasAuthority("OBSERVABILITY:read") - .requestMatchers("/actuator/**").hasAuthority("OBSERVABILITY:write") - .anyRequest().authenticated(); -} -``` -Refer to [`servlet-resource-server`](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials/servlet-resource-server) for a (much more) verbose alternative using `spring-boot-starter-oauth2-resource-server`. +This tutorial uses `spring-addons-starter-oidc` which auto-configures two `SecurityFilterChain` beans based on properties file (one with `oauth2ResourceServer` and one with `oauth2Login`). **These security filter-chains are not explicitly defined in security-conf, but are there!** -### 4.2. Application Properties +### 4.1. Application Properties ```yaml -api-host: ${scheme}://localhost:${server.port} -ui-host: ${api-host} -rp-initiated-logout-enabled: true - -scheme: http -keycloak-port: 8442 -keycloak-issuer: ${scheme}://localhost:${keycloak-port}/realms/master -keycloak-secret: change-me -cognito-issuer: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl -cognito-secret: change-me -auth0-issuer: https://dev-ch4mpy.eu.auth0.com/ -auth0-secret: change-me - -server: - port: 8080 - ssl: - enabled: false - spring: - lifecycle: - timeout-per-shutdown-phase: 30s security: oauth2: client: provider: keycloak: issuer-uri: ${keycloak-issuer} - cognito: - issuer-uri: ${cognito-issuer} - auth0: - issuer-uri: ${auth0-issuer} + entra: + issuer-uri: ${entra-issuer} registration: - keycloak-user: + keycloak-authorization-code: authorization-grant-type: authorization_code - client-name: a local Keycloak instance - client-id: spring-addons-confidential - client-secret: ${keycloak-secret} + client-name: Keycloak (local) + client-id: spring-addons-user + client-secret: secret provider: keycloak scope: openid,profile,email,offline_access - keycloak-programmatic: + keycloak-client-credentials: authorization-grant-type: client_credentials - client-id: spring-addons-confidential - client-secret: ${keycloak-secret} + client-id: spring-addons-m2m + client-secret: secret provider: keycloak - scope: openid,offline_access - cognito-confidential-user: + scope: openid + entra: authorization-grant-type: authorization_code - client-name: Amazon Cognito - client-id: 12olioff63qklfe9nio746es9f - client-secret: ${cognito-secret} - provider: cognito - scope: openid,profile,email - auth0-confidential-user: - authorization-grant-type: authorization_code - client-name: Auth0 - client-id: TyY0H7xkRMRe6lDf9F8EiNqCo8PdhICy - client-secret: ${auth0-secret} - provider: auth0 - scope: openid,profile,email,offline_access + client-name: Microsoft Entra + client-id: 0866cd01-6f25-4501-8ce5-b89dbfc671e0 + client-secret: change-me + provider: entra + scope: api://4f68014f-7f14-4f89-8197-06f0b3ff24d9/spring-addons com: c4-soft: springaddons: - security: - cors: - - path: /api/greet - issuers: - - location: ${keycloak-issuer} - username-claim: $.preferred_username + oidc: + ops: + - iss: ${keycloak-issuer} authorities: - path: $.realm_access.roles - - path: $.resource_access.*.roles - - location: ${cognito-issuer} - username-claim: $.username - authorities: - - path: $.cognito:groups - - location: ${auth0-issuer} - username-claim: $['https://c4-soft.com/user']['name'] + - iss: ${entra-issuer} authorities: - - path: $['https://c4-soft.com/user']['roles'] - - path: $.permissions - permit-all: - - /actuator/health/readiness - - /actuator/health/liveness - - /v3/api-docs/** - - /api/public + - path: $.groups + resourceserver: + permit-all: + - /actuator/health/readiness + - /actuator/health/liveness + - /v3/api-docs/** + - /api/public + - /favicon.ico client: security-matchers: - /login/** - /oauth2/** - / - /ui/** + - /swagger-ui.html - /swagger-ui/** permit-all: - /login/** - /oauth2/** - / - - /ui/ + - /ui/** - /swagger-ui.html - /swagger-ui/** - client-uri: ${ui-host} + client-uri: ${client-uri} post-login-redirect-path: /ui/greet post-logout-redirect-path: /ui/greet - multi-tenancy-enabled: true - oauth2-logout: - cognito: - uri: https://spring-addons.auth.us-west-2.amazoncognito.com/logout - client-id-request-param: client_id - post-logout-uri-request-param: logout_uri - auth0: - uri: ${auth0-issuer}v2/logout - client-id-request-param: client_id - post-logout-uri-request-param: returnTo - authorization-request-params: - auth0-confidential-user: - - name: audience - value: demo.c4-soft.com - -logging: - level: - org: - springframework: - security: DEBUG - -management: - endpoint: - health: - probes: - enabled: true - endpoints: - web: - exposure: - include: '*' - health: - livenessstate: - enabled: true - readinessstate: - enabled: true - ---- -scheme: https -keycloak-port: 8443 - -server: - ssl: - enabled: true - -spring: - config: - activate: - on-profile: ssl + pkce-forced: true + rest: + client: + greet-client: + base-url: ${client-uri}/api + authorization: + oauth2: + oauth2-registration-id: keycloak-authorization-code ``` -Non-standard logouts are then registered with properties under `com.c4-soft.springaddons.security.client` for each issuer, spring OIDC handler being used as default for non-listed ones. +The properties under `rest` define the configuration for a `RestClient` bean named `greetClient` and using a `registration` with client-credentials to authorize its requests to our REST API. To implement a single tenant scenario, we would keep just a single entry in `spring.security.oauth2.client.provider`, `com.c4-soft.springaddons.security.issuers` and `com.c4-soft.springaddons.security.client.oauth2-logout` arrays. That easy. Don't forget to update the issuer URIs as well as client ID & secrets with your own (or to override it with command line arguments, environment variables or whatever). -#### 4.3. OAuth2 Security Filter-Chain -Here is approximately what `spring-addons-starter-oidc` configure with the properties above: +#### 4.2. OAuth2 Security Filter-Chain +**We have absolutely no Java code to write.** For information purpose, here is approximately what `spring-addons-starter-oidc` configure under the hood with the properties above: ```java @EnableWebSecurity @Configuration @@ -335,7 +257,7 @@ public class WebSecurityConf { } ``` -### 4.4. Logout +### 4.3. RP-Initiated Logout This one is tricky. It is important to have in mind that each user has a session on our client but also on each authorization server. If we invalidate only the session on our client, it is very likely that the next login attempt with the same browser will complete silently. For a complete logout, **both client and authorization sessions should be terminated**. @@ -344,7 +266,8 @@ OIDC specifies two logout protocols: - [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) where a client asks the authorization-server to terminate a user session - [back-channel logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) where the authorization-server brodcasts a logout event to a list of registered clients so that each can terminate its own session for the user -#### 4.4.1 RP-Initiated Logout +Here, we cover only the RP-Initiated Logout. + In the case of a single "OIDC" authorization-server strictly following the RP-Initiated Logout standard, we could use the `OidcClientInitiatedLogoutSuccessHandler` from spring security: ```java http.logout().logoutSuccessHandler(new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository)); @@ -362,23 +285,6 @@ Now, let's address our use case: OAuth2 client with potentially several authoriz As RP-Initiated Logout is using redirections to the authorization server (on logout URI) and then back to the client (on post-logout URI), we'll have to ensure that all our application `/`, `/ui/greet` and `/ui/bulk-logout-idps` endpoints are declared as allowed post-logout URIs on all identity providers. -#### 4.4.2. Back-Channel Logout -Back-channel logout [is not implemented yet in spring-security](https://github.com/spring-projects/spring-security/issues/7845) (vote there if you are interested in it). - -### 4.5. `WebClient` in Servlet Applications -As we use `WebClient`, which is a reactive component, in a servlet application, we have to tweak its auto-configuration: -```java -@Configuration -public class WebClientConfig { - @Bean - WebClient webClient(ClientRegistrationRepository clientRegistrationRepository, OAuth2AuthorizedClientService authorizedClientService) { - var authorizedClientManager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientService); - var oauth = new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); - return WebClient.builder().apply(oauth.oauth2Configuration()).build(); - } -} -``` - ## 5. Resource Server Components As username and roles are already mapped, it's super easy to build a greeting containing both from the `Authentication` instance in the security-context: ```java 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 a33cf9a01..dd06e81a4 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 @@ -3,7 +3,7 @@ rp-initiated-logout-enabled: true scheme: http keycloak-issuer: http://localhost:7080/auth/realms/spring-addons -auth0-issuer: https://dev-ch4mpy.eu.auth0.com/ +entra-issuer: https://sts.windows.net/4f68014f-7f14-4f89-8197-06f0b3ff24d9 server: port: 8080 @@ -18,7 +18,7 @@ spring: keycloak: issuer-uri: ${keycloak-issuer} entra: - issuer-uri: https://sts.windows.net/4f68014f-7f14-4f89-8197-06f0b3ff24d9/ + issuer-uri: ${entra-issuer} registration: keycloak-authorization-code: authorization-grant-type: authorization_code @@ -33,7 +33,7 @@ spring: client-secret: secret provider: keycloak scope: openid - quiz-bff: + entra: authorization-grant-type: authorization_code client-name: Microsoft Entra client-id: 0866cd01-6f25-4501-8ce5-b89dbfc671e0 @@ -49,6 +49,9 @@ com: - iss: ${keycloak-issuer} authorities: - path: $.realm_access.roles + - iss: ${entra-issuer} + authorities: + - path: $.groups resourceserver: permit-all: - /actuator/health/readiness @@ -75,17 +78,6 @@ com: post-login-redirect-path: /ui/greet post-logout-redirect-path: /ui/greet pkce-forced: true - oauth2-logout: - auth0-authorization-code: - uri: ${auth0-issuer}v2/logout - client-id-request-param: client_id - post-logout-uri-request-param: returnTo - authorization-params: - auth0-authorization-code: - audience: demo.c4-soft.com - token-params: - auth0-authorization-code: - audience: demo.c4-soft.com rest: client: greet-client: