Skip to content

Instantly share code, notes, and snippets.

@diyfr
Last active December 3, 2021 08:05
Show Gist options
  • Save diyfr/71519d0d3ac45b16fc33a30a120314e2 to your computer and use it in GitHub Desktop.
Save diyfr/71519d0d3ac45b16fc33a30a120314e2 to your computer and use it in GitHub Desktop.
Springboot security & OIDC avec IDP Tiers (Keycloak), SpringDoc

Ce qui est proposé dans ce Gist

Sur un serveur Springboot, sécuriser toutes ses api en vérifiant la validité d'un jeton au format JWT(fourni par exemple par Keycloak).
Avoir la possibilité de désactiver simplement cette sécurisation grâce à une propriété dans le fichier application.yml
Configuration de springdoc.

Ajouter la dépendance dans POM.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>

Ajouter une classe dans le package config par exemple

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;

import java.util.Collections;
import java.util.Map;

@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
    protected String issuerUri;

    @Value("${spring.security.oauth2.resourceserver.jwt.audience}")
    protected String audience;

    @Value("${spring.security.oauth2.resourceserver.jwt.sub-replace-by}")
    protected String subReplaceBy;

    @Value("${spring.security.enabled:true}")
    protected boolean enabledSecurity;

    @Value("${spring.security.oauth2.resourceserver.jwt.roles.resource-name:}")
    protected String resourceName;

    @Value("${spring.security.oauth2.resourceserver.jwt.roles.for-realm:true}")
    protected boolean roleForRealm;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        if (enabledSecurity)
            http
                .sessionManagement(sessionManagement ->
                    sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeRequests()
                .antMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                .antMatchers(HttpMethod.GET, "/api/**").authenticated()
                .and()
                .oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter());
        else
            http
                .authorizeRequests()
                .antMatchers("/**").permitAll()
                .and().csrf().disable();
    }

    @Bean
    JwtDecoder jwtDecoder() {
        NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder) JwtDecoders.fromOidcIssuerLocation(issuerUri);
        OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(audience);
        OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
        OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
        jwtDecoder.setJwtValidator(withAudience);
        jwtDecoder.setClaimSetConverter(new ReplaceSubClaimAdapter());
        return jwtDecoder;
    }

    JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter(roleForRealm, resourceName));
        return jwtAuthenticationConverter;
    }

    class ReplaceSubClaimAdapter implements Converter<Map<String, Object>, Map<String, Object>> {

        private final MappedJwtClaimSetConverter delegate = MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());

        @Override
        public Map<String, Object> convert(Map<String, Object> claims) {
            Map<String, Object> convertedClaims = this.delegate.convert(claims);
            String substitueValue = (String) convertedClaims.get(subReplaceBy);
            convertedClaims.put("sub", substitueValue);
            return convertedClaims;
        }
    }

On ajoute la classe qui va permettre de valider l'audience

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;

public class AudienceValidator implements OAuth2TokenValidator<Jwt> {

    private final String audience;

    public AudienceValidator(String aud) {
        this.audience = aud;
    }

    @Override
    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        if (jwt.getAudience().contains(audience)) {
            return OAuth2TokenValidatorResult.success();
        }
        OAuth2Error error = new OAuth2Error("invalid token", "The required audience is missing", null);
        return OAuth2TokenValidatorResult.failure(error);
    }
}

et la classe qui va substituer le sub de keycloak par un élément définissant l'utilisateur:

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 java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

    private String resourceName;
    private boolean roleForRealm = true;

    public static final String REALM_ACCESS = "realm_access";
    public static final String RESOURCE_ACCESS = "resource_access";
    public static final String ROLES = "roles";
    public static final String ROLE_PREFIX = "ROLE_";

    public KeycloakRealmRoleConverter() {
    }

    public KeycloakRealmRoleConverter(boolean roleForRealm, String resourceName) {
        this.roleForRealm = roleForRealm;
        this.resourceName = resourceName;
    }

    @SuppressWarnings("unchecked")
    @Override
    public Collection<GrantedAuthority> convert(final Jwt jwt) {
        Map<String, Object> access;
        if (!roleForRealm && resourceName != null) {
            access = (Map<String, Object>) jwt.getClaims().get(RESOURCE_ACCESS);
            if (access.containsKey(resourceName)) {
                access = (Map<String, Object>) access.get(resourceName);
            }
        } else {
            access = (Map<String, Object>) jwt.getClaims().get(REALM_ACCESS);
        }
        return ((List<String>) access.get(ROLES)).stream()
            .map(roleName -> ROLE_PREFIX + roleName.toUpperCase())
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());
    }
}

