liksi logo

Mettre en place une authentification custom avec Keycloak

Antoine Barroux - Publié le 06/02/2024
Mettre en place une authentification custom avec Keycloak

Authentification custom avec Keycloak

Lors de la modernisation d’applications, il est très fréquent que l’on souhaite ajouter Keycloak à notre stack pour lui déléguer l’authentification de nos utilisateurs. Cependant, il peut être compliqué de migrer directement tous ses utilisateurs dans Keycloak afin de suivre le flow classique d’authentification. Souvent, une première version est mise en place qui a pour but d’authentifier notre utilisateur via Keycloak mais de conserver la base d’utilisateurs côté legacy. Je vous propose dans cet article d’implémenter cette logique.

Contexte

Récemment, j’ai eu à mettre en place un mécanisme d’authentification custom sur Keycloak ayant pour but d’authentifier des utilisateurs à partir d’une base d’utilisateurs gérée par une application legacy. On sort donc du cadre classique qui se base sur une base d’utilisateurs gérée en interne par Keycloak. Cette mise en place, bien que pas forcément complexe une fois toute la chaîne mise en place, représente quelques défis et il n’est pas si simple de trouver des ressources détaillées sur le sujet. C’est pourquoi il est l’heure aujourd’hui de faire le tour de ce qui a été mis en place pour répondre à ce besoin.

Et justement, commençons par définir ce besoin. À la base, on retrouve une application legacy, qui est notamment en charge de l’authentification des utilisateurs à partir de leurs credentials. Cette application a pour but d’être modernisée et l’objectif est donc de migrer certaines de ses responsabilités, notamment celles qui impactent fortement la modernisation (l’authentification en fait partie). L’idée est donc de migrer l’authentification vers Keycloak, tout en conservant dans un premier temps la base des utilisateurs côté application legacy. Les besoins sont donc :

  • La mire de login doit être celle de Keycloak. Cette mire se compose de trois champs au lieu des deux champs habituels : le compte de l’utilisateur, son login et son mot de passe.
  • La validation des credentials doit être faite par l’application legacy via une API REST exposée par cette dernière.

Le but de cet article n’est pas de reprendre de zéro la notion de customisation de l’authentification Keycloak. Si vous souhaitez quelques bases sur le sujet, je ne peux que vous recommander de lire la documentation officielle afin d’avoir un premier aperçu. Les trois notions importantes ici sont les suivantes :

  • L’Authentication Flow : c’est une suite ordonnée d’étapes d’authentification (Authenticator). Il définit le workflow par lequel chaque utilisateur va devoir passer pour s’authentifier. Ces étapes peuvent correspondre à des écrans de formulaire, des vérifications de cookies, etc. Dans l’exemple ci-dessous, le flow d’authentification va essayer tous les authenticators de haut en bas jusqu’à ce qu’un d’eux réussisse à valider l’authentification. On va tout d’abord tester la présence d’un cookie, puis d’une authentification Kerberos, jusqu’à tomber sur un formulaire de login dans le cas où tous les Authenticator précédents auraient échoué.
  • L’Authenticator : c’est une étape d’authentification. On voit notamment dans l’exemple ci-dessus une liste d’Authenticator, dont un formulaire de login par exemple.
  • Le User Storage Provider : c’est un provider qui permet de se connecter à une base d’utilisateurs externe. Un exemple assez parlant de User Storage Provider est la récupération d’utilisateurs via un LDAP existant.

Première étape : gérer les trois champs de login

Dans un premier temps, on va vouloir surcharger le comportement habituel du formulaire de login afin de rajouter notre champ account. Pour cela, deux étapes sont nécessaires. La première, c’est de surcharger le template de login afin d’y rajouter notre input supplémentaire. Dans cet exemple, nous allons faire quelque chose de très simple. Une nouvelle fois, la documentation officielle à ce sujet vous permettra de comprendre le fonctionnement des templates.

Nous allons donc créer la hiérarchie de répertoires suivante : themes/custom/login. Une fois interprété par Keycloak, nous aurons un thème appelé “Custom” qui définira un template dédié pour les écrans de login. Le contenu du fichier login.ftl ressemble à ceci :

