Pourquoi un énième article sur l’intégration de Keycloak avec Spring ?

Une simple recherche sur la toile atteste de la richesse des articles sur ce sujet. Malgré tout, ces articles introduisent (le plus souvent) uniquement le connecteur Spring Boot , dans un contexte d’application monolithe, avec une configuration simple (peu adaptée aux développements d’entreprise).

J’ai eu envie de proposer un tutoriel offrant une approche différente :

  • Sécuriser une API stateless (s’affranchir de la redirection d’URL qui propose une page d’authentification Keycloak – adaptée aux web-ui) avec un token JWT
  • Utiliser l’auto-configuration de Keycloak à l’aide du connecteur Spring Boot
  • Bénéficier d’une souplesse sur la configuration de la sécurité grâce au connecteur Spring Security
  • Mutualiser cette configuration pour la ré-utiliser dans les TUs

Pour le bon déroulement de cet article (et des suivants), les parties #1 et #2 doivent être terminées.

Le code source présenté est disponible sur le repository Github d’Ineat. Des branches sont disponibles par version de Keycloak / Spring utilisé.

Je vous propose, tout au long de ce billet, la possibilité de passer (ou pas) l’étape de coding. Cela nécessite l’initialisation d’un repository git local :

git init
git remote add solutions https://github.com/ineat/spring-keycloak-tutorials.git
git fetch solutions

FICHE TECHNIQUE

Keycloak : 12.0.2

Spring Boot : 2.4.2

PRE-REQUIS

JAVA 11

Spring Framework (MVC / Security / Test ) / Spring Boot

Initialisation du projet Spring

Rien de tel que start.spring.io (Spring Initializr) pour démarrer.

Passer cette étape : git cherry-pick 2bd5e16

Vous aurez besoin

  • du module WEB (Spring MVC) afin de pouvoir exposer des APIs
  • du module Security (évidemment), pour sécuriser nos apis
  • les modules Validation, DevTools & Lombok pour des raisons pratiques

👉Cliquez ici pour pré-renseigner les champs de start.spring.io

Une fois le projet généré, il est nécessaire de l’importer dans votre EDI favori.

Création d’un contrôleur pour exposer des APIS

Afin de pouvoir s’amuser, nous avons besoin de l’existence de quelques endpoints d’API.

Partons de ce schéma :

  • les paths GET / et GET /unsecured seront accessibles à n’importe qui
  • le path GET /admin ne sera accessible qu’à nos utilisateurs ayant un rôle ADMIN
  • le path GET /user ne sera accessible qu’à nos utilisateurs ayant un rôle USER

Passer cette étape : git cherry-pick 6f3e732

Transposer avec Spring MVC :

package com.ineat.tutorials.springkeycloaktutorials;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

/**
 * A Sample controller used to expose Keycloak Secured routes
 */
@RestController
public class SpringKeycloakTutorialsApisController {

    @RequestMapping(path = {"/", "/unsecured"})
    public String noSecuredEndpoint(){
        return "This is an unsecured endpoint payload";
    }


    @RequestMapping(
        path = "/admin",
        method = RequestMethod.GET, // @RequestMapping default assignment
       produces = MediaType.APPLICATION_JSON_VALUE // TIP : use org.springframework.http.MediaType for MimeType instead of hard coded value
    )
    public String adminSecuredEndpoint(){
        return "This is an ADMIN endpoint payload";
    }

    @RequestMapping("/user")
    public String userSecuredEndpoint(){
        return "This is an USER endpoint payload";
    }
}

A ce stade, puisque nous utilisons spring-boot et le starter security, les endpoints HTTP de notre application sont tous sécurisés par défaut

Afin de pouvoir tester que nos routes sont correctement exposées, utilisez l’utilisateur par défaut et le mot de passe généré par Spring pour accéder aux trois pages ci-dessous.

Démarrez votre application (via votre EDI, un mvn spring-boot:run, etc) afin de pouvoir tester l’accès à nos endpoints (port 8080 par défaut) et constatez que vous avez correctement accès à ces urls (via navigateur, cUrl, wget, etc) :

Configuration Keycloak / Spring Boot

Ajout des dépendances

