Comme nous avons pu le voir dans les articles précédents, Keycloak est une solution d’Identify and Access Management facilement intégrable à un backend Spring.

Une problématique à laquelle nous pouvons cependant être confrontés est que Spring ne peut pas gérer simplement le multi – domaines (Spring permettant uniquement de lier une application à un seul et même Realm Keycloak).

Mais alors comment doit-on s’y prendre quand notre application Spring doit contacter N Realm ? Ce qui va suivre devrait vous donner quelques éléments de réponse.

FICHE TECHNIQUE

Keycloak : 15.0.2

Spring Boot : 2.5.4

Spring Security : 5.5.2

Un cas concret

Imaginons que nous ayons une application en SaaS permettant de gérer des espaces de partage de documents. Cette application offre donc la possibilité de créer des groupes au sein desquels les utilisateurs pourront consulter certains documents. L’administrateur de tel ou tel groupe pourra, en plus des droits de lecture, ajouter des fichiers.

Nous avons deux types de droits : USER et ADMIN. Dans le cas où l’application est utilisée par une seule organisation (disons orga-realm), il suffit d’ajouter dans le fichier application.properties les configurations suivantes :

keycloak.auth-server-url=http://localhost:8180/auth
keycloak.realm=orga-realm
keycloak.resource=orga-api

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

D’un point de vue api, nous aurons un contrôleur qui ressemblera à ceci (en simplifié) :

