Makina Blog

Le blog Makina-corpus

Administrer des comptes Keycloak depuis une application Python/Django


Dans cet article, nous allons créer une application Python/Django qui agira en tant que maître sur Keycloak afin de pouvoir ajouter facilement des comportements personnalisés à Keycloak.

Pourquoi vouloir étendre Keycloak ?

Bien qu'étant une solution de Single Sign On (SSO) très complète, l'outil parfait n'existe pas. Ainsi des comportements métiers particuliers à Keycloak ont souvent besoin d'être ajoutés. Si étendre les fonctionnalités de Keycloak peut s'avérer difficile, il est tout de même possible d'utiliser une application Python/Django pour étendre les possibilités de Keycloak.

L'équipe Makina Corpus a utilisé cette stratégie pour :

  • Créer des systèmes de permissions hiérarchiques avec plus de profondeur que le permet la sémantique de Keycloak
  • Gérer des permissions géographiques
  • Limiter les données utilisateurs modifiables par un administrateur

Dans cet article, nous allons écrire une application Django qui permet à un administrateur de créer des comptes sur Keycloak, ainsi qu'un test vérifiant que cette application interagit bien avec Keycloak de la manière voulue.

Architecture de notre système de gestion des utilisateurs

Administrer Keycloak de manière programmatique

En plus de l'API du protocole Open ID Connect (OIDC), Keycloak offre une API HTTP Restful permettant d'administrer le royaume (gestion des comptes etc.).

La documentation de cette API est disponible ici. Il existe une librairie Python offrant des primitives basées sur la documentation de cette API : python-keycloak.

Création du compte Keycloak

Avant de commencer le développement, il est nécessaire configurer des accès à cette API depuis Keycloak. Cet article ne détaillera pas l'ensemble des configurations propres à Keycloak, mais vous devrez créer un nouveau client de type confidential pour votre royaume. Ensuite activez le paramètre "Service Account Enabled" pour ce client. Cette option permet au client d’accéder à l'API de gestion des utilisateurs.

Implémentation de l'application Django

Initialisation du projet

Commencez par créer un dossier pour votre projet et installez-y un environnement virtuel (venv).

En plus de Django, nous allons utiliser la librairie python-keycloak qui offre une API pratique pour interagir avec l'endpoint de gestion de royaumes de Keycloak.

Il faut donc installer les dépendances suivantes :

django
python-keycloak

On crée un nouveau projet Django :

. venv/bin/activate # Activation du venv
django-admin startproject keycloak_demo

Puis une nouvelle application nommée accounts

. venv/bin/activate # Activation du venv
cd keycloak_demo
./manage.py startapp accounts  # Création de la nouvelle application

Enfin, les informations de connexion à Keycloak doivent être rajoutées dans le fichier de paramètres du projet : keycloak_demo/settings.py.

AUTH_SERVER_ROOT = "https://sso.example.com"  # L'URL de votre Keycloak
OIDC_RP_CLIENT_ID = "django_client_user_management"  # L'ID de votre client dans Keycloak 
OIDC_RP_CLIENT_SECRET = "s3cret"  # Le secret d'authentification de votre client pour la connexion

Pensez aussi à ajouter notre application à la liste des applications installées INSTALLED_APPS.

Renouvellement des jetons d'authentification

La classe KeycloakAdmin fournie par python-keycloak est supposée gérer le renouvellement des jetons d'authentification. Cependant, il nous est arrivé de rencontrer des soucis de rafraîchissement durant le développement (les requêtes de rafraîchissement n'étaient pas émises par python-keycloak version 0.25.0).

Aussi, nous allons créer un décorateur qui permettra de facilement renouveler le jeton d'authentification si la requête vers Keycloak venait à échouer.

Tout d'abord, créons une classe pour encapsuler la gestion de Keycloak. Dans l'application accounts, ajoutons un nouveau fichier helpers.py. Puis y ajouter le code suivant :

from keycloak import KeycloakAdmin
from django.conf import settings