Afin de bénéficier des avantages de Keycloak avec Spring Boot, il est nécessaire de

  • Déclarer la dépendance du BOM keycloak-adapter-bom pour bénéficier de la gestion des dépendances Keycloak
  • Déclarer la dépendance keycloak-spring-boot-starter

Passer cette étape : git cherry-pick 70b6551

Documentation de référence

  • Ajout de la version de Keycloak :
<properties>

  ...

  <keycloak.version>12.0.2</keycloak.version>
</properties>

Parceque nous codons proprement, pensez à référencer la version de Keycloak dans le bloc <properties>

  • Ajout du BOM :
<dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.keycloak.bom</groupId>
        <artifactId>keycloak-adapter-bom</artifactId>
        <version>${keycloak.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  • Ajout du starter boot Keycloak :
<dependency>
  <groupId>org.keycloak</groupId>
  <artifactId>keycloak-spring-boot-starter</artifactId>
</dependency>

Désormais, vous avez normalement accès à une multitude de clés de configuration, disponibles depuis votre fichier application.properties

Liste des clés de configuration (utilisation de l’auto-complétion de votre EDI favori)

 Ajout de la configuration

Passer cette étape : git cherry-pick 70b6551

Nous allons à présent configurer notre application Spring pour quelle puisse communiquer avec Keycloak, en s’appuyant sur les clés de configuration disponibles.

Documentation de référence liée aux options de configuration

Exemple de configuration Keycloak – Spring Boot

  • Accès à notre serveur Keycloak :
########################################
# Spring Boot / Keycloak Configuration
########################################
keycloak.auth-server-url=http://localhost:8180/auth
keycloak.realm=ineat-realm
keycloak.resource=ineat-api

NOTE : Les valeurs des clés de configuration sont celles configurées dans l’article #2 de cette série

NOTE 2 : le secret de votre client-id est affiché dans l’onglet Credentials de votre domaine :

Récupération du client-secret

NOTE 3 : le secret n’est pas obligatoire si Keycloak et votre application se trouve sur le même domaine réseau (ici localhost)

  • Ajustement pour le mode Bearer :

Pour rappel, notre API ne sera contactable qu’avec un BEARER token, nous allons donc affiner le paramétrage :

#we do not write a web-app - so no login page and redirects are necessary
keycloak.bearer-only=true

Issue de la documentation officielle :

bearer-only

This should be set to true for services. If enabled the adapter will not attempt to authenticate users, but only verify bearer tokens. This is OPTIONAL. The default value is false.

Mise en place des contraintes de sécurité

Passer cette étape : git cherry-pick 008b9fc

Voilà selon moi la partie la plus interessante de ce tutoriel, mettre en place les contraintes de sécurité pour vos endpoints.

Comme je l’ai mentionné en début d’article, l’objectif de ce chapitre est d’adopter une configuration JAVA (via les capacités de Spring Security et de l’adapter Keycloak) afin de permettre (entre autres)

  • une flexibilité sur la configuration
  • de ré-exploiter ces contraintes du coté des tests unitaires afin de vous assurer une non-reg sur la sécurisation de vos endpoints

Voici ce que donne la sécurisation de nos endpoints en utilisant les capacités spring-boot du connecteur Keycloak  :

keycloak.securityConstraints[0].securityCollections[0].name = insecure endpoint
keycloak.securityConstraints[0].securityCollections[0].patterns[0] = /unsecured
keycloak.securityConstraints[0].securityCollections[0].patterns[1] = /

keycloak.security-constraints[1].authRoles[0]=USER
keycloak.security-constraints[1].securityCollections[0].patterns[0]=/user

keycloak.security-constraints[2].authRoles[0]=ADMIN
keycloak.security-constraints[2].securityCollections[0].patterns[0]=/admin

Les équipes de Keycloak proposent un grand nombre de connecteurs dont un adapter Spring Security. Comme le précise cette documentation, il est nécessaire

  • d’étendre la classe KeycloakWebSecurityConfigurerAdapterqui propose une implémentation par défaut (adaptée pour un contexte d’application proposant une MIRE de Login) du contexte de sécurité Spring
  • de préciser une stratégie d’authentification
  • de définir la sécurisation de vos endpoints

Un bogue référencé depuis la version 7 (et corrigé en 9) peut vous obliger à créer une configuration KeycloakSpringBootConfigResolver pour permettre l’utilisation d’un fichier de configurations :

/**
 * See <a href="https://issues.redhat.com/browse/KEYCLOAK-11282">Keycloak Issue</a>
 */
@Configuration
public class CustomKeycloakSpringBootConfigResolver extends KeycloakSpringBootConfigResolver {
    private final KeycloakDeployment keycloakDeployment;

    public CustomKeycloakSpringBootConfigResolver(KeycloakSpringBootProperties properties) {
        keycloakDeployment = KeycloakDeploymentBuilder.build(properties);
    }

    @Override
    public KeycloakDeployment resolve(HttpFacade.Request facade) {
        return keycloakDeployment;
    }
}

 

1 – Utilisation du KeycloakWebSecurityConfigurerAdapter

Commençons par créer une classe de configuration utilisant l’adapter de Keycloak :

public class SpringKeycloakTutorialsSecurityConfiguration {

    @KeycloakConfiguration
    @ConditionalOnProperty(name = "keycloak.enabled", havingValue = "true", matchIfMissing = true)
    public static class KeycloakConfigurationAdapter extends KeycloakWebSecurityConfigurerAdapter {
        ...
    }
}
  • L’utilisation d’une classe embarquée permet d’éviter des effets de bord quand nous souhaitons rendre une configuration activable
  • @ConditionalOnProperty(name = "keycloak.enabled", havingValue = "true", matchIfMissing = true) rend notre configuration activable/desactivable en utilisant la même clé de configuration que celle proposée par le connecteur Spring-Boot Keycloak.
  • @KeycloakConfiguration permet de définir notre classe comme étant une configuration spring-security (elle embarque les annotations @Configuration, @EnableWebSecurity et @ComponentScan(basePackageClasses = KeycloakSecurityComponents.class))

2 – Définition de la stratégie d’authentification

Compte tenu de notre approche STATELESS, nous n’avons pas besoin d’une gestion des sessions Keycloak :

public static class KeycloakConfigurationAdapter extends KeycloakWebSecurityConfigurerAdapter {
...
        /**
         * Defines the session authentication strategy.
         */
        @Bean
        @Override
        protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
            // required for bearer-only applications.
            return new NullAuthenticatedSessionStrategy();
        }
...
}