public class AppliController {
@RequestMapping(name = "/admin",method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?>adminEndpoint(){
    // Ajout de documents par l’admin
    return ...;
}
@RequestMapping(name = "/admin",method = RequestMethod.POST produces = MediaType.APPLICATION_JSON_VALUE)
public List<String> adminEndpoint(){
    // Liste des noms des documents accessibles par l’admin
    return ...;
}
@RequestMapping("/user")
public List<String> userEndpoint(){
    // Liste des noms des documents accessibles par les utilisateurs lambda
    return ...;
}

Cependant, notre projet a pour but de fournir des services à de multiples organisations, chacune disposant de ses propres utilisateurs et administrateurs. Pour résumer nous avons besoin d’un domaine (realm) par organisation et en l’état il n’est pas possible de spécifier plusieurs domaines au sein du fichier application.properties.

La solution

Etape 1 – Créer notre propre ConfigResolver.

Référence : https://www.keycloak.org/docs/latest/securing_apps/index.html#_multi_tenancy

Bon point numéro un : il est possible de charger les configurations relatives à un Realm directement depuis un fichier JSON (moyennant quelques lignes de Java).

Bon point numéro deux : les dépendances Keycloak ajoutées à notre application proposent une interface appelée KeycloackConfigResolver.

En partant de ces deux constats, on peut tout à fait imaginer de créer une implémentation du KeycloackConfigResolver qui se chargera de traiter les requêtes entrantes, et de charger le fichier de configuration JSON adapté en fonction d’un paramètre de requête HTTP (par exemple un header). 

L’implémentation devra au minimum surcharger la méthode resolve comme suit :

public class HeaderBasedKeycloakConfigResolver implements KeycloakConfigResolver {

    @Override
    public KeycloakDeployment resolve(OIDCHttpFacade.Request request) {
        String realm = request.getHeader("realm");
        InputStream is = getClass().getResourceAsStream("/"+realm+"-keycloak.json");
        return KeycloakDeploymentBuilder.build(is);
    }
}

Comme dit plus haut, elle va récupérer le header « realm », et charger le fichier de configuration {realm}-keycloak.json, et ceci pour chaque requête.

Il s’agit ici de la forme la plus simpliste, en réalité il faudrait certainement gérer un cache afin de ne pas recharger plusieurs fois une même configuration, cache pouvant être de la forme suivante :

private final ConcurrentHashMap<String, KeycloakDeployment> cache = new ConcurrentHashMap<>();

Une autre solution, plus élégante, serait d’utiliser la gestion de cache offerte par Spring. Si vous optez pour cette option, il faudra activer le cache en ajoutant l’annotation @EnableCaching à la classe MultitenantApplication (déjà annotée avec @SpringBootApplication) et faire évoluer la classe HeaderBasedKeycloakConfigResolver de la façon suivante :

public class HeaderBasedConfigResolver implements KeycloakConfigResolver {
    
    ...

    @Override
    public KeycloakDeployment resolve(OIDCHttpFacade.Request request) {
        String realm = request.getHeader("realm");
        return getRealmConfiguration(realm);
    }

    ...

    @Cacheable("keycloakDeployments")
    private KeycloakDeployment getRealmConfiguration(String realm) {
        InputStream is = getClass().getResourceAsStream("/"+realm+"-keycloak.json");
        return KeycloakDeploymentBuilder.build(is);
    }

}

Etape 2 – Référencer le HeaderBasedKeycloakConfigResolver.

Nous créons une classe MultitenantTomcatContextCustomizer qui étend KeycloakBaseTomcatContextCustomizer et implémente TomcatContextCustomizer.

On y surcharge la méthode customize :

...
static class MultitenantTomcatContextCustomizer extends KeycloakBaseTomcatContextCustomizer implements TomcatContextCustomizer {
    public MultitenantTomcatContextCustomizer(final KeycloakSpringBootProperties keycloakProperties) {
        super(keycloakProperties);
    }


    @Override
    public void customize(final Context context) {
        super.customize(context);
        final String name = "keycloak.config.resolver";
        context.removeParameter(name);
        context.addParameter(name, HeaderBasedConfigResolver.class.getName());
    }
}

L’objectif est de charger le HeaderBasedConfigResolver créé à l’étape 1, lors de l’init de l’application.

Il nous faudra ensuite créer notre propre classe de configuration. Cette classe, qui étendra KeycloakAutoConfiguration, permettra d’intégrer la configuration Keycloak a Spring Boot. Nous y référençons le HeaderBasedConfigResolver et le MultitenantTomcatContextCustomizer que nous venons de coder. 

On surcharge les méthodes setKeycloakSpringBootProperties et tomcatKeycloakContextCustomizer:

@Configuration
@ConditionalOnWebApplication
@EnableConfigurationProperties(KeycloakSpringBootProperties.class)
public class MultitenantConfiguration extends KeycloakAutoConfiguration {
    private KeycloakSpringBootProperties m_keycloakProperties;

    @Autowired
    @Override
    public void setKeycloakSpringBootProperties(final KeycloakSpringBootProperties keycloakProperties) {
       m_keycloakProperties = keycloakProperties;
       super.setKeycloakSpringBootProperties(keycloakProperties);
       HeaderBasedConfigResolver.setAdapterConfig(keycloakProperties); 
    }

    @Bean
    @ConditionalOnClass(name = { "org.apache.catalina.startup.Tomcat" })
    @Override
    public TomcatContextCustomizer tomcatKeycloakContextCustomizer() {
        return new MultitenantTomcatContextCustomizer(m_keycloakProperties);
    }
}

Le serveur web embarqué dans notre application étant Tomcat, la méthode surchargée ici sera donc tomcatKeycloakContextCustomizerDans le cas où vous souhaiteriez utiliser une autre solution (comme Jetty ou Undertow), il vous sera nécessaire de créer votre propre Customizer (qui étendra KeycloakJettyServerCustomizer ou KeycloakUndertowDeploymentInfoCustomizer) et de surcharger la méthode appropriée dans MultitenantConfiguration (jettyKeycloakServerCustomizer ou undertowKeycloakContextCustomizer).

Afin que Spring remplace nos configurations Keycloak (définies dans MultitenantConfiguration) avec ses propres configs, nous ajoutons la ligne suivante à notre application.properties :

spring.autoconfigure.exclude=org.keycloak.adapters.springboot.KeycloakAutoConfiguration

Le but ici est que l’instanciation du HeaderBasedKeycloakConfigResolver ne soit pas bypassée lors du démarrage de l’application.

Partie 3 – Intégrer le Config Resolver au sein des configurations Spring.

Tout comme pour la partie 3 de la série d’articles portant sur Keycloak, il nous faut définir une classe KeycloakWebSecurityConfigurerAdapter dans laquelle nous retrouverons les configurations s’appuyant sur l’adapter Keycloak. Peu de choses changent à ce niveau, si ce n’est la méthode KeycloackConfigResolver (qui renvoie à présent notre HeaderBasedKeycloakConfigResolver, définit précédemment) :

@Configuration
@EnableWebSecurity
@ConditionalOnProperty(name = "keycloak.enabled", havingValue = "true", matchIfMissing = true)
@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class)
public static class KeycloakConfigurationAdapter extends KeycloakWebSecurityConfigurerAdapter {
   ...