class KeycloakHelper:
    def __init__(self):
        self._keycloak_admin = None

    def _init_keycloak_connection(self):
        server_url = settings.AUTH_SERVER_ROOT + "/auth/"
        self._keycloak_admin = KeycloakAdmin(
            server_url=server_url,
            client_id=settings.OIDC_RP_CLIENT_ID,
            realm_name="realm_des_bananes",
            client_secret_key=settings.OIDC_RP_CLIENT_SECRET,
            verify=False,
        )

Vous pouvez remarquer la présence de références aux variables provenant de settings.py qui permettent de se connecter à Keycloak.

Pourquoi est-ce que l'on initialises pas self._keycloak_admin avec la véritable valeur dans le constructeur ?

Bien vu ! La réponse complète se trouve un peu plus loin. C'est une limitation de la librairie python-keycloak lorsqu'elle est utilisée dans un contexte sans serveur Keycloak.

Ensuite, nous allons écrire un décorateur pour attraper les exceptions de type KeycloakAuthenticationError et nous ré-authentifier auprès de Keycloak. Si vous n'avez jamais écrit de décorateur en Python, c'est le moment de lire la documentation !

import functools
from keycloak.exceptions import KeycloakAuthenticationError

def refresh_keycloak_token(func):
    @functools.wraps(func)
    def wrapper(self, *args, **kwargs):
        try:
            return func(self, *args, **kwargs)
        except KeycloakAuthenticationError:
            self._init_keycloak_connection()
            return func(self, *args, **kwargs)

    return wrapper

Attention : cette méthode n'est pas optimale, idéalement il faudrait utiliser la route conçue pour le rafraîchissement du jeton d'authentification…

Récupérer les informations d'un utilisateur

Nous pouvons désormais ajouter des méthodes interagissant avec Keycloak à notre classe, en utilisant le décorateur refresh_keycloak_token écrit précédemment :

class KeycloakHelper:
    @refresh_keycloak_token
    def get_user_detail(self, user_id: str):
        return self.get_user(user_id)

Configuration de Django

Pour visualiser/créer/éditer/supprimer des utilisateurs Keycloak (le fameux CRUD), les fonctionnalités de génération automatiques d'outils d'administration de Django vont être utilisées.

Comme nous avons deux systèmes concurrents qui peuvent être utilisés simultanément pour éditer de la donnée, l'approche que nous devons avoir en cas de problème de concurrence doit être définie.

Soit Keycloak est maître et l'application Django doit porter une attention particulière afin d'éviter d'écraser des modifications.

Soit c'est l'application Django qui est maître et l'interface de Keycloak n'est plus utilisée.

Dans le cadre de cet article, nous allons partir sur la deuxième approche qui a l'avantage d'être plus simple.

Modélisation des données

Pour représenter les utilisateurs Keycloak, un modèle qui hérite de django.contrib.auth.AbstractUser est écrit afin de bénéficier de toutes les fonctionnalités de Django en matière de gestion des utilisateurs.

import uuid
from django.db import models
from django.contrib.auth.models import AbstractUser

class KeycloakUser(AbstractUser):
    USERNAME_FIELD = "email"
    EMAIL_FIELD = "email"
    REQUIRED_FIELDS = []

    email = models.EmailField(unique=True)

    keycloak_uuid = models.UUIDField(editable=False, unique=True, default=uuid.uuid4)

Keycloak ne vérifiant pas l'unicité des noms d'utilisateurs (username), c'est un attribut absent du modèle. Django en ayant quand même besoin, le champ est redirigé sur l'attribut email.

Enfin, nous retrouvons une valeur par défaut pour les uuid, afin de pouvoir instancier facilement des utilisateurs même s'ils ne sont pas encore créés côté Keycloak.

Attention, il faut penser à ajouter AUTH_USER_MODEL = "accounts.KeycloakUser" dans settings.py, sinon Django ne va pas comprendre qu'il s'agit du modèle de donnée pour les utilisateurs :

Création d'un utilisateur dans Keycloak depuis Django