You must provide a session authentication strategy bean which should be of type RegisterSessionAuthenticationStrategy for public or confidential applications and NullAuthenticatedSessionStrategy for bearer-only applications.

Issue de la documentation officielle

3 – Définition de la stratégie de nommage des rôles

Par défaut, Spring s’appuie sur des noms de rôles préfixés par ROLE_. Pour éviter ce fonctionnement, il est nécessaire de spécifier à Spring d’utiliser le nom du rôle tel quel :

...
        /**
         * Registers the KeycloakAuthenticationProvider with the authentication manager.
         */
        @Autowired
        public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
            KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
            // simple Authority Mapper to avoid ROLE_
            keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
            auth.authenticationProvider(keycloakAuthenticationProvider);
        }
...

NOTE : le bean keycloakAuthenticationProvider() est pré-defini dans la classe KeycloakWebSecurityConfigurerAdapter

4 – Définition du resolver Spring Boot

Afin de bénéficier des avantages de la configuration Spring-boot dans notre configuration Spring-security, il est nécessaire de déclarer un bean de type KeycloakConfigResolver :

...
        /**
         * Required to handle spring boot configurations
         * @return
         */
        @Bean
        public KeycloakConfigResolver KeycloakConfigResolver() {
            return new KeycloakSpringBootConfigResolver();
        }

Un bogue référencé depuis la version 7 (et corrigé en 9) peut vous obliger à créer une configuration KeycloakSpringBootConfigResolver à la place du bean ci-dessus :

***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 1 of method setKeycloakSpringBootProperties in org.keycloak.adapters.springboot.KeycloakBaseSpringBootConfiguration required a bean of type 'org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver' that could not be found.


Action:

Consider defining a bean of type 'org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver' in your configuration.

Contournement à mettre en place :

/**
 * See <a href="https://issues.redhat.com/browse/KEYCLOAK-11282">Keycloak Issue</a>
 */
@Configuration
public class CustomKeycloakSpringBootConfigResolver extends KeycloakSpringBootConfigResolver {
    private final KeycloakDeployment keycloakDeployment;

    public CustomKeycloakSpringBootConfigResolver(KeycloakSpringBootProperties properties) {
        keycloakDeployment = KeycloakDeploymentBuilder.build(properties);
    }

    @Override
    public KeycloakDeployment resolve(HttpFacade.Request facade) {
        return keycloakDeployment;
    }
}

5 – Définition des matchers Spring Security

Documentation de référence Spring

La dernière étape consiste à définir comment protéger nos endpoints :

...
        /**
         * Configuration spécifique à keycloak (ajouts de filtres, etc)
         * @param http
         * @throws Exception
         */
        @Override
        protected void configure(HttpSecurity http) throws Exception
        {
            http
                    // disable csrf because of API mode
                    .csrf().disable()

                    .sessionManagement()
                     // use previously declared bean
                        .sessionAuthenticationStrategy(sessionAuthenticationStrategy())
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)


                    // keycloak filters for securisation
                    .and()
                        .addFilterBefore(keycloakPreAuthActionsFilter(), LogoutFilter.class)
                        .addFilterBefore(keycloakAuthenticationProcessingFilter(), X509AuthenticationFilter.class)
                        .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint())

                    // delegate logout endpoint to spring security

                    .and()
                        .logout()
                        .addLogoutHandler(keycloakLogoutHandler())
                        .logoutUrl("/logout").logoutSuccessHandler(
                        // logout handler for API
                        (HttpServletRequest request, HttpServletResponse response, Authentication authentication) ->
                                response.setStatus(HttpServletResponse.SC_OK)
                     )
                    .and()
                        // manage routes securisation here
                        .authorizeRequests().antMatchers(HttpMethod.OPTIONS).permitAll()


                        .antMatchers("/logout", "/", "/unsecured").permitAll()
                        .antMatchers("/user").hasRole("USER")
                        .antMatchers("/admin").hasRole("ADMIN")

                        .anyRequest().denyAll();


        }

Plus de précisions concernant cette configuration :

  • Ligne 16 : configuration de la chaine de sécurité pour qu’elle utilise notre politique de sesssion, déclarée précedemment
  • Lignes 22 à 24 : utilisation des filtres de sécurités fournit par Keycloak (qui permettent de valider les tokens à chaque appel, etc)
  • Lignes 29 à 35 : cette configuration n’est pas forcement nécessaire. Ici, nous redéfinissons le endpoint de déconnexion et la réponse de ce dernier en cas de succès (ligne 33).
  • Lignes 38 à 45 :
    • Ligne 45 : par défaut, tous nos endpoints sont sécurisés (et interdits à tous). Chaque règle définie surchargera cette règle par défaut 
    • Lignes 43 & 42 : sécurisation de nos endpoints /admin et /user aux utilisateurs ayant respectivement les rôles ADMIN et USER (définis précédemment)
    • Ligne 41 : pas de sécurité pour les endpoints /logout, / et /unsecured
    • Ligne 39 : il est nécessaire de ne pas sécuriser les appels via la méthode HTTP OPTIONS pour permettre à des frameworks comme Angular de récupérer les informations  d’un endpoint.

6 – Tester la sécurité de votre API

La sécurité est en place, il serait maintenant interessant de tester que ce que nous venons de mettre en place est fonctionnel. Vous rappelez vous du client Keycloak ineat-web que nous avons créé ici ? Il est temps de s’en servir en tant que client de notre API.

Pour tester rapidement les accès, nous allons utiliser le flow Resource Owner Password Credentials de OAuth, comme décrit dans la documentation Keycloak :

Si vous utilisez Postman, la collection est disponible à la racine du projet.

git cherry-pick c2732b0

  • Pour une demande de token d’utilisation lié à l’utilisateur  “ineat-user”
curl -X POST \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d 'username=ineat-user&password=password&grant_type=password' \
    -d 'client_id=ineat-web' \
    -d 'client_secret=038c7378-c478-4c5c-a616-c1ddf294cdb7' \
    "http://localhost:8180/auth/realms/ineat-realm/protocol/openid-connect/token"
  • Pour une demande de token d’utilisation lié à l’utilisateur  “ineat-admin”
curl -X POST \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d 'username=ineat-admin&password=password&grant_type=password' \
    -d 'client_id=ineat-web' \
    -d 'client_secret=038c7378-c478-4c5c-a616-c1ddf294cdb7' \
    "http://localhost:8180/auth/realms/ineat-realm/protocol/openid-connect/token"

La réponse de Keycloak devrait ressembler à ce payload :

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3bzc0OVZFNHZnbHpfeGRpUEM1aUlmZFdtamg4ZktnTHEzMzRuQmxxRDQ0In0.eyJqdGkiOiJlN2Y1ODE4Zi01MTkzLTRlMTctODQ5YS05OTdjZTg1Nzg5OWQiLCJleHAiOjE1MTIxNDI3NTUsIm5iZiI6MCwiaWF0IjoxNTEyMTQyNDU1LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgxODAvYXV0aC9yZWFsbXMvaW5lYXQtcmVhbG0iLCJhdWQiOiJpbmVhdC13ZWIiLCJzdWIiOiJjZTI1OTc1OC01Y2Q2LTQzMzktYmY0MC1iYzllZTEzZTk0ZTkiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJpbmVhdC13ZWIiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiI5MDE2N2IyNC1mY2UwLTQ3ZTctYTQ0NC1mM2VmZGFkMGM3NmEiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIiwiVVNFUiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInByZWZlcnJlZF91c2VybmFtZSI6ImluZWF0LXVzZXIifQ.QDWTKxjTrey-unJrgowzUMpGP9JsDc5pDAPUhtEKUzKxHdTTZnqFVcbvgX-Ksi_VyxVdG_A82CAGPVted4P_UMcxoemvm1MpeaoYD3I8CAAT9807LpnTgxHSfoTUk4XhdpYUtX-z9BGHiI1ZTgat83N-2CdgHNrVhdsSbuofXpX48RwwTW8iv7bIHfqhZSSecauko_zOdLd0N0HkVpUVKlbvULmkRHBTQkbMSksC-JWUQh6iPjDefhh7XItYatO9q72lpw_BnefA895dYAEzDSwztmHNZc_DvEhYK-GtjEnUJSymY_8OeR1wtpiasp52jppaTY-vWkXywfGqzM9FSg",
    "expires_in": 300,
    "refresh_expires_in": 1800,
    "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3bzc0OVZFNHZnbHpfeGRpUEM1aUlmZFdtamg4ZktnTHEzMzRuQmxxRDQ0In0.eyJqdGkiOiJhY2EyYWRkZC04ZDgzLTRjYWQtODRiMC1hOGQwNWRmZDczYTYiLCJleHAiOjE1MTIxNDQyNTUsIm5iZiI6MCwiaWF0IjoxNTEyMTQyNDU1LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgxODAvYXV0aC9yZWFsbXMvaW5lYXQtcmVhbG0iLCJhdWQiOiJpbmVhdC13ZWIiLCJzdWIiOiJjZTI1OTc1OC01Y2Q2LTQzMzktYmY0MC1iYzllZTEzZTk0ZTkiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoiaW5lYXQtd2ViIiwiYXV0aF90aW1lIjowLCJzZXNzaW9uX3N0YXRlIjoiOTAxNjdiMjQtZmNlMC00N2U3LWE0NDQtZjNlZmRhZDBjNzZhIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIiwiVVNFUiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX19.Y9MhRvydNufNIcFypE2N1FDXDW_UyvYmMwvTS0ZX04QArAOpSaVkOWNL_4oFVm8ZvqK3bkPSHxz-RBvJk2sDylSqYDjl3-8-ziiy-mh4pyhrJEKQPj92x0BpWimGTJRcNx9zt49rKbBPig50kYIqh3kgmPqz6TfE9FU5pJzUtFZzLM1XZYUoyd2Nf5FAjIa-7m3qfNOw0FvUpbJx_GwSHzv00DiiVHLC_ts40vZvxTIGq14UYsXYgLJFNe_MBzXbqeE0Cpb71sIDbsWRFW3vapDWJNBbnf_fpEiQ9qSNdYk5HsuHLbxLiv98LnVLGjX1CMhNu8jlcf7eDBc1J5t8ww",
    "token_type": "bearer",
    "not-before-policy": 0,
    "session_state": "90167b24-fce0-47e7-a444-f3efdad0c76a"
}