<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('account', 'username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section>
<#if section = "header">
${msg("loginAccountTitle")}
<#elseif section = "form">
<#if realm.password>
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
  <div class="${properties.kcFormGroupClass!}">
    <label for="account" class="${properties.kcLabelClass!}">${msg("account")}</label>
    <input tabindex="1" id="account" class="${properties.kcInputClass!}" name="account" type="text" autocomplete="off" autofocus
           aria-invalid="<#if messagesPerField.existsError('account', 'username','password')>true</#if>"
    />
  </div>
  <div class="${properties.kcFormGroupClass!}">
    <label for="username" class="${properties.kcLabelClass!}">${msg("usernameOrEmail")}</label>
    <input tabindex="2" id="username" class="${properties.kcInputClass!}" name="username"  type="text" autocomplete="off"
           aria-invalid="<#if messagesPerField.existsError('account', 'username','password')>true</#if>"
    />
  </div>
  <div class="${properties.kcFormGroupClass!}">
    <label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
    <input tabindex="3" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="off"
           aria-invalid="<#if messagesPerField.existsError('account', 'username','password')>true</#if>"
    />
  </div>
  <div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
    <input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
  <input tabindex="4" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
  </div>
</form>
</#if>
</#if>
</@layout.registrationLayout>

Mise à part la syntaxe particulière liée à ces templates, rien de bien compliqué ici. On rajoute simplement un input de type text permettant de récupérer notre notion d’account. Nous allons également devoir préciser à Keycloak quel sera le thème de base que l’on souhaite étendre : il faut rajouter un fichier theme.properties à côté de votre login.ftl.

parent=keycloak
import=common/keycloak

Une fois ce template créé, il va falloir expliquer à Keycloak comment traiter ce nouveau champ. Pour cela, on va créer un Authenticator custom très simple, dont la responsabilité sera de récupérer la valeur de ces champs et de les transmettre à la couche responsable de la récupération des utilisateurs.

Pour ce faire, nous allons démarrer un projet java avec Maven et y ajouter les dépendances suivantes :

<properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <keycloak.version>23.0.3</keycloak.version>
    <slf4j-api.version>2.0.10</slf4j-api.version>
    <logback-classic.version>1.4.14</logback-classic.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-core</artifactId>
        <version>${keycloak.version}</version>
    </dependency>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-server-spi</artifactId>
        <version>${keycloak.version}</version>
    </dependency>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-server-spi-private</artifactId>
        <version>${keycloak.version}</version>
    </dependency>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-services</artifactId>
        <version>${keycloak.version}</version>
    </dependency>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-model-legacy</artifactId>
        <version>${keycloak.version}</version>
    </dependency>

    <!-- Loggers -->
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>${slf4j-api.version}</version>
    </dependency>
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>${logback-classic.version}</version>
    </dependency>
</dependencies>

Nous allons ensuite créer un Authenticator en étendant la classe UsernamePasswordForm fournie par Keycloak. Celle-ci implémente déjà une logique d’authentification via un formulaire de login habituel (username + password). Cette classe déclare notamment une méthode validateForm qui sera appelée à chaque fois que l’utilisateur va soumettre le formulaire. C’est ici que l’on va placer notre logique :

public class CustomUserFormAuthenticator extends UsernamePasswordForm {

  private static final String ACCOUNT_INPUT = "account";

  @Override
  protected boolean validateForm(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {
    try {
      final var account = formData.getFirst(ACCOUNT_INPUT);
      context.getAuthenticationSession().setAuthNote(ACCOUNT_INPUT, account);
      return super.validateForm(context, formData);
    } catch (Exception e) {
      context.failure(AuthenticationFlowError.INTERNAL_ERROR);
      return false;
    }
  }
}

On récupère tout simplement la valeur de notre champ account et on la stocke dans la session d’authentification. Une fois l’Authenticator en place, on va devoir créer une factory dédiée qui sera utilisée par Keycloak pour l’instancier :

public class CustomUserFormAuthenticatorFactory implements AuthenticatorFactory {

  @Override
  public Authenticator create(KeycloakSession keycloakSession) {
    return new CustomUserFormAuthenticator();
  }

  @Override
  public String getId() {
    return "custom-form-authentication";
  }

  @Override
  public String getHelpText() {
    return "Custom authenticator to handle custom template fields";
  }

  @Override
  public String getDisplayType() {
    return "Custom Form Authenticator";
  }

  @Override
  public String getReferenceCategory() {
    return null;
  }

  @Override
  public boolean isConfigurable() {
    return true;
  }

  private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {AuthenticationExecutionModel.Requirement.REQUIRED,
          AuthenticationExecutionModel.Requirement.DISABLED};

  @Override
  public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
    return REQUIREMENT_CHOICES;
  }

  @Override
  public boolean isUserSetupAllowed() {
    return false;
  }

  @Override
  public List<ProviderConfigProperty> getConfigProperties() {
    return null;
  }


  @Override
  public void init(Config.Scope scope) {

  }

  @Override
  public void postInit(KeycloakSessionFactory keycloakSessionFactory) {

  }

  @Override
  public void close() {

  }

  @Override
  public int order() {
    return 1000;
  }
}

Beaucoup de méthodes à implémenter ici, je ne vais pas toutes les passer en revue pour éviter de complexifier notre exemple. Je vous laisse vous référer à la documentation officielle si vous souhaitez approfondir chacune de ces méthodes.

Deuxième étape : utiliser une base utilisateur externe

L’étape suivante va être de fournir à Keycloak un User Storage Provider en charge de la récupération de nos utilisateurs sur notre API legacy. Celui-ci devra récupérer notre champ account présent dans la session d’authentification pour récupérer correctement notre utilisateur. Le workflow du User Storage Provider se découpe en trois étapes :

  • La récupération de l’id de l’utilisateur à partir de son username
  • La validation du couple id utilisateur/mot de passe
  • La récupération de l’utilisateur à partir de son id. Cette méthode correspond à la récupération de l’utilisateur en base de données via Hibernate à chaque fois que Keycloak a besoin de récupérer des informations sur l’utilisateur.

Comme nous souhaitons déléguer la validation de l’authentification à notre API legacy, nous allons mettre en place un connecteur HTTP qui sera responsable de mener à bien ces trois interactions avec notre API.

public record LegacyLoginResponse(String userId) {

}

public interface LegacyConnector {
  LegacyLoginResponse getUserByUsername(KeycloakSession keycloakSession, String account, String login) throws Exception;
  LegacyLoginResponse validateUserPassword(KeycloakSession keycloakSession, String userId, String password) throws Exception;
  LegacyLoginResponse getUserById(KeycloakSession keycloakSession, String userId) throws Exception;
}

Nous allons implémenter ce connecteur de deux manières différentes :

  • Un fake, qui aura pour but de tester nos SPIs en s’affranchissant de la communication avec notre API Legacy.
  • Un vrai connecteur chargé de communiquer avec notre API Legacy et qui permettra de valider le fonctionnement de bout en bout.
public class LegacyConnectorFake implements LegacyConnector {

  private LegacyConnectorFake() {
  }

  public static LegacyConnectorFake create() {
    return new LegacyConnectorFake();
  }

  @Override
  public LegacyLoginResponse validateUserPassword(final KeycloakSession keycloakSession, final String userId, final String password)
          throws Exception {
    return "password".equals(password) ? new LegacyLoginResponse("1") : null;
  }

  @Override
  public LegacyLoginResponse getUserByUsername(final KeycloakSession keycloakSession, final String account, final String login)
          throws Exception {
    return new LegacyLoginResponse("1");
  }

  @Override
  public LegacyLoginResponse getUserById(final KeycloakSession keycloakSession, final String userId) throws Exception {
    return new LegacyLoginResponse(userId);
  }
}
public class LegacyConnectorImpl implements LegacyConnector {
  private static final Logger LOGGER = LoggerFactory.getLogger(LegacyConnectorImpl.class);

  private LegacyConnectorImpl() {
  }

  public static LegacyConnectorImpl create() {
    return new LegacyConnectorImpl();
  }

  public LegacyLoginResponse getUserByUsername(KeycloakSession keycloakSession, String account, String login) throws Exception {
    LOGGER.info("Get user by username on legacy API {}, {}", account, login);
    final var uri = "http://localhost:9000/api/users";
    SimpleHttp request = SimpleHttp.doPost(uri, keycloakSession)
            .param("account", account)
            .param("login", login);

    try (SimpleHttp.Response response = request.asResponse()) {
      if (response.getStatus() == 200) {
        return response.asJson(LegacyLoginResponse.class);
      } else {
        LOGGER.warn("An error occured while trying to get user by username. Code HTTP : {} / {}, {}", response.getStatus(), account, login);
        throw new Exception("Unable to authenticate user");
      }
    } catch (Exception e) {
      LOGGER.warn("A technical error occured while trying to get user by username");
      throw new Exception("Technical error while calling legacy API", e);
    }
  }

  public LegacyLoginResponse validateUserPassword(KeycloakSession keycloakSession, String userId, String password) throws Exception {
    LOGGER.info("Validate user password on legacy API {}", userId);
    final var uri = "http://localhost:9000/api/auth";
    SimpleHttp request = SimpleHttp.doPost(uri, keycloakSession)
            .param("userId", userId)
            .param("password", password);

    try (SimpleHttp.Response response = request.asResponse()) {
      if (response.getStatus() == 200) {
        return response.asJson(LegacyLoginResponse.class);

      } else {
        LOGGER.warn("Unable to validate credentials for user {}", userId);
        throw new Exception("Unable to authenticate user");
      }
    } catch (Exception e) {
      LOGGER.warn("Unable to validate credentials for user {}", userId);
      throw new Exception("Technical error while calling legacy API", e);
    }
  }

  public LegacyLoginResponse getUserById(KeycloakSession keycloakSession, String userId) throws Exception {
    LOGGER.info("Get user by id on legacy API {}", userId);
    final var uri = "http://localhost:9000/api/users";
    SimpleHttp request = SimpleHttp.doPost(uri, keycloakSession)
            .param("userId", userId);

    try (SimpleHttp.Response response = request.asResponse()) {
      if (response.getStatus() == 200) {
        return response.asJson(LegacyLoginResponse.class);
      } else {
        LOGGER.warn("An error occured while trying to get user by id. Code HTTP : {} / {}", response.getStatus(), userId);
        throw new Exception("Unable to retrieve user");
      }
    } catch (Exception e) {
      LOGGER.warn("A technical error occured while trying to get user by id");
      throw new Exception("Technical error while calling legacy API", e);
    }
  }
}

Rien de particulier ici, on utilise SimpleHttp car c’est un client HTTP fourni de base par Keycloak. Lors de l’instanciation du client, on lui fournit la session keycloak afin de faire notre appel HTTP dans la même transaction que notre flow d’authentification. Pour simplifier l’exemple, nous avons utilisé une URI en dur pour communiquer avec notre API legacy. Dans le monde réel, il faudra bien sûr variabiliser cette URI afin de pouvoir gérer vos différents environnements.

Une fois notre connecteur en place, nous avons tout ce dont nous avons besoin pour implémenter notre User Storage Provider. De la même manière que pour l’Authenticator, il va falloir implémenter quelques interfaces fournies par Keycloak :

public class LegacyUserStorageProvider implements UserStorageProvider, UserLookupProvider, CredentialInputValidator {

  private static final Logger LOG = LoggerFactory.getLogger(LegacyUserStorageProvider.class);

  private final KeycloakSession keycloakSession;
  private final ComponentModel componentModel;
  private final LegacyConnector legacyConnector;
    
  public LegacyUserStorageProvider(KeycloakSession keycloakSession,
          ComponentModel componentModel,
          LegacyConnector legacyConnector) {
    this.keycloakSession = keycloakSession;
    this.componentModel = componentModel;
    this.legacyConnector = legacyConnector;
  }
}

Les méthodes que l’on va vouloir surcharger sont celles définies par le workflow cité plus haut :

@Override
public UserModel getUserByUsername(final RealmModel realmModel, final String username) {

}

@Override
public boolean isValid(final RealmModel realmModel, final UserModel userModel, final CredentialInput credentialInput) {

}

@Override
public UserModel getUserById(final RealmModel realmModel, final String id) {

}

Le souci, c’est que l’on doit fournir à Keycloak des objets de type UserModel, mais cette classe est abstraite… on va donc devoir fournir notre propre représentation d’utilisateur pour notre flow d’authentification. C’est notamment dans cette représentation qu’on va pouvoir stocker des attributs custom en lien avec notre utilisateur, que l’on pourra mapper dans les claims de nos tokens par la suite. Dans notre cas, on souhaite que le userId de notre application legacy se retrouve dans les claims de notre token.

public class CustomUserModel extends AbstractUserAdapter {
  private String legacyUserId;

  private CustomUserModel(final KeycloakSession session,
          final RealmModel realm,
          final ComponentModel storageProviderModel) {
    super(session, realm, storageProviderModel);
  }

  public static UserModel from(final KeycloakSession keycloakSession, final RealmModel realmModel,
          final ComponentModel componentModel, final LegacyLoginResponse legacyLoginResponse) {
    final var userModel = new CustomUserModel(keycloakSession, realmModel, componentModel);
    userModel.legacyUserId = legacyLoginResponse.userId();
    userModel.storageId = new StorageId(componentModel.getId(), legacyLoginResponse.userId());
    return userModel;
  }

  @Override
  public String getUsername() {
    return this.legacyUserId;
  }

  @Override
  public SubjectCredentialManager credentialManager() {
    return new LegacyUserCredentialManager(session, realm, this);
  }
}

Notre objet ne contiendra rien de plus que ce champ custom. Le storageId est l’identifiant que l’on récupèrera dans notre provider, il est donc important de comprendre comment il est construit. Il est composé de l’identifiant du provider (ici, notre User Storage Provider) et de l’id de l’utilisateur. Il aura donc ce format :

f:componentId:userId 

Une fois cette représentation d’utilisateur en place, nous pouvons reprendre l’implémentation du storage provider. Nous allons tout d’abord implémenter la première étape : la récupération de l’utilisateur à partir de son username. Ici, on va récupérer également la notion d’account que nous avions stocké dans les notes de la session d’authentification, car notre API legacy ne peut identifier un utilisateur qu’à partir de ces deux champs :

@Override
public UserModel getUserByUsername(final RealmModel realmModel, final String username) {
  try {
    LOG.info("Get user by username {}", username);
    final var account = keycloakSession.getContext().getAuthenticationSession().getAuthNote("account");
    return CustomUserModel.from(keycloakSession, realmModel, componentModel, legacyConnector.getUserByUsername(keycloakSession, account, username));
  } catch(Exception e) {
    LOG.error("Error while getting user by username {}", username, e);
    return null;
  }
}

Une fois ceci fait, Keycloak va utiliser la méthode isValid pour valider le couple storageId/mot de passe. De la même manière, nous allons faire passe-plat sur notre API legacy :

@Override
public boolean isValid(final RealmModel realmModel, final UserModel userModel, final CredentialInput credentialInput) {
  try {
    LOG.info("validate credentials {}", userModel.getId());
    // userModel.getId() => storageId : f:componentId:userId
    final var userId = StorageId.externalId(userModel.getId());
    return Objects.nonNull(legacyConnector.validateUserPassword(keycloakSession, userId, credentialInput.getChallengeResponse()));
  } catch(Exception e) {
    LOG.error("Error vaidating credentials {}", userModel.getId(), e);
    return false;
  }
}

La classe StorageId permet de manipuler ce type d’objet et notamment de ne récupérer que l’identifiant fonctionnel (ici, notre userId).

Nous allons terminer cette implémentation par la récupération de l’utilisateur à partir de son id :

@Override
public UserModel getUserById(final RealmModel realmModel, final String id) {
  try {
    LOG.info("Get user by id {}", id);
    // Id => storageId : f:componentId:userId
    final var userId = StorageId.externalId(id);
    return CustomUserModel.from(keycloakSession, realmModel, componentModel, legacyConnector.getUserById(keycloakSession, userId));
  } catch(Exception e) {
    LOG.error("Error while getting user by id {}", id, e);
    return null;
  }
}

Comme expliqué plus haut, Keycloak utilise cette méthode pour récupérer l’utilisateur en base de données lors d’un flow d’authentification classique. C’est Hibernate derrière qui va se charger de récupérer l’utilisateur, et souvent, la base de données n’est pas sollicitée car l’utilisateur se trouve dans le cache infinispan. Le souci, c’est que dans notre cas, notre route getUserById va se faire appeler à chaque fois que Keycloak aura besoin d’informations sur l’utilisateur. Pour palier ce problème, il est fortement recommandé de cacher ces appels afin d’éviter toute requête inutile à votre API legacy. Pensez à bien mettre en place ce cache au niveau de votre couche connecteur HTTP, et non pas au niveau de la méthode de votre User Storage Provider. En effet, si vous mettez en place un cache au niveau de votre provider, vous serez amenés à mettre en cache votre UserModel qui contiendra la session d’authentification en cours. C’est cette session qui détient toute la logique de gestion des transactions. Si vous la mettez en cache, vous vous exposez à des soucis de session expirée / EntityManager closed.

Il reste quelques méthodes à implémenter sur lesquelles nous ne nous attarderons pas :

@Override
public UserModel getUserByEmail(final RealmModel realmModel, final String s) {
    return null;
}

@Override
public boolean supportsCredentialType(final String s) {
    return PasswordCredentialModel.TYPE.equals(s);
}

@Override
public boolean isConfiguredFor(final RealmModel realmModel, final UserModel userModel, final String s) {
    return true;
}

@Override
public void close() {

}

Une fois la notion de storageId prise en compte, l’implémentation est assez limpide. De la même manière que pour l’Authenticator, il va falloir fournir à Keycloak une factory lui permettant d’instancier ce nouveau provider :

public class LegacyUserStorageProviderFactory implements UserStorageProviderFactory<LegacyUserStorageProvider> {

  private static final String PROVIDER_ID = "legacy-user-storage-provider";
  private static final String ENV_USE_FAKE_LEGACY_CONNECTOR = "USE_FAKE_LEGACY_CONNECTOR";

  @Override
  public LegacyUserStorageProvider create(final KeycloakSession keycloakSession, final ComponentModel componentModel) {
    final var shouldUseFakeConnector = System.getenv(ENV_USE_FAKE_LEGACY_CONNECTOR);
    if (Objects.nonNull(shouldUseFakeConnector) && Boolean.TRUE.equals(Boolean.valueOf(shouldUseFakeConnector))) {
      return new LegacyUserStorageProvider(keycloakSession, componentModel, LegacyConnectorFake.create());
    } else {
      return new LegacyUserStorageProvider(keycloakSession, componentModel, LegacyConnectorImpl.create());
    }
  }

  @Override
  public String getId() {
    return PROVIDER_ID;
  }
}

On a introduit ici le pilotage de notre connecteur à l’aide d’une variable d’environnement. Cela nous permettra de pouvoir switcher facilement entre nos phases de développement et nos phases de tests de bout en bout.

A ce stade là, tout est en place pour pouvoir tester notre flow d’authentification. Enfin presque ! Il manque encore une petite étape pour déclarer nos factories et que Keycloak parvienne à les retrouver. Pour cela, on va créer un répertoire META-INF/services dans notre répertoire resources, avec deux fichiers :

  • org.keycloak.authentication.AuthenticatorFactory qui contiendra le chemin vers notre factory d’Authenticator
fr.liksi.keycloak.spi.authenticator.CustomUserFormAuthenticatorFactory
  • org.keycloak.storage.UserStorageProviderFactory qui contiendra le chemin vers notre factory de User Storage Provider
fr.liksi.keycloak.spi.userstorage.LegacyUserStorageProviderFactory

Tout est prêt, il est l’heure de builder et de lancer tout ça !

Troisième étape : Build & Configuration

Nous allons commencer par builder notre image docker de Keycloak en y ajoutant notre thème et nos SPIs. Je ne vais pas détailler ici ce Dockerfile, si ce n’est que notre thème va se retrouver dans le répertoire /opt/keycloak/themes, et le jar contenant nos providers se retrouvera dans /opt/keycloak/providers. Nous positionnons également la variable d’environnement qui pilote le switch entre la fausse et la vraie implémentation de notre client HTTP à true : pour nos tests, nous resterons sur le fake.

ARG KEYCLOAK_VERSION=23.0.4

# ----------------------- Build du SPI ----------------------- #
FROM maven:3.8.3-openjdk-17 as spi-builder
WORKDIR /spi-build
COPY /keycloak-custom-external-authentication .
RUN mvn clean install -DskipTests
RUN mkdir /output
RUN find . -name '*.jar' -exec cp {} /output/ \;

# Ajout des themes / SPIs
FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} as keycloak-builder
WORKDIR /opt/keycloak
# Copie des thèmes
COPY /themes/ /opt/keycloak/themes/
# Copie des SPI(jar) dans Keycloak
COPY --from=spi-builder /output/ /opt/keycloak/providers/
RUN /opt/keycloak/bin/kc.sh build