Afin de pouvoir créer un utilisateur, une méthode dans notre classe KeycloakHelper doit être rajoutée. Cette méthode prend en entrée un email, un nom d'utilisateur et s'occupe de pousser l'utilisateur sur Keycloak.

@refresh_keycloak_token
def create_user(
    self,
    email: str,
    username: str,
):
    payload = {
        "email": email,
        "username": username,
        "enabled": str(True),
    }
    return self.get_keycloak_connection().create_user(payload)

Attention, dans le cas d'une véritable application, vérifiez que l'utilisateur n'existe pas déjà.

Création de l'interface d'administration

Pour l'interface d'administration, pensez à exclure plusieurs attributs du modèle qui sont gérés automatiquement : * les IDs Keycloak qui sont générés par Keycloak * la date d'enregistrement qui est gérée par Django * le mot de passe qui est aussi géré par Keycloak

import uuid

from django.contrib import admin

from accounts.helpers import KeycloakHelper
from accounts.models import KeycloakUser

@admin.register(KeycloakUser)
class UserAdmin(admin.ModelAdmin):
    exclude = ["keycloak_uuid", "date_joined", "password"]
    keycloak_connection = KeycloakHelper()

    def save_model(self, request, obj, form, change):
        if not change:  #  Création d'un compte utilisateur
            super().save_model(request, obj, form, change)

            keycloak_id = self.keycloak_connection.create_user(
                obj.email, obj.email
            )
            obj.keycloak_uuid = keycloak_id
        super().save_model(request, obj, form, change)

La fonction de mise à jour d'un utilisateur n'est pas implémentée (cas où change == True) mais si vous avez réussi à suivre jusque là, ce n'est pas très différent de la création d'un utilisateur.

Implémenter un test

Nous allons maintenant écrire un test afin de vérifier que notre application utilise bien l'API de Keycloak. Ensemble, nous allons mettre en place des mocks afin de simuler l'API de Keycloak.

Initialisation fainéante de la connexion

Tout d'abord, afin de mocker la librairie python-keycloak, il faut faire attention à initialiser de manière fainéante notre connexion à Keycloak. En effet, si une vue embarque notre objet KeycloakHelper, celui-ci sera construit à l'initialisation de Django (au moment du runserver), donc avant notre test. Dans le cas où nous initialisons l'objet KeycloakAdmin dans le constructeur de KeycloakHelper, nos mocks ne seront pas en place et le test échouera faute de serveur Keycloak.

Ainsi, écrivons une méthode qui réalise cette initialisation fainéante à partir de la méthode init_keycloak_connection :

class KeycloakHelper:
    def get_keycloak_connection(self) -> KeycloakAdmin:
        """
        Encapsule la connexion à Keycloak et réalise une initialisation fainéante de la connexion
        """
        if self._keycloak_admin is None:
            self._init_keycloak_connection()
        return self._keycloak_admin

Les méthodes qui utilisent l'objet KeycloakAdmin vont devoir être ré-écrites pour passer par cette méthode. Par exemple, pour la méthode get_user_detail cela donne :

class KeycloakHelper:
    @refresh_keycloak_token
    def get_user_detail(self, user_id: str):
        return self.get_keycloak_connection().get_user(user_id)

Mettre en place du mocking pour simuler l'API de Keycloak

Commençons par créer une classe de test qui mock l’objet KeycloakAdmin pour la durée de notre test :

from unittest.mock import patch
from django.test import TestCase

class KeycloakTestCase(TestCase):
    def setUp(self):
        self.patcher = patch(
            "accounts.helpers.KeycloakAdmin.__init__", autospec=True, return_value=None
        )
        self.mock_keycloak_admin = self.patcher.start()  # Activation du patch nécessaire pour le rendre effectif
        self.addCleanup(self.patcher.stop)  # Désactivation du patch après le test

Ici nous patchons le constructeur de KeycloakAdmin afin de renvoyer un objet de mock (MagicMock).