Ajouter les propriétés dans le fichier application.yml (remplacer les valeurs SERVER & REALM par les votres)

spring:
    security:
        enabled: true
        oauth2:
            resourceserver:
                jwt:
                    issuer-uri: https://SERVER/auth/realms/REALM
                    audience: MyDemo
                    sub-replace-by: email  # On remplace la valeur sub du claim par l'email
                    roles:
                        resource-name: agentk  # Optionnel Le rôle est basé sur l'agent et non sur le realm  
                        for-realm: false  # True par défaut. Par défaut on va chercher les rôles sur le Realm
springdoc:
    swagger-ui:
        oauth:
            clientId: MyDemo  #Valeur par défaut renseignée dans la fenêtre d'autorisation

Springdoc

Ajouter la dépendance Springdoc dans POM.xml

        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-security</artifactId>
            <version>${springdoc.version}</version>
        </dependency>

Modifier la configuration de springdoc pour appliquer la demande du jeton sur toutes les opérations.

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.*;
import io.swagger.v3.oas.models.servers.Server;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.Arrays;

@Configuration
public class SpringDocConfig {


    private static final Logger log = LoggerFactory.getLogger(SpringDocConfig.class);

    @Value("${server.version}")
    String version;

    @Value("${server.oasBaseUrl}")
    String serverUrl;

    @Value("${server.oasDescription}")
    String serverDescription;

    @Value("${spring.security.enabled:true}")
    boolean enabledSecurity;
    
    @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
    String authUrl;



    @Bean
    public OpenAPI springbootOpenAPI() {
        OpenAPI oai = new OpenAPI()
            .info(new Info().title("MyAPI")
                .description("Serveur d'API")
                .version(version)
                .license(new License().name("EULA")
                    .url("https://SERVER/EULA")));
        if (serverUrl != null && !serverUrl.isEmpty()) {
            Server server = new Server();
            server.setUrl(serverUrl);
            if (serverDescription != null && !serverDescription.isEmpty()) {
                server.setDescription(serverDescription);
            }
            if (oai.getServers() == null) {
                oai.setServers(new ArrayList<>(1));
            }
            oai.getServers().add(server);
        }
        if (enabledSecurity) {
            oai
                    //.addSecurityItem(new SecurityRequirement().addList(securitySchemeName))
                    .components(new Components()
                            .addSecuritySchemes("oauth", new SecurityScheme()
                                    .type(SecurityScheme.Type.OAUTH2)
                                    .description("Keycloak Authentification")
                                    .flows(new OAuthFlows()
                                            .implicit(new OAuthFlow()
                                                    .authorizationUrl(authUrl + "/protocol/openid-connect/auth")
                                                    .refreshUrl(authUrl + "/protocol/openid-connect/token")
                                                    .tokenUrl(authUrl + "/protocol/openid-connect/token")
                                                    .scopes(new Scopes())
                                            )))
                            .addSecuritySchemes("api_key",  // Pour une injection manuelle du jeton keycloak
                                    new SecurityScheme()
                                            .name("api_key")
                                            .type(SecurityScheme.Type.HTTP)
                                            .scheme("Bearer")
                                            .bearerFormat("JWT")

                            ))

                    .security(Arrays.asList(
                            new SecurityRequirement().addList("oauth"),
                            new SecurityRequirement().addList("api_key")));
        }
        log.info("OpenApi Documentation initialisé");
        return oai;
    }
}

Pour désactiver la demande du jeton sur une méthode, il suffit d'ajouter l'annotation suivante

    @SecurityRequirements(value = {})

Pour injecter l'utilisateur dans une méthode d'accès à une ressource, il suffit d'injecter @AuthenticationPrincipal

 @Operation(summary = "Retourne une liste d'item")
    public List<Item> getAllItem( @Parameter(description = "Nom de l'item")
                                  @RequestParam(name = "itemName", required = false) String itemName,
                               @AuthenticationPrincipal Jwt principal) {
                               }

On peut aussi récupèrer l'utilisateur authentifié

 Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment