Makina Blog
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.
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 avancé
À distance (FOAD) Du 9 au 13 décembre 2024
Voir la formationActualités en lien
Présentation de django-admin-watchdog
Comment garder une trace des erreurs Django en toute simplicité.
Présentation de django-tracking-fields
Suivi de modification d'objets Django
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.