Toutes les classes qui testent une interaction avec Keycloak hériteront de KeycloakTestCase afin de bénéficier de ce comportement. Nous pourrons ainsi facilement patcher les autres méthodes que nous souhaitons simuler (KeycloakAdmin.get_user_detail, KeyclaokAdmin.create_user, etc.)

Nous allons aussi écrire une fonction permettant de générer l'URL de la vue d'ajout des utilisateurs pour le site d'administration :

from django.urls import reverse

def get_admin_create_view_url(cls) -> str:
    """
    À partir d'un objet ou d'une classe, renvoie l'url d'admin afin de créer un objet de ce type
    """
    return reverse("admin:{}_{}_add".format(cls._meta.app_label, cls.__name__.lower()))

Enfin, le test :

from django.contrib.admin import AdminSite

from accounts.admin import UserAdmin
from accounts.models import KeycloakUser

from uuid import uuid4

class AdminTestKeycloakInteraction(KeycloakTestCase):
    """
    Test que les fonctionnalités d'administration font bien appel à Keyclaok
    """

    def setUp(self):
        super().setUp()  # Bien penser à appeler le setUp() de la classe parente

        self.site = AdminSite()
        _ = UserAdmin(admin_site=self.site, model=KeycloakUser)
        self.admin_user = KeycloakUser.objects.create(is_superuser=True, is_staff=True, username="admin",
                                                      email="admin@example.com", keycloak_uuid=uuid4())

    @mock.patch("accounts.helpers.KeycloakAdmin.create_user", return_value=None)
    def test_user_create(
        self,
        mocked_create_user,
    ):
        uuid = uuid4()
        mocked_create_user.return_value = uuid

        payload = {
            "email": "test_user@example.com",
            "username": "username000",
        }
        self.client.force_login(self.admin_user)
        response = self.client.post(
            get_admin_create_view_url(KeycloakUser), data=payload, follow=True
        )
        self.assertEqual(response.status_code, 200)

        keycloak_payload = {
            "email": payload["email"],
            "username": payload["email"],  # USERNAME_FIELD = "email"
            "enabled": str(True),
        }
        mocked_create_user.assert_called_once_with(keycloak_payload)

        saved_user = KeycloakUser.objects.get(email=payload["email"])
        self.assertEqual(saved_user.keycloak_uuid, uuid)
        self.assertEqual(saved_user.email, payload["email"])

Notez bien que nous patchons les méthodes de KeycloakAdmin et non de KeycloakHelper. En effet, nous voulons tester le comportement de KeycloakHelper, il ne faut donc pas mettre en place de mock pour cette classe !

Conclusion

Vous avez maintenant toutes les clés en main pour réaliser d'autres opérations avec Keycloak pour compléter cette interface d'admin.

Avant d'implémenter des comportements plus complexes, je vous suggère de réaliser les opérations CRUD manquantes.

Limites

Pour l'instant, notre application Django ne permet pas de la connexion des administrateurs en SSO. Pour cela, il faudrait utiliser une brique logicielle OIDC telle que mozilla-django-oidc (pour faire du Just In Time Provisionning) ou drf-oidc-auth pour gérer l'authentification par Json Web Token (JWT).

Le code source accompagnant cet article est disponible sur notre Github.

Formations associées

Formations Django

Formation Django initiation

Nantes Du 12 au 14 mars 2024

Voir la formation

Formations Django

Formation Django avancé

À distance (FOAD) Du 9 au 13 décembre 2024

Voir la formation

Formations Django

Formation Django intégration

À distance (FOAD) 11 mai

Voir la formation

Actualités en lien

Image
logging
08/07/2020

Présentation de django-admin-watchdog

Comment garder une trace des erreurs Django en toute simplicité.

Voir l'article
Image
Django logo
08/07/2020

Présentation de django-tracking-fields

Suivi de modification d'objets Django

Voir l'article
02/08/2019

Créer un tag d'inclusion avec paramètres dans Django

La bibliothèque de tags interne permet d'enregistrer des tags avec paramètres ou des tags d'inclusion de template, voici comment faire les deux en même temps.

Voir l'article

Inscription à la newsletter

Nous vous avons convaincus