C’est l’access_token renvoyé par Keycloak que vous devez utiliser lors de l’appel à l’api. Il correspond au fameux bearer attendu dans le header Authorization.

Vous pouvez décoder le contenu d’un token JWT sur le site officiel de la spec (via le decoder) … ce qui parait alors plus clair :

La partie visible du token (claims) contient des informations intéressantes

Essayez à présent de contacter chacun des endpoints avec votre bearer user. Seul le endpoint /admin devrait vous renvoyer un code HTTP 401 :

curl -X GET \
  http://localhost:8080/user \
  -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3bzc0OVZFNHZnbHpfeGRpUEM1aUlmZFdtamg4ZktnTHEzMzRuQmxxRDQ0In0.eyJqdGkiOiJhNWRlODViNS02M2ZmLTRjMWQtYTRlOC05MjYxNDIyZWIxYzciLCJleHAiOjE1MTIxNDMyNDYsIm5iZiI6MCwiaWF0IjoxNTEyMTQyOTQ2LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgxODAvYXV0aC9yZWFsbXMvaW5lYXQtcmVhbG0iLCJhdWQiOiJpbmVhdC13ZWIiLCJzdWIiOiJjZTI1OTc1OC01Y2Q2LTQzMzktYmY0MC1iYzllZTEzZTk0ZTkiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJpbmVhdC13ZWIiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiJiYTAzNDJlZS1hZDViLTRkNjktYWU3NS0yMjgzYzQ0MWFkMjEiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIiwiVVNFUiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInByZWZlcnJlZF91c2VybmFtZSI6ImluZWF0LXVzZXIifQ.POJ8W3oCVr1XYYxBzZJ6_BIPQlYxE9_5ZztTbEY8DLjgFgxNxNn0K7OGCd5Vo1naxaM6qq8Vwrxr9VJEZNiG1jJeuowsOooSlDk5HRB3anOCYEMzEcFhY8tPBMK_TLJDR3pSDes_qiF5F_XFGkbu4Jw4WkG6SigZSszwIrCftf4Hc0CWFA_umGqfu06_M3W18Idla4P7XQqLy3uBLQPR1CE_jd4xArtQzhyphowyZnJn058l5bj6fGCaB9qtlEezn_xMSUBq4BAWPOzL2Wl-W5it3Saf8n44UpxCE1XGBLMXqXwp4uOpIZ8J5RbJFGQ6FaR9zPdHUplnxjiSsGfITg'
This is an USER endpoint payload

A contrario, si un profil USER tente de contacter /admin :

curl -X GET \
  http://localhost:8080/admin \
  -H 'authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3bzc0OVZFNHZnbHpfeGRpUEM1aUlmZFdtamg4ZktnTHEzMzRuQmxxRDQ0In0.eyJqdGkiOiJhNWRlODViNS02M2ZmLTRjMWQtYTRlOC05MjYxNDIyZWIxYzciLCJleHAiOjE1MTIxNDMyNDYsIm5iZiI6MCwiaWF0IjoxNTEyMTQyOTQ2LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgxODAvYXV0aC9yZWFsbXMvaW5lYXQtcmVhbG0iLCJhdWQiOiJpbmVhdC13ZWIiLCJzdWIiOiJjZTI1OTc1OC01Y2Q2LTQzMzktYmY0MC1iYzllZTEzZTk0ZTkiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJpbmVhdC13ZWIiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiJiYTAzNDJlZS1hZDViLTRkNjktYWU3NS0yMjgzYzQ0MWFkMjEiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVtYV9hdXRob3JpemF0aW9uIiwiVVNFUiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInByZWZlcnJlZF91c2VybmFtZSI6ImluZWF0LXVzZXIifQ.POJ8W3oCVr1XYYxBzZJ6_BIPQlYxE9_5ZztTbEY8DLjgFgxNxNn0K7OGCd5Vo1naxaM6qq8Vwrxr9VJEZNiG1jJeuowsOooSlDk5HRB3anOCYEMzEcFhY8tPBMK_TLJDR3pSDes_qiF5F_XFGkbu4Jw4WkG6SigZSszwIrCftf4Hc0CWFA_umGqfu06_M3W18Idla4P7XQqLy3uBLQPR1CE_jd4xArtQzhyphowyZnJn058l5bj6fGCaB9qtlEezn_xMSUBq4BAWPOzL2Wl-W5it3Saf8n44UpxCE1XGBLMXqXwp4uOpIZ8J5RbJFGQ6FaR9zPdHUplnxjiSsGfITg'
{
    "timestamp": 1512143299479,
    "status": 401,
    "error": "Unauthorized",
    "message": "Unable to authenticate using the Authorization header",
    "path": "/admin"
}

