From 9bc2a84d924d0c1d1fb14b126c1327f30d45ea81 Mon Sep 17 00:00:00 2001 From: jhonatapers Date: Tue, 27 May 2025 20:20:33 -0300 Subject: [PATCH] feat: add CORS configuration properties to support cross-origin requests --- .env.example | 6 + .../cors/CorsConfigurationProperties.java | 53 ++++++++ .../KeycloakAuthoritiesConverter.java | 67 ++++++++++ .../security/KeycloakJwtConverter.java | 32 +++++ .../security/SecurityConfig.java | 122 ++++++------------ .../src/main/resources/application-env.yml | 8 ++ 6 files changed, 204 insertions(+), 84 deletions(-) create mode 100644 infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/properties/cors/CorsConfigurationProperties.java create mode 100644 infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/KeycloakAuthoritiesConverter.java create mode 100644 infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/KeycloakJwtConverter.java diff --git a/.env.example b/.env.example index 82c9763c..0be60ad2 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,12 @@ SERVER_PORT=8080 FORWARD_HEADERS_STRATEGY=framework #none for no proxy, framework for Spring Cloud Gateway, native for native proxy REQUEST_TIMEOUT=60000 +CORS_PATTERN=/** +CORS_ALLOWED_ORIGINS=* +CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS +CORS_ALLOWED_HEADERS=* +CORS_ALLOW_CREDENTIALS=true + KEYCLOAK_HOST=http://localhost:8090 KEYCLOAK_REALM=heroes diff --git a/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/properties/cors/CorsConfigurationProperties.java b/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/properties/cors/CorsConfigurationProperties.java new file mode 100644 index 00000000..6ea40d80 --- /dev/null +++ b/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/properties/cors/CorsConfigurationProperties.java @@ -0,0 +1,53 @@ +package com.callv2.drive.infrastructure.configuration.properties.cors; + +import java.util.List; + +public class CorsConfigurationProperties { + + private String pattern; + private List allowedOriginsPatterns; + private List allowedMethods; + private List allowedHeaders; + private boolean allowCredentials; + + public String getPattern() { + return pattern; + } + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public List getAllowedOriginsPatterns() { + return allowedOriginsPatterns; + } + + public void setAllowedOriginsPatterns(List allowedOriginsPatterns) { + this.allowedOriginsPatterns = allowedOriginsPatterns; + } + + public List getAllowedMethods() { + return allowedMethods; + } + + public void setAllowedMethods(List allowedMethods) { + this.allowedMethods = allowedMethods; + } + + public List getAllowedHeaders() { + return allowedHeaders; + } + + public void setAllowedHeaders(List allowedHeaders) { + this.allowedHeaders = allowedHeaders; + } + + public boolean isAllowCredentials() { + return allowCredentials; + } + + public void setAllowCredentials(boolean allowCredentials) { + this.allowCredentials = allowCredentials; + } + +} diff --git a/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/KeycloakAuthoritiesConverter.java b/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/KeycloakAuthoritiesConverter.java new file mode 100644 index 00000000..c77d950c --- /dev/null +++ b/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/KeycloakAuthoritiesConverter.java @@ -0,0 +1,67 @@ +package com.callv2.drive.infrastructure.configuration.security; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import com.nimbusds.jose.shaded.gson.internal.LinkedTreeMap; + +public class KeycloakAuthoritiesConverter implements Converter> { + + private static final String REALM_ACCESS = "realm_access"; + private static final String ROLES = "roles"; + private static final String RESOURCE_ACCESS = "resource_access"; + private static final String SEPARATOR = "_"; + private static final String ROLE_PREFIX = "ROLE_"; + + @Override + public Collection convert(final Jwt jwt) { + final var realmRoles = extractRealmRoles(jwt); + final var resourceRoles = extractResourceRoles(jwt); + + return Stream.concat(realmRoles, resourceRoles) + .map(role -> new SimpleGrantedAuthority(ROLE_PREFIX + role.toUpperCase())) + .collect(Collectors.toSet()); + } + + private Stream extractResourceRoles(final Jwt jwt) { + + final Function, Stream> mapResource = resource -> { + final var key = resource.getKey(); + @SuppressWarnings("rawtypes") + final LinkedTreeMap value = (LinkedTreeMap) resource.getValue(); + @SuppressWarnings("unchecked") + final var roles = (Collection) value.get(ROLES); + return roles.stream().map(role -> key.concat(SEPARATOR).concat(role)); + }; + + final Function>, Collection> mapResources = resources -> resources + .stream() + .flatMap(mapResource) + .toList(); + + return Optional.ofNullable(jwt.getClaimAsMap(RESOURCE_ACCESS)) + .map(resources -> resources.entrySet()) + .map(mapResources) + .orElse(Collections.emptyList()) + .stream(); + } + + @SuppressWarnings("unchecked") + private Stream extractRealmRoles(final Jwt jwt) { + return Optional.ofNullable(jwt.getClaimAsMap(REALM_ACCESS)) + .map(resource -> (Collection) resource.get(ROLES)) + .orElse(Collections.emptyList()) + .stream(); + } +} diff --git a/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/KeycloakJwtConverter.java b/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/KeycloakJwtConverter.java new file mode 100644 index 00000000..2bf950b1 --- /dev/null +++ b/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/KeycloakJwtConverter.java @@ -0,0 +1,32 @@ +package com.callv2.drive.infrastructure.configuration.security; + +import java.util.Collection; + +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.jwt.JwtClaimNames; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +public class KeycloakJwtConverter implements Converter { + + private final KeycloakAuthoritiesConverter authoritiesConverter; + + public KeycloakJwtConverter() { + this.authoritiesConverter = new KeycloakAuthoritiesConverter(); + } + + @Override + public AbstractAuthenticationToken convert(final Jwt jwt) { + return new JwtAuthenticationToken(jwt, extractAuthorities(jwt), extractPrincipal(jwt)); + } + + private String extractPrincipal(final Jwt jwt) { + return jwt.getClaimAsString(JwtClaimNames.SUB); + } + + private Collection extractAuthorities(final Jwt jwt) { + return this.authoritiesConverter.convert(jwt); + } +} diff --git a/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/SecurityConfig.java b/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/SecurityConfig.java index b8b94fdb..b12ca7be 100644 --- a/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/SecurityConfig.java +++ b/infrastructure/src/main/java/com/callv2/drive/infrastructure/configuration/security/SecurityConfig.java @@ -1,31 +1,21 @@ package com.callv2.drive.infrastructure.configuration.security; -import java.util.Collection; -import java.util.Collections; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AbstractAuthenticationToken; 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.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtClaimNames; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import com.nimbusds.jose.shaded.gson.internal.LinkedTreeMap; +import com.callv2.drive.infrastructure.configuration.properties.cors.CorsConfigurationProperties; @Configuration @EnableWebSecurity @@ -35,11 +25,12 @@ public class SecurityConfig { private static final String ROLE_ADMIN = "ADMINISTRADOR"; @Bean - public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { + SecurityFilterChain securityFilterChain( + final HttpSecurity http, + final CorsConfigurationSource corsConfigurationSource) throws Exception { return http - .csrf(csrf -> { - csrf.disable(); - }) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) + .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(authorize -> { authorize .requestMatchers("admin/**") @@ -63,76 +54,39 @@ public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws E .build(); } - static class KeycloakJwtConverter implements Converter { - - private final KeycloakAuthoritiesConverter authoritiesConverter; - - public KeycloakJwtConverter() { - this.authoritiesConverter = new KeycloakAuthoritiesConverter(); - } + @Bean + CorsConfigurationSource corsConfigurationSource(final CorsConfigurationProperties corsProperties) { - @Override - public AbstractAuthenticationToken convert(final Jwt jwt) { - return new JwtAuthenticationToken(jwt, extractAuthorities(jwt), extractPrincipal(jwt)); - } + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - private String extractPrincipal(final Jwt jwt) { - return jwt.getClaimAsString(JwtClaimNames.SUB); - } + source.registerCorsConfiguration( + corsProperties.getPattern(), + corsConfiguration( + corsProperties.getAllowedOriginsPatterns(), + corsProperties.getAllowedMethods(), + corsProperties.getAllowedHeaders(), + corsProperties.isAllowCredentials())); - private Collection extractAuthorities(final Jwt jwt) { - return this.authoritiesConverter.convert(jwt); - } + return source; } - static class KeycloakAuthoritiesConverter implements Converter> { - - private static final String REALM_ACCESS = "realm_access"; - private static final String ROLES = "roles"; - private static final String RESOURCE_ACCESS = "resource_access"; - private static final String SEPARATOR = "_"; - private static final String ROLE_PREFIX = "ROLE_"; - - @Override - public Collection convert(final Jwt jwt) { - final var realmRoles = extractRealmRoles(jwt); - final var resourceRoles = extractResourceRoles(jwt); - - return Stream.concat(realmRoles, resourceRoles) - .map(role -> new SimpleGrantedAuthority(ROLE_PREFIX + role.toUpperCase())) - .collect(Collectors.toSet()); - } - - private Stream extractResourceRoles(final Jwt jwt) { - - final Function, Stream> mapResource = resource -> { - final var key = resource.getKey(); - @SuppressWarnings("rawtypes") - final LinkedTreeMap value = (LinkedTreeMap) resource.getValue(); - @SuppressWarnings("unchecked") - final var roles = (Collection) value.get(ROLES); - return roles.stream().map(role -> key.concat(SEPARATOR).concat(role)); - }; - - final Function>, Collection> mapResources = resources -> resources - .stream() - .flatMap(mapResource) - .toList(); - - return Optional.ofNullable(jwt.getClaimAsMap(RESOURCE_ACCESS)) - .map(resources -> resources.entrySet()) - .map(mapResources) - .orElse(Collections.emptyList()) - .stream(); - } + static CorsConfiguration corsConfiguration( + final List allowedOriginsPatterns, + final List allowedMethods, + final List allowedHeaders, + final boolean allowCredentials) { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(allowedOriginsPatterns); + configuration.setAllowedMethods(allowedMethods); + configuration.setAllowedHeaders(allowedHeaders); + configuration.setAllowCredentials(allowCredentials); + return configuration; + } - @SuppressWarnings("unchecked") - private Stream extractRealmRoles(final Jwt jwt) { - return Optional.ofNullable(jwt.getClaimAsMap(REALM_ACCESS)) - .map(resource -> (Collection) resource.get(ROLES)) - .orElse(Collections.emptyList()) - .stream(); - } + @Bean + @ConfigurationProperties("security.cors") + CorsConfigurationProperties corsConfigurationProperties() { + return new CorsConfigurationProperties(); } } diff --git a/infrastructure/src/main/resources/application-env.yml b/infrastructure/src/main/resources/application-env.yml index 6a0f0baf..1ad58a86 100644 --- a/infrastructure/src/main/resources/application-env.yml +++ b/infrastructure/src/main/resources/application-env.yml @@ -2,6 +2,14 @@ server: port: ${SERVER_PORT} forward-headers-strategy: ${FORWARD_HEADERS_STRATEGY:none} +security: + cors: + pattern: ${CORS_PATTERN} + allowed-origins: ${CORS_ALLOWED_ORIGINS} + allowed-methods: ${CORS_ALLOWED_METHODS} + allowed-headers: ${CORS_ALLOWED_HEADERS} + allow-credentials: ${CORS_ALLOW_CREDENTIALS} + keycloak: realm: ${KEYCLOAK_REALM} host: ${KEYCLOAK_HOST}