diff --git a/apps/backend-api-springboot3/pom.xml b/apps/backend-api-springboot3/pom.xml index cf7d7214..d7800d5d 100644 --- a/apps/backend-api-springboot3/pom.xml +++ b/apps/backend-api-springboot3/pom.xml @@ -1,83 +1,88 @@ <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> - <modelVersion>4.0.0</modelVersion> - <parent> - <groupId>org.springframework.boot</groupId> - <artifactId>spring-boot-starter-parent</artifactId> - <version>3.0.2</version> - <relativePath/> <!-- lookup parent from repository --> - </parent> - <groupId>com.example</groupId> - <artifactId>backend-api-springboot3</artifactId> - <version>0.0.1-SNAPSHOT</version> - <name>backend-api-springboot3</name> - <description>backend-api-springboot3</description> - <properties> - <java.version>17</java.version> - </properties> - <dependencies> - <dependency> - <groupId>org.springframework.boot</groupId> - <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> - </dependency> - <dependency> - <groupId>org.springframework.boot</groupId> - <artifactId>spring-boot-starter-web</artifactId> - </dependency> - - <dependency> - <groupId>org.springframework.boot</groupId> - <artifactId>spring-boot-devtools</artifactId> - <scope>runtime</scope> - <optional>true</optional> - </dependency> - - <dependency> - <groupId>org.projectlombok</groupId> - <artifactId>lombok</artifactId> - </dependency> - - <dependency> - <groupId>org.springframework.boot</groupId> - <artifactId>spring-boot-starter-test</artifactId> - <scope>test</scope> - </dependency> - <dependency> - <groupId>com.c4-soft.springaddons</groupId> - <artifactId>spring-addons-oauth2-test</artifactId> - <version>6.0.12</version> - <scope>test</scope> - </dependency> - </dependencies> - - <build> - <plugins> - <plugin> - <groupId>org.springframework.boot</groupId> - <artifactId>spring-boot-maven-plugin</artifactId> - </plugin> - </plugins> - </build> - <repositories> - <repository> - <id>spring-milestones</id> - <name>Spring Milestones</name> - <url>https://repo.spring.io/milestone</url> - <snapshots> - <enabled>false</enabled> - </snapshots> - </repository> - </repositories> - <pluginRepositories> - <pluginRepository> - <id>spring-milestones</id> - <name>Spring Milestones</name> - <url>https://repo.spring.io/milestone</url> - <snapshots> - <enabled>false</enabled> - </snapshots> - </pluginRepository> - </pluginRepositories> - + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-parent</artifactId> + <version>3.0.2</version> + <relativePath /> <!-- lookup parent from repository --> + </parent> + <groupId>com.example</groupId> + <artifactId>backend-api-springboot3</artifactId> + <version>0.0.1-SNAPSHOT</version> + <name>backend-api-springboot3</name> + <description>backend-api-springboot3</description> + <properties> + <java.version>17</java.version> + <spring-addons.version>6.0.12</spring-addons.version> + </properties> + <dependencies> + <dependency> + <groupId>com.c4-soft.springaddons</groupId> + <artifactId>spring-addons-webmvc-jwt-resource-server</artifactId> + <version>${spring-addons.version}</version> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-web</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-devtools</artifactId> + <scope>runtime</scope> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-test</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>com.c4-soft.springaddons</groupId> + <artifactId>spring-addons-webmvc-jwt-test</artifactId> + <version>${spring-addons.version}</version> + <scope>test</scope> + </dependency> + </dependencies> + <build> + <plugins> + <plugin> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-maven-plugin</artifactId> + <configuration> + <excludes> + <exclude> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + </exclude> + </excludes> + </configuration> + </plugin> + </plugins> + </build> + <repositories> + <repository> + <id>spring-milestones</id> + <name>Spring Milestones</name> + <url>https://repo.spring.io/milestone</url> + <snapshots> + <enabled>false</enabled> + </snapshots> + </repository> + </repositories> + <pluginRepositories> + <pluginRepository> + <id>spring-milestones</id> + <name>Spring Milestones</name> + <url>https://repo.spring.io/milestone</url> + <snapshots> + <enabled>false</enabled> + </snapshots> + </pluginRepository> + </pluginRepositories> </project> diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/AccessController.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/AccessController.java new file mode 100644 index 00000000..1a0bbe12 --- /dev/null +++ b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/AccessController.java @@ -0,0 +1,31 @@ +package com.acme.backend.springboot.users.config; + +import java.util.function.Supplier; + +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; + +import lombok.extern.slf4j.Slf4j; + +/** + * Example for generic custom access checks on request level. + */ +@Slf4j +public class AccessController { + + private static final AuthorizationDecision GRANTED = new AuthorizationDecision(true); + private static final AuthorizationDecision DENIED = new AuthorizationDecision(false); + + public static AuthorizationDecision checkAccess(Supplier<Authentication> authentication, RequestAuthorizationContext requestContext) { + + var auth = authentication.get(); + if (auth == null) { + log.warn("Authentication provider returned null authentication"); + return DENIED; + } + log.info("Check access for username={} path={}", auth.getName(), requestContext.getRequest().getRequestURI()); + return auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).filter("ROLE_ACCESS"::equals).count() > 0 ? GRANTED : DENIED; + } +} diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/AcmeServiceProperties.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/AcmeServiceProperties.java deleted file mode 100644 index 90af5ecf..00000000 --- a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/AcmeServiceProperties.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.acme.backend.springboot.users.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.List; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "acme") -public class AcmeServiceProperties { - - private KeycloakJwtProperties jwt = new KeycloakJwtProperties(); - - /** - * Specifies JWT client ID, issuer URI and allowed audiences - * for validation - */ - @Getter - @Setter - public static class KeycloakJwtProperties { - - private String clientId; - - private String issuerUri; - - private List<String> allowedAudiences; - } - -} diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/JwtSecurityConfig.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/JwtSecurityConfig.java deleted file mode 100644 index 6b5f55d7..00000000 --- a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/JwtSecurityConfig.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.acme.backend.springboot.users.config; - -import com.acme.backend.springboot.users.support.keycloak.KeycloakGrantedAuthoritiesConverter; -import com.acme.backend.springboot.users.support.keycloak.KeycloakJwtAuthenticationConverter; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtIssuerValidator; -import org.springframework.security.oauth2.jwt.JwtTimestampValidator; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Set; - -/** - * Configures JWT handling (decoder and validator) - */ -@Configuration -class JwtSecurityConfig { - - /** - * Configures a decoder with the specified validators (validation key fetched from JWKS endpoint) - * - * @param validators validators for the given key - * @param properties key properties (provides JWK location) - * @return the decoder bean - */ - @Bean - JwtDecoder jwtDecoder(List<OAuth2TokenValidator<Jwt>> validators, OAuth2ResourceServerProperties properties) { - - NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder // - .withJwkSetUri(properties.getJwt().getJwkSetUri()) // - .jwsAlgorithms(algs -> algs.addAll(Set.of(SignatureAlgorithm.RS256, SignatureAlgorithm.ES256))) // - .build(); - - jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators)); - - return jwtDecoder; - } - - /** - * Configures the token validator. Specifies two additional validation constraints: - * <p> - * * Timestamp on the token is still valid - * * The issuer is the expected entity - * - * @param properties JWT resource specification - * @return token validator - */ - @Bean - OAuth2TokenValidator<Jwt> defaultTokenValidator(OAuth2ResourceServerProperties properties) { - - List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>(); - validators.add(new JwtTimestampValidator()); - validators.add(new JwtIssuerValidator(properties.getJwt().getIssuerUri())); - - return new DelegatingOAuth2TokenValidator<>(validators); - } - - @Bean - KeycloakJwtAuthenticationConverter keycloakJwtAuthenticationConverter(Converter<Jwt, Collection<GrantedAuthority>> authoritiesConverter) { - return new KeycloakJwtAuthenticationConverter(authoritiesConverter); - } - - @Bean - Converter<Jwt, Collection<GrantedAuthority>> keycloakGrantedAuthoritiesConverter(GrantedAuthoritiesMapper authoritiesMapper, AcmeServiceProperties acmeServiceProperties) { - String clientId = acmeServiceProperties.getJwt().getClientId(); - return new KeycloakGrantedAuthoritiesConverter(clientId, authoritiesMapper); - } - -} diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/MethodSecurityConfig.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/MethodSecurityConfig.java deleted file mode 100644 index 4a859ba0..00000000 --- a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/MethodSecurityConfig.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.acme.backend.springboot.users.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.access.PermissionEvaluator; -import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; -import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; -import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration; -import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper; - - -/** - * Enables security annotations via like {@link org.springframework.security.access.prepost.PreAuthorize} and - * {@link org.springframework.security.access.prepost.PostAuthorize} annotations per-method. - */ -@Configuration -@RequiredArgsConstructor -@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, proxyTargetClass = true) -class MethodSecurityConfig extends GlobalMethodSecurityConfiguration { - - private final ApplicationContext applicationContext; - - private final PermissionEvaluator permissionEvaluator; - - @Override - protected MethodSecurityExpressionHandler createExpressionHandler() { - - DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); - expressionHandler.setApplicationContext(applicationContext); - expressionHandler.setPermissionEvaluator(permissionEvaluator); - - return expressionHandler; - } - - @Bean - GrantedAuthoritiesMapper keycloakAuthoritiesMapper() { - - SimpleAuthorityMapper mapper = new SimpleAuthorityMapper(); - mapper.setConvertToUpperCase(true); - return mapper; - } - -} \ No newline at end of file diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/WebSecurityConfig.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/WebSecurityConfig.java index 5e303cf1..25b301f1 100644 --- a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/WebSecurityConfig.java +++ b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/config/WebSecurityConfig.java @@ -1,91 +1,38 @@ package com.acme.backend.springboot.users.config; -import com.acme.backend.springboot.users.support.access.AccessController; -import com.acme.backend.springboot.users.support.keycloak.KeycloakJwtAuthenticationConverter; -import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.CorsConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; -import java.util.List; +import com.c4_soft.springaddons.security.oauth2.config.synchronised.ExpressionInterceptUrlRegistryPostProcessor; /** - * Configuration applied on all web endpoints defined for this - * application. Any configuration on specific resources is applied - * in addition to these global rules. + * Configuration applied on all web endpoints defined for this application. Any configuration on specific resources is applied in addition to these global + * rules. + * <ul> + * <li>Stateless session (no session kept server-side)</li> + * <li>CORS set up</li> + * <li>Require the role "ACCESS" for all api paths</li> + * <li>JWT converted into Spring token</li> + * </ul> */ @Configuration -@RequiredArgsConstructor -class WebSecurityConfig { - - private final KeycloakJwtAuthenticationConverter keycloakJwtAuthenticationConverter; - - /** - * Configures basic security handler per HTTP session. - * <p> - * <ul> - * <li>Stateless session (no session kept server-side)</li> - * <li>CORS set up</li> - * <li>Require the role "ACCESS" for all api paths</li> - * <li>JWT converted into Spring token</li> - * </ul> - * - * @param http security configuration - * @throws Exception any error - */ - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - - http.sessionManagement(smc -> { - smc.sessionCreationPolicy(SessionCreationPolicy.STATELESS); - }); - http.cors(this::configureCors); - http.authorizeHttpRequests(ahrc -> { - // declarative route configuration - // .mvcMatchers("/api").hasAuthority("ROLE_ACCESS") - ahrc.requestMatchers("/api/**").access(AccessController::checkAccess); - // add additional routes - ahrc.anyRequest().fullyAuthenticated(); // - }); - http.oauth2ResourceServer(arsc -> { - arsc.jwt().jwtAuthenticationConverter(keycloakJwtAuthenticationConverter); - }); - - return http.build(); - } - - @Bean - AccessController accessController() { - return new AccessController(); - } - - /** - * Configures CORS to allow requests from localhost:30000 - * - * @param cors mutable cors configuration - */ - protected void configureCors(CorsConfigurer<HttpSecurity> cors) { - - UrlBasedCorsConfigurationSource defaultUrlBasedCorsConfigSource = new UrlBasedCorsConfigurationSource(); - CorsConfiguration corsConfiguration = new CorsConfiguration().applyPermitDefaultValues(); - corsConfiguration.addAllowedOrigin("https://apps.acme.test:4443"); - List.of("GET", "POST", "PUT", "DELETE").forEach(corsConfiguration::addAllowedMethod); - defaultUrlBasedCorsConfigSource.registerCorsConfiguration("/api/**", corsConfiguration); - - cors.configurationSource(req -> { - - CorsConfiguration config = new CorsConfiguration(); - - config = config.combine(defaultUrlBasedCorsConfigSource.getCorsConfiguration(req)); - - // check if request Header "origin" is in white-list -> dynamically generate cors config - - return config; - }); - } +@EnableMethodSecurity(securedEnabled = true, proxyTargetClass = true) +public class WebSecurityConfig { + + /** + * Default security requires users to be authenticated to all routes but those listed in "permit-all" property (see yaml file) + * + * @return a custom authorization registry for the routes not listed in "permit-all" property + */ + @Bean + ExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { + // @formatter:off + return (AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) -> registry + .requestMatchers("/api/**").access(AccessController::checkAccess) + .anyRequest().fullyAuthenticated(); + // @formatter:on + } } \ No newline at end of file diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/access/AccessController.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/access/AccessController.java deleted file mode 100644 index 03f15cda..00000000 --- a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/access/AccessController.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.acme.backend.springboot.users.support.access; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authorization.AuthorizationDecision; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.access.intercept.RequestAuthorizationContext; - -import java.util.function.Supplier; - -/** - * Example for generic custom access checks on request level. - */ -@Slf4j -public class AccessController { - - private static final AuthorizationDecision GRANTED = new AuthorizationDecision(true); - private static final AuthorizationDecision DENIED = new AuthorizationDecision(false); - - public static AuthorizationDecision checkAccess(Supplier<Authentication> authentication, RequestAuthorizationContext requestContext) { - - var auth = authentication.get(); - log.info("Check access for username={} path={}", auth.getName(), requestContext.getRequest().getRequestURI()); - - return GRANTED; - } -} diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/keycloak/KeycloakAudienceValidator.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/keycloak/KeycloakAudienceValidator.java deleted file mode 100644 index c57420cc..00000000 --- a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/keycloak/KeycloakAudienceValidator.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.acme.backend.springboot.users.support.keycloak; - -import lombok.RequiredArgsConstructor; -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.stereotype.Component; - -/** - * Example class for custom audience (aud) or authorized party (azp) claim validations. - */ -@Component -@RequiredArgsConstructor -class KeycloakAudienceValidator implements OAuth2TokenValidator<Jwt> { - - private final OAuth2Error ERROR_INVALID_AUDIENCE = new OAuth2Error("invalid_token", "Invalid audience", null); - - @Override - public OAuth2TokenValidatorResult validate(Jwt jwt) { - -// String authorizedParty = jwt.getClaimAsString("azp"); -// -// if (!keycloakDataServiceProperties.getJwt().getAllowedAudiences().contains(authorizedParty)) { -// return OAuth2TokenValidatorResult.failure(ERROR_INVALID_AUDIENCE); -// } - - return OAuth2TokenValidatorResult.success(); - } -} diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/keycloak/KeycloakGrantedAuthoritiesConverter.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/keycloak/KeycloakGrantedAuthoritiesConverter.java deleted file mode 100644 index 80a7f569..00000000 --- a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/keycloak/KeycloakGrantedAuthoritiesConverter.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.acme.backend.springboot.users.support.keycloak; - -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; -import org.springframework.util.CollectionUtils; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Allows to extract granted authorities from a given JWT. The authorities - * are determined by combining the realm (overarching) and client (application-specific) - * roles, and normalizing them (configure them to the default format). - */ -public class KeycloakGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> { - - private static final Converter<Jwt, Collection<GrantedAuthority>> JWT_SCOPE_GRANTED_AUTHORITIES_CONVERTER = new JwtGrantedAuthoritiesConverter(); - - private final String clientId; - - private final GrantedAuthoritiesMapper authoritiesMapper; - - public KeycloakGrantedAuthoritiesConverter(String clientId, GrantedAuthoritiesMapper authoritiesMapper) { - this.clientId = clientId; - this.authoritiesMapper = authoritiesMapper; - } - - @Override - public Collection<GrantedAuthority> convert(Jwt jwt) { - - Collection<GrantedAuthority> authorities = mapKeycloakRolesToAuthorities( // - getRealmRolesFrom(jwt), // - getClientRolesFrom(jwt, clientId) // - ); - - Collection<GrantedAuthority> scopeAuthorities = JWT_SCOPE_GRANTED_AUTHORITIES_CONVERTER.convert(jwt); - if(!CollectionUtils.isEmpty(scopeAuthorities)) { - authorities.addAll(scopeAuthorities); - } - - return authorities; - } - - protected Collection<GrantedAuthority> mapKeycloakRolesToAuthorities(Set<String> realmRoles, Set<String> clientRoles) { - - List<GrantedAuthority> combinedAuthorities = new ArrayList<>(); - - combinedAuthorities.addAll(authoritiesMapper.mapAuthorities(realmRoles.stream() // - .map(SimpleGrantedAuthority::new) // - .collect(Collectors.toList()))); - - combinedAuthorities.addAll(authoritiesMapper.mapAuthorities(clientRoles.stream() // - .map(SimpleGrantedAuthority::new) // - .collect(Collectors.toList()))); - - return combinedAuthorities; - } - - protected Set<String> getRealmRolesFrom(Jwt jwt) { - - Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access"); - - if (CollectionUtils.isEmpty(realmAccess)) { - return Collections.emptySet(); - } - - @SuppressWarnings("unchecked") - Collection<String> realmRoles = (Collection<String>) realmAccess.get("roles"); - if (CollectionUtils.isEmpty(realmRoles)) { - return Collections.emptySet(); - } - - return realmRoles.stream().map(this::normalizeRole).collect(Collectors.toSet()); - } - - protected Set<String> getClientRolesFrom(Jwt jwt, String clientId) { - - Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access"); - - if (CollectionUtils.isEmpty(resourceAccess)) { - return Collections.emptySet(); - } - - @SuppressWarnings("unchecked") - Map<String, List<String>> clientAccess = (Map<String, List<String>>) resourceAccess.get(clientId); - if (CollectionUtils.isEmpty(clientAccess)) { - return Collections.emptySet(); - } - - List<String> clientRoles = clientAccess.get("roles"); - if (CollectionUtils.isEmpty(clientRoles)) { - return Collections.emptySet(); - } - - return clientRoles.stream().map(this::normalizeRole).collect(Collectors.toSet()); - } - - private String normalizeRole(String role) { - return role.replace('-', '_'); - } -} diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/keycloak/KeycloakJwtAuthenticationConverter.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/keycloak/KeycloakJwtAuthenticationConverter.java deleted file mode 100644 index 04e305bc..00000000 --- a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/keycloak/KeycloakJwtAuthenticationConverter.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.acme.backend.springboot.users.support.keycloak; - -import lombok.RequiredArgsConstructor; -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; - -import java.util.Collection; - -/** - * Converts a JWT into a Spring authentication token (by extracting - * the username and roles from the claims of the token, delegating - * to the {@link KeycloakGrantedAuthoritiesConverter}) - */ -@RequiredArgsConstructor -public class KeycloakJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> { - - private Converter<Jwt, Collection<GrantedAuthority>> grantedAuthoritiesConverter; - - public KeycloakJwtAuthenticationConverter(Converter<Jwt, Collection<GrantedAuthority>> grantedAuthoritiesConverter) { - this.grantedAuthoritiesConverter = grantedAuthoritiesConverter; - } - - @Override - public JwtAuthenticationToken convert(Jwt jwt) { - - Collection<GrantedAuthority> authorities = grantedAuthoritiesConverter.convert(jwt); - String username = getUsernameFrom(jwt); - - return new JwtAuthenticationToken(jwt, authorities, username); - } - - protected String getUsernameFrom(Jwt jwt) { - - if (jwt.hasClaim("preferred_username")) { - return jwt.getClaimAsString("preferred_username"); - } - - return jwt.getSubject(); - } -} diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/permissions/DefaultPermissionEvaluator.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/permissions/DefaultPermissionEvaluator.java deleted file mode 100644 index bff146b5..00000000 --- a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/permissions/DefaultPermissionEvaluator.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.acme.backend.springboot.users.support.permissions; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.access.PermissionEvaluator; -import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Component; - -import java.io.Serializable; - -/** - * Custom {@link PermissionEvaluator} for method level permission checks. - * - * @see com.acme.backend.springboot.users.config.MethodSecurityConfig - */ -@Slf4j -@Component -@RequiredArgsConstructor -class DefaultPermissionEvaluator implements PermissionEvaluator { - - @Override - public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) { - log.info("check permission user={} target={} permission={}", auth.getName(), targetDomainObject, permission); - - // TODO implement sophisticated permission check here - return true; - } - - @Override - public boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission) { - DomainObjectReference dor = new DomainObjectReference(targetType, targetId.toString()); - return hasPermission(auth, dor, permission); - } -} diff --git a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/permissions/DomainObjectReference.java b/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/permissions/DomainObjectReference.java deleted file mode 100644 index 33207f15..00000000 --- a/apps/backend-api-springboot3/src/main/java/com/acme/backend/springboot/users/support/permissions/DomainObjectReference.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.acme.backend.springboot.users.support.permissions; - -import lombok.Data; - -/** - * Defines a single domain object by a type and name to look up - */ -@Data -public class DomainObjectReference { - - private final String type; - - private final String id; -} diff --git a/apps/backend-api-springboot3/src/main/resources/application.yml b/apps/backend-api-springboot3/src/main/resources/application.yml index 3092f247..3fc681cc 100644 --- a/apps/backend-api-springboot3/src/main/resources/application.yml +++ b/apps/backend-api-springboot3/src/main/resources/application.yml @@ -9,19 +9,29 @@ spring: oauth2: resourceserver: jwt: - issuer-uri: ${acme.jwt.issuerUri} - jwk-set-uri: ${acme.jwt.issuerUri}/protocol/openid-connect/certs -# Use mock-service jwks-endpoint to obtain public key for testing -# jwk-set-uri: http://localhost:9999/jwks + audiences: + - https://localhost:${server.port} -acme: - jwt: - issuerUri: https://id.acme.test:8443/auth/realms/acme-internal +com: + c4-soft: + springaddons: + security: + issuers: + - location: https://localhost:8443/auth/realms/acme-internal + authorities: + claims: + - realm_access.roles + - resource_access.spring-addons-confidential.roles + caze: UPPER + prefix: ROLE_ + cors: + - path: "/api/**" + allowed-origins: "https://localhost:4443" + permit-all: server: port: 4623 ssl: enabled: true key-store: ../../config/stage/dev/tls/acme.test+1.p12 - key-store-password: changeit - key-store-type: PKCS12 \ No newline at end of file + key-store-password: changeit \ No newline at end of file diff --git a/apps/backend-api-springboot3/src/test/java/com/acme/backend/springboot/users/BackendApiSpringboot3AppTests.java b/apps/backend-api-springboot3/src/test/java/com/acme/backend/springboot/users/BackendApiSpringboot3AppTests.java index 2cdd42de..d041dac0 100644 --- a/apps/backend-api-springboot3/src/test/java/com/acme/backend/springboot/users/BackendApiSpringboot3AppTests.java +++ b/apps/backend-api-springboot3/src/test/java/com/acme/backend/springboot/users/BackendApiSpringboot3AppTests.java @@ -3,7 +3,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import com.c4_soft.springaddons.security.oauth2.test.webmvc.jwt.AutoConfigureAddonsWebSecurity; + @SpringBootTest +@AutoConfigureAddonsWebSecurity class BackendApiSpringboot3AppTests { @Test diff --git a/apps/backend-api-springboot3/src/test/java/com/acme/backend/springboot/users/web/UsersControllerTest.java b/apps/backend-api-springboot3/src/test/java/com/acme/backend/springboot/users/web/UsersControllerTest.java index df418944..cad9a83b 100644 --- a/apps/backend-api-springboot3/src/test/java/com/acme/backend/springboot/users/web/UsersControllerTest.java +++ b/apps/backend-api-springboot3/src/test/java/com/acme/backend/springboot/users/web/UsersControllerTest.java @@ -8,36 +8,41 @@ 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 org.springframework.test.web.servlet.MockMvc; +import com.acme.backend.springboot.users.config.WebSecurityConfig; import com.c4_soft.springaddons.security.oauth2.test.annotations.OpenIdClaims; import com.c4_soft.springaddons.security.oauth2.test.annotations.WithMockJwtAuth; +import com.c4_soft.springaddons.security.oauth2.test.webmvc.jwt.AutoConfigureAddonsWebSecurity; @WebMvcTest(controllers = UsersController.class) +@AutoConfigureAddonsWebSecurity +@Import(WebSecurityConfig.class) class UsersControllerTest { @Autowired MockMvc api; @Test - void givenRequestIsAnonymous_whengetUsersMe_thenUnauthorized() throws Exception { - api.perform(get("/api/users/me")).andExpectAll(status().isUnauthorized()); + void givenRequestIsAnonymous_whenGetUsersMe_thenUnauthorized() throws Exception { + api.perform(get("/api/users/me").secure(true)).andExpectAll(status().isUnauthorized()); } @Test @WithMockJwtAuth(claims = @OpenIdClaims(sub = "Tonton Pirate")) - void givenUserIsNotGrantedWithAccess_whengetUsersMe_thenForbidden() throws Exception { + void givenUserIsNotGrantedWithAccess_whenGetUsersMe_thenForbidden() throws Exception { // @formatter:off - api.perform(get("/api/users/me")) + api.perform(get("/api/users/me").secure(true)) // should be forbidden as user does is not granted with "ROLE_ACCESS" - .andExpect(status().isOk()); + .andExpect(status().isForbidden()); // @formatter:on } @Test @WithMockJwtAuth(authorities = { "ROLE_ACCESS" }, claims = @OpenIdClaims(sub = "Tonton Pirate")) - void givenUserIsGrantedWithAccess_whengetUsersMe_thenOk() throws Exception { + void givenUserIsGrantedWithAccess_whenGetUsersMe_thenOk() throws Exception { // @formatter:off - api.perform(get("/api/users/me")) + api.perform(get("/api/users/me").secure(true)) .andExpect(status().isOk()) .andExpect(jsonPath("$.message", is("Hello Tonton Pirate"))); // @formatter:on