La cerise sur le gateau ?

Vous avez assez d’éléments pour sécuriser efficacement votre backend d’APIs à ce stade.

J’aimerai aborder un dernier point, pour conclure cette 3ème partie en beauté … coder votre sécurité par les tests (TU j’entends).

L’idée est de pouvoir isoler la configuration Keycloak de la configuration Spring-Security

  • afin de s’assurer que la sécurisation de vos routes soit toujours effective, peu importe la solution d’IAM retenue.
  • afin de pouvoir mettre en place la sécurité par les tests

Pour arriver à cela il est nécessaire de

  • séparer le code spécifique Keycloak du code commun
  • utiliser le code commun coté main et coté test
  • mettre en place des TUs basés sur les roles utilisateurs

Let’s do this !

Passer cette étape : git cherry-pick e66c4c0

1 – Utilisation des Custom DSLs Spring Security

Documentation de référence Spring

En ce basant sur cette documentation de référence, nous allons isoler la partie commune à l’aide d’une classe héritant AbstractHttpConfigurer :

  • Création d’une classe statique contenant la configuration commune dans votre classe SpringKeycloakTutorialsSecurityConfiguration :
/**
 * See https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#jc-custom-dsls
 * <ul><li>Manage paths securisation here !You must use this configuration in tests to validate routes securisation</li>
 * <li>Use with                 .and().apply(new CommonVitodocSecuritAdapter()) on http dsl</li>
 * </ul>
 */
public static class CommonSpringKeycloakTutorialsSecuritAdapter extends AbstractHttpConfigurer<CommonSpringKeycloakTutorialsSecuritAdapter, HttpSecurity> {

    @Override
    public void init(HttpSecurity http) throws Exception {
        // any method that adds another configurer
        // must be done in the init method
        http
                // disable csrf because of API mode
                .csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                    // manage routes securisation here
                    .authorizeRequests().antMatchers(HttpMethod.OPTIONS).permitAll()

                // manage routes securisation here
                .and()
                    .authorizeRequests()
                        .antMatchers(HttpMethod.OPTIONS).permitAll()


                        .antMatchers("/logout", "/", "/unsecured").permitAll()
                        .antMatchers("/user").hasRole("USER")
                        .antMatchers("/admin").hasRole("ADMIN")

                        .anyRequest().denyAll();

    }

}
  • Suppression des éléments communs dans la configuration initiale et utilisation de l’adapter dans la configuration initiale via .apply(new CommonSpringKeycloakTutorialsSecuritAdapter())
/**
       * Configuration spécifique à keycloak (ajouts de filtres, etc)
       * @param http
       * @throws Exception
       */
      @Override
      protected void configure(HttpSecurity http) throws Exception
      {
          http
                  .sessionManagement()
                      // use previously declared bean
                      .sessionAuthenticationStrategy(sessionAuthenticationStrategy())


                  // keycloak filters for securisation
                  .and()
                      .addFilterBefore(keycloakPreAuthActionsFilter(), LogoutFilter.class)
                      .addFilterBefore(keycloakAuthenticationProcessingFilter(), X509AuthenticationFilter.class)
                      .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint())

                  // delegate logout endpoint to spring security

                  .and()
                      .logout()
                      .addLogoutHandler(keycloakLogoutHandler())
                      .logoutUrl("/logout").logoutSuccessHandler(
                          // logout handler for API
                          (HttpServletRequest request, HttpServletResponse response, Authentication authentication) ->
                                  response.setStatus(HttpServletResponse.SC_OK)
                       )
                  .and().apply(new CommonSpringKeycloakTutorialsSecuritAdapter());


      }
  }

A cette étape, votre code est iso-fonctionnel.

2 – Mise en place des TUs validant nos règles