   @Bean
   public KeycloakConfigResolver KeycloakConfigResolver() {
       return new HeaderBasedConfigResolver();
   }
   ...
}

Des problèmes de CORS (request preflight) peuvent survenir lors de l’usage de vos APIs par des clients web (SPA par exemple). Ce commit illustre comment laisser passer les requêtes avant l’arrivée dans le HeaderBasedConfigResolver et ainsi résoudre le problème.

Comment tester ?

Avant de pouvoir tester notre développement il y a quelques prérequis. Commençons par créer deux Realms, ORGA1 et ORGA2, ainsi qu’une ressource en Bearer-Only sur chaque Realm (respectivement ORGA1-api et ORGA2-api). Pour obtenir les tokens nécessaires à l’authentification sur ORGA1 et ORGA2, nous allons créer deux clients supplémentaires ORGA1-web et ORGA2-web (en accès public). Nous aurons également besoins de deux utilisateurs sur chaque Realm (orga1-user / orga1-admin et orga2-user / orga2-admin).

Les manipulations à réaliser pour créer les domaines, rôles, ressources et utilisateurs ont été largement détaillées au sein des précédents articles de cette série. Je vous renvoie donc à la lecture de ces derniers pour plus de détails, notamment celui – ci. Les exports de mes propres configurations sont disponibles ici.

Il nous faut ensuite créer deux fichiers json, qui contiendront la configuration de nos Realms et qui seront utilisés par notre application.

Ci-dessous le contenu de ORGA1-keycloack.json et ORGA2-keycloak.json

{
"realm": "ORGA1",
"resource": "ORGA1-api",
"auth-server-url": "http://localhost:8182/auth/"
}

{
"realm": "ORGA2",
"resource": "ORGA2-api",
"auth-server-url": "http://localhost:8182/auth/"
}

Nous sommes prêt à tester notre développement. Démarrons donc l’application, puis exécutons la commande suivante pour obtenir un token d’authentification sur ORGA1 (en remplaçant ${ORGA1-SECRET} par le secret correspondant à la ressource ORGA1-web):

curl -X POST \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d 'username=orga1-user&password=orga1-user&grant_type=password&client_assertion=ee&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
     -d 'client_id=ORGA1-web' \
     -d 'client_secret=${ORGA1-SECRET}' "http://localhost:8182/auth/realms/ORGA1/protocol/openid-connect/token"
{"access_token":"eyJhb...tMrw","expires_in":300,"refresh_expires_in":1800,
"refresh_token":"eyJhbG...CUB3w","token_type":"bearer","not-before-policy":0,
"session_state":"bdd4e8...8e444f","scope":"profile email"}

Nous pouvons ensuite exécuter la commande suivante pour interroger l’api (en remplaçant ${TOKEN} par l’access_token obtenu avec la commande précédente):

curl -X GET \
     -H "Authorization: Bearer ${TOKEN}" \
     -H "realm: ORGA1" http://localhost:8080/user
["Documents user"]

Nous devrions obtenir la liste des documents utilisateurs.

Si on tente d’appeler l’api admin (http://localhost:8080/admin) avec ce même token et sur ce même Realm, nous obtiendrons une erreur 401 (ce qui est normal). Là où notre solution est intéressante c’est qu’à présent il possible de distinguer les utilisateurs de chaque organisation. Lançons la requête suivante, qui permet d’appeler la même api que précédemment, avec le même token, mais pour la seconde organisation.

curl -X GET \
     -H "Authorization: Bearer ${TOKEN}" \
     -H "realm: ORGA2" http://localhost:8080/user
<!doctype html><html lang="en"><head><title>401 Unauthorized</title>...

L’accès n’est pas permis, puisque nous ne sommes pas authentifié sur l’ORGA2.

Testons jusqu’au bout et lançons la requête suivante pour obtenir un token auprès de l’ORGA2 :

curl -X POST \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d 'username=orga2-user&password=orga2-user&grant_type=password&client_assertion=ee&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
     -d 'client_id=ORGA2-web' \
     -d 'client_secret=${ORGA2-SECRET}' "http://localhost:8182/auth/realms/ORGA2/protocol/openid-connect/token"
{"access_token":"eJabdH...8e45g","expires_in":300,"refresh_expires_in":1800,
"refresh_token":"eJabd...4effgJ","token_type":"bearer","not-before-policy":0,
"session_state":"bf44ea...ee454f","scope":"profile email"}

Et enfin appelons l’api renvoyant les documents d’un utilisateur, mais cette fois avec le token de l’ORGA2 :

curl -X GET \
     -H "Authorization: Bearer ${TOKEN}" \
     -H "realm: ORGA2" http://localhost:8080/user
["Documents user"]

Nous obtenons bien une réponse 🙂

Nous avons donc des utilisateurs réalisant des appels API au sein d’une même application, appels cloisonnés sur deux niveaux :

– il y a bien une distinction entre utilisateurs et administrateurs d’un même domaine

– il y a également une distinction entre les utilisateurs des deux domaines

Conclusion

En quelques lignes de code nous obtenons une authentification multi – domaines sur Keycloak, chose qui n’est pas nativement possible avec Spring. Cet article vous a détaillé comment mettre en place les différents mécanismes nécessaires à l’authentification multi – domaines.

Vous trouverez les sources de l’application et le code complet ici.

Liens utiles

Série d’articles Keycloak


Show CommentsClose Comments

1 Comment

Comments are closed.