# Build de l'image finale
FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}
COPY --from=keycloak-builder /opt/keycloak/ /opt/keycloak/
ENV KC_DB=postgres
ENV USE_FAKE_LEGACY_CONNECTOR=true
ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start", "--http-enabled=true", "--hostname-strict-https=false"]

Par confort, on se rajoute un petit docker-compose…

version: "3.7"

services:
  kc-app:
    build: .
    ports:
      - "8081:8080"
    environment:
      KC_DB_URL: "jdbc:postgresql://kc-db:5432/keycloak"
      KC_DB_USERNAME: "db-user"
      KC_DB_PASSWORD: "db-password"
      KC_HOSTNAME: "localhost"
      KEYCLOAK_ADMIN: "admin"
      KEYCLOAK_ADMIN_PASSWORD: "admin"
  kc-db:
    image: postgres:16.1-alpine
    environment:
      POSTGRES_USER: "db-user"
      POSTGRES_PASSWORD: "db-password"
      POSTGRES_DB: "keycloak"
    expose:
      - "5432"

Une fois votre image docker prête et lancée, il va falloir configurer Keycloak pour utiliser notre flow d’authentification. On va tout d’abord créer un nouveau realm appelé “test”, ainsi qu’un client dédié. Attention à la Redirect URI de votre client, elle doit correspondre à celle que l’on utilisera pour tester notre flow d’authentification. Pensez également à bien sélectionner votre thème dans la configuration de votre client. Si vous l’avez correctement importé lors de votre phase de build, vous devriez le retrouver dans la liste déroulante (à côté des thèmes de base fournis par Keycloak).

