Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.callv2.drive.infrastructure.configuration.properties.cors;

import java.util.List;

public class CorsConfigurationProperties {

private String pattern;
private List<String> allowedOriginsPatterns;
private List<String> allowedMethods;
private List<String> allowedHeaders;
private boolean allowCredentials;

public String getPattern() {
return pattern;
}

public void setPattern(String pattern) {
this.pattern = pattern;
}

public List<String> getAllowedOriginsPatterns() {
return allowedOriginsPatterns;
}

public void setAllowedOriginsPatterns(List<String> allowedOriginsPatterns) {
this.allowedOriginsPatterns = allowedOriginsPatterns;
}

public List<String> getAllowedMethods() {
return allowedMethods;
}

public void setAllowedMethods(List<String> allowedMethods) {
this.allowedMethods = allowedMethods;
}

public List<String> getAllowedHeaders() {
return allowedHeaders;
}

public void setAllowedHeaders(List<String> allowedHeaders) {
this.allowedHeaders = allowedHeaders;
}

public boolean isAllowCredentials() {
return allowCredentials;
}

public void setAllowCredentials(boolean allowCredentials) {
this.allowCredentials = allowCredentials;
}

}
Original file line number Diff line number Diff line change
@@ -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<Jwt, Collection<GrantedAuthority>> {

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<GrantedAuthority> 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<String> extractResourceRoles(final Jwt jwt) {

final Function<Map.Entry<String, Object>, Stream<String>> mapResource = resource -> {
final var key = resource.getKey();
@SuppressWarnings("rawtypes")
final LinkedTreeMap value = (LinkedTreeMap) resource.getValue();
@SuppressWarnings("unchecked")
final var roles = (Collection<String>) value.get(ROLES);
return roles.stream().map(role -> key.concat(SEPARATOR).concat(role));
};

final Function<Set<Map.Entry<String, Object>>, Collection<String>> 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<String> extractRealmRoles(final Jwt jwt) {
return Optional.ofNullable(jwt.getClaimAsMap(REALM_ACCESS))
.map(resource -> (Collection<String>) resource.get(ROLES))
.orElse(Collections.emptyList())
.stream();
}
}
Original file line number Diff line number Diff line change
@@ -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<Jwt, AbstractAuthenticationToken> {

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<? extends GrantedAuthority> extractAuthorities(final Jwt jwt) {
return this.authoritiesConverter.convert(jwt);
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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/**")
Expand All @@ -63,76 +54,39 @@ public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws E
.build();
}

static class KeycloakJwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {

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<? extends GrantedAuthority> extractAuthorities(final Jwt jwt) {
return this.authoritiesConverter.convert(jwt);
}
return source;
}

static class KeycloakAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

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<GrantedAuthority> 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<String> extractResourceRoles(final Jwt jwt) {

final Function<Map.Entry<String, Object>, Stream<String>> mapResource = resource -> {
final var key = resource.getKey();
@SuppressWarnings("rawtypes")
final LinkedTreeMap value = (LinkedTreeMap) resource.getValue();
@SuppressWarnings("unchecked")
final var roles = (Collection<String>) value.get(ROLES);
return roles.stream().map(role -> key.concat(SEPARATOR).concat(role));
};

final Function<Set<Map.Entry<String, Object>>, Collection<String>> 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<String> allowedOriginsPatterns,
final List<String> allowedMethods,
final List<String> 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<String> extractRealmRoles(final Jwt jwt) {
return Optional.ofNullable(jwt.getClaimAsMap(REALM_ACCESS))
.map(resource -> (Collection<String>) resource.get(ROLES))
.orElse(Collections.emptyList())
.stream();
}
@Bean
@ConfigurationProperties("security.cors")
CorsConfigurationProperties corsConfigurationProperties() {
return new CorsConfigurationProperties();
}

}
8 changes: 8 additions & 0 deletions infrastructure/src/main/resources/application-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down