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..0358dd66 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,8 +1,7 @@ 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 java.util.List; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -12,80 +11,82 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.List; +import com.acme.backend.springboot.users.support.access.AccessController; +import com.acme.backend.springboot.users.support.keycloak.KeycloakJwtAuthenticationConverter; + +import lombok.RequiredArgsConstructor; /** - * 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. */ @Configuration @RequiredArgsConstructor -class WebSecurityConfig { - - private final KeycloakJwtAuthenticationConverter keycloakJwtAuthenticationConverter; - - /** - * Configures basic security handler per HTTP session. - *

- *

- * - * @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 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; - }); - } +public class WebSecurityConfig { + + private final KeycloakJwtAuthenticationConverter keycloakJwtAuthenticationConverter; + + /** + * Configures basic security handler per HTTP session. + *

+ *

+ * + * @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 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; + }); + } } \ 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 index 03f15cda..79df696b 100644 --- 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 @@ -1,11 +1,13 @@ package com.acme.backend.springboot.users.support.access; -import lombok.extern.slf4j.Slf4j; +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 java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; /** * Example for generic custom access checks on request level. @@ -13,14 +15,17 @@ @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, RequestAuthorizationContext requestContext) { + private static final AuthorizationDecision GRANTED = new AuthorizationDecision(true); + private static final AuthorizationDecision DENIED = new AuthorizationDecision(false); - var auth = authentication.get(); - log.info("Check access for username={} path={}", auth.getName(), requestContext.getRequest().getRequestURI()); + public static AuthorizationDecision checkAccess(Supplier authentication, RequestAuthorizationContext requestContext) { - return GRANTED; - } + 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/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..31b6b096 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 @@ -1,13 +1,50 @@ package com.acme.backend.springboot.users; +import static org.hamcrest.CoreMatchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +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.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.c4_soft.springaddons.security.oauth2.test.annotations.OpenIdClaims; +import com.c4_soft.springaddons.security.oauth2.test.annotations.WithMockJwtAuth; @SpringBootTest +@AutoConfigureMockMvc class BackendApiSpringboot3AppTests { + @Autowired + MockMvc api; + + @Test + @WithAnonymousUser + void givenRequestIsAnonymous_whengetUsersMe_thenUnauthorized() throws Exception { + api.perform(get("/api/users/me")).andExpectAll(status().isUnauthorized()); + } + + @Test + @WithMockJwtAuth(claims = @OpenIdClaims(sub = "Tonton Pirate")) + void givenUserIsNotGrantedWithAccess_whengetUsersMe_thenForbidden() throws Exception { + // @formatter:off + api.perform(get("/api/users/me")) + .andExpect(status().isForbidden()); + // @formatter:on + } + @Test - void contextLoads() { + @WithMockJwtAuth(authorities = { "ROLE_ACCESS" }, claims = @OpenIdClaims(sub = "Tonton Pirate")) + void givenUserIsGrantedWithAccess_whengetUsersMe_thenOk() throws Exception { + // @formatter:off + api.perform(get("/api/users/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message", is("Hello Tonton Pirate"))); + // @formatter:on } } 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..2cec6ae8 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,17 +8,24 @@ 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.security.test.context.support.WithAnonymousUser; import org.springframework.test.web.servlet.MockMvc; +import com.acme.backend.springboot.users.config.WebSecurityConfig; +import com.acme.backend.springboot.users.support.keycloak.KeycloakJwtAuthenticationConverter; import com.c4_soft.springaddons.security.oauth2.test.annotations.OpenIdClaims; import com.c4_soft.springaddons.security.oauth2.test.annotations.WithMockJwtAuth; @WebMvcTest(controllers = UsersController.class) +@Import({ WebSecurityConfig.class, KeycloakJwtAuthenticationConverter.class }) class UsersControllerTest { + @Autowired MockMvc api; @Test + @WithAnonymousUser void givenRequestIsAnonymous_whengetUsersMe_thenUnauthorized() throws Exception { api.perform(get("/api/users/me")).andExpectAll(status().isUnauthorized()); } @@ -28,8 +35,7 @@ void givenRequestIsAnonymous_whengetUsersMe_thenUnauthorized() throws Exception void givenUserIsNotGrantedWithAccess_whengetUsersMe_thenForbidden() throws Exception { // @formatter:off api.perform(get("/api/users/me")) - // should be forbidden as user does is not granted with "ROLE_ACCESS" - .andExpect(status().isOk()); + .andExpect(status().isForbidden()); // @formatter:on }