Afin de tester efficacement nos règles de sécurité, nous allons utiliser

Passer cette étape : git cherry-pick d97938f

Voici le code source complet accompagné d’annotations :

package com.ineat.tutorials.springkeycloaktutorials;

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.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Import;
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.test.context.support.WithMockUser;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(value = SpringKeycloakTutorialsApisController.class)
@Import(SpringKeycloakTutorialsSecurityTestConfiguration.class)
// Disable Keycloak configuration processing
@TestPropertySource(properties = {"keycloak.enabled=false"})
public class SpringKeycloakTutorialsSecurityTests {

    @Autowired
    MockMvc mockMvc;


    @Test
    public void testUnsecuredPathIsAllowedForAll() throws Exception {
        mockMvc.perform( MockMvcRequestBuilders.get("/unsecured"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").value("This is an unsecured endpoint payload"));
    }



    @Test
    @WithMockUser
    public void testAdminPathIsNotAllowedForAll() throws Exception {
        mockMvc.perform( MockMvcRequestBuilders.get("/admin"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    public void testAdmindPathIsOnlyAllowedForAdminProfil() throws Exception {
        mockMvc.perform( MockMvcRequestBuilders.get("/admin"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").value("This is an ADMIN endpoint payload"));
    }

    @Test
    @WithMockUser(roles = "USER")
    public void testUserPathIsOnlyAllowedForUserProfil() throws Exception {
        mockMvc.perform( MockMvcRequestBuilders.get("/user"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").value("This is an USER endpoint payload"));
    }

    @Test
    @WithMockUser(roles = "USER")
    public void testAdmindPathIsNotAllowedForUserProfil() throws Exception {
        mockMvc.perform( MockMvcRequestBuilders.get("/admin"))
                .andExpect(status().isForbidden());
    }
}

/***
 * <p>Use this configuration class to test if your path is secured</p>
 * <p>his class use {@link SpringKeycloakTutorialsSecurityConfiguration.CommonSpringKeycloakTutorialsSecuritAdapter} which define all
 * security matchers</p>
 */
@TestConfiguration
@EnableWebSecurity
 class SpringKeycloakTutorialsSecurityTestConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // use the common configuration to validate matchers
        http.apply(new SpringKeycloakTutorialsSecurityConfiguration.CommonSpringKeycloakTutorialsSecuritAdapter());

    }
}
  • Lignes 74 à 83 : Classe de configuration de sécurité, destinée aux tests, qui charge notre configuration commune CommonSpringKeycloakTutorialsSecuritAdapter
  • Ligne 20 : Chargement de cette classe de configuration pour prise en compte de notre paramétrage
  • Ligne 22 : Désactivation de l’autoconfiguration Keycloak
  • Autres lignes : Remarquez la définition de chaque test qui permet de valider la sécurité mise en place sur un endpoint :
    • Lignes 45 à 51 : Prenons pour exemple ce bloc de test
      • Ligne 46 : Nous définissons un context de connection avec un utilisateur possédant le rôle ADMIN
      • Ligne 48 : Nous simulons un appel HTTP sur GET - /admin
      • Ligne 49 : … qui doit nous retourner un HTTP Status 200 (puisque le path /admin n’est autorisé que pour les rôles ADMIN, vous suivez ?) …
      • Ligne 50 : … et doit nous retourner un payload (testé grâce à jsonPath("$")) contenant "This is an ADMIN endpoint payload"

3 – Tests … de vos tests

  • La manière la plus rapide consiste a exécuter vos tests depuis votre environnement de développement.
  • Via maven, exécuter la commande mvn test

Cet article touche à sa fin …

… Et notre tutoriel sur l’utilisation de Spring Boot/Security avec Keycloak par la même occasion.

N’hésitez pas à me laisser vos feedbacks (@ldussart sur twitter) et partager cet article sur les réseaux sociaux si il vous a été utile !

Merci à ma fille d’être née ce 5 décembre 2017 … j’ai terminé la rédaction de ce billet à ses côtés.

Édité le 05/02/2021 pour un passage de Keycloak 9.0.0 à Keycloak 12.0.2 et Spring Boot 2.4 (Le code source des versions précédentes sont disponibles sous des branches du repository)

Série d’articles Keycloak :