Ensuite, rendez-vous dans la section “Authentication”. Ici, nous allons surcharger le Browser Flow afin d’utiliser notre Authenticator custom au lieu du formulaire de login par défaut.

Vous pouvez voir que ce flow est composé de plusieurs étapes de base, dont un UsernamePasswordForm. C’est cette étape que l’on veut remplacer par notre Authenticator. Rajoutez le step correspondant (vous retrouverez votre Authenticator à partir du nom que vous lui avez donné via votre Factory), placez-le à la place du UsernamePasswordForm et supprimez ce dernier. Vous devriez avoir un flow ressemblant à celui-ci :

Pour finir notre configuration, il va falloir activer notre User Storage Provider. Pour ce faire, rendez-vous dans la section “User Federation”. Vous devriez ici voir apparaître votre provider dans les sélections possibles. Sélectionnez-le et donnez-lui un nom :

Pour finir, testons tout ça !

Pour tester notre flow d’authentification, il va falloir se rendre sur notre mire de login, qui se situe à l’URL suivante :

http://localhost:8081/realms/test/protocol/openid-connect/auth?client_id=test&response_type=code&redirect_uri=http%3A%2F%2Flocalhost:3000

Notez que le nom du realm, du client ainsi que la redirect URI sera peut-être différente pour vous selon les nommages que vous aurez choisi dans les étapes précédentes. Vous devriez voir apparaître votre template de login avec nos trois champs account, username et password.

Vous pouvez tester vos cas de succès et d’erreur. En cas de succès, vous devriez être redirigés vers votre redirect URI (localhost:3000 dans notre cas).

Conclusion

Beaucoup de mise en place mais une complexité finalement assez faible lorsque l’on a bien compris comment fonctionne Keycloak.

Vous pouvez retrouver le code complet de cet exemple sur Github.

Bibliographie

Un grand merci à Tristan Denmat pour son accompagnement et son expertise sur Keycloak qui m’ont permis de réaliser cet article.

Derniers articles