Skip to content
This repository has been archived by the owner on Oct 22, 2024. It is now read-only.

Commit

Permalink
oidc: modification de l'app
Browse files Browse the repository at this point in the history
- ajouts des routes pesonnalisées OIDC pour les redirections vers le
front-end
- modification de certaines portions de `mozilla-django-oidc` avec une
vue custom
  • Loading branch information
ikarius committed Oct 3, 2024
1 parent 6da184a commit a4bcf73
Show file tree
Hide file tree
Showing 4 changed files with 232 additions and 1 deletion.
113 changes: 113 additions & 0 deletions dora/oidc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,115 @@
from logging import getLogger

import requests
from django.core.exceptions import SuspiciousOperation
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from rest_framework.authtoken.models import Token

from dora.users.models import User

logger = getLogger(__name__)


class OIDCError(Exception):
"""Exception générique pour les erreurs OIDC"""


class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
def get_userinfo(self, access_token, id_token, payload):
# Surcharge de la récupération des informations utilisateur:
# le décodage JSON du contenu JWT pose problème avec ProConnect
# qui le retourne en format binaire (content-type: application/jwt)
# d'où ce petit hack.
# Inspiré de : https://github.com/numerique-gouv/people/blob/b637774179d94cecb0ef2454d4762750a6a5e8c0/src/backend/core/authentication/backends.py#L47C1-L47C57
user_response = requests.get(
self.OIDC_OP_USER_ENDPOINT,
headers={"Authorization": "Bearer {0}".format(access_token)},
verify=self.get_settings("OIDC_VERIFY_SSL", True),
timeout=self.get_settings("OIDC_TIMEOUT", None),
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()

try:
# cas où le type du token JWT est `application/json`
return user_response.json()
except requests.exceptions.JSONDecodeError:
# sinon, on présume qu'il s'agit d'un token JWT au format `application/jwt` (+...)
# comme c'est le cas pour ProConnect.
return self.verify_token(user_response.text)

# Pas nécessaire de surcharger `get_or_create_user` puisque sur DORA,
# les utilisateurs ont un e-mail unique qui leur sert de `username`.

def create_user(self, claims):
# on peut à la rigueur se passer de certains élements contenus dans les claims,
# mais pas de ceux-là :
email, sub = claims.get("email"), claims.get("sub")
if not email:
raise SuspiciousOperation(
"L'adresse e-mail n'est pas inclue dans les `claims`"
)

if not sub:
raise SuspiciousOperation(
"Le sujet (`sub`) n'est pas inclu dans les `claims`"
)

# TODO: le SIRET fait partie des claims obligatoire,
# voir comment traiter les rattachements à une structure.
# De plus, il semble que l'appartenance à plusieurs SIRET soit possible.

# L'utilisateur est créé sans mot de passe (aucune connexion à l'admin),
# et comme venant de ProConnect, on considère l'e-mail vérifié.
new_user = self.UserModel.objects.create_user(
email,
sub_pc=sub,
first_name=claims.get("given_name", "N/D"),
last_name=claims.get("usual_name", "N/D"),
is_valid=True,
)

# compatibilité :
# durant la phase de migration vers ProConnect on ne replace *que* le fournisseur d'identité,
# et on ne touche pas aux mécanismes d'identification entre back et front.
self.get_or_create_drf_token(new_user)

return new_user

def update_user(self, user, claims):
# L'utilisateur peut déjà étre inscrit à IC, dans ce cas on réutilise la plupart
# des informations déjà connues

if not user.sub_pc:
# utilisateur existant, mais non-enregistré sur ProConnect
sub = claims.get("sub")
if not sub:
raise SuspiciousOperation(
"Le sujet (`sub`) n'est pas inclu dans les `claims`"
)
user.sub_pc = sub
user.save()

return user

def get_user(self, user_id):
if user := super().get_user(user_id):
self.get_or_create_drf_token(user)
return user
return None

def get_or_create_drf_token(self, user_email):
# Pour être temporairement compatible, on crée un token d'identification DRF lié au nouvel utilisateur.
if not user_email:
logger.exception("Utilisateur non renseigné pour la création du token DRF")

user = User.objects.get(email=user_email)

token, created = Token.objects.get_or_create(user=user)

if created:
logger.info("Initialisation du token DRF pour l'utilisateur %s", user_email)

return token
13 changes: 13 additions & 0 deletions dora/oidc/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.apps import AppConfig

"""
dora.oidc:
Gère les connexions OIDC-Connect via ProConnect.
Basée sur un provider custom de django-allauth.
Remplace l'ancien système de connexion à Inclusion-Connect à partir de novembre 2024.
"""


class OIDCConfig(AppConfig):
name = "dora.oidc"
verbose_name = "Gestion des connexions ProConnect"
18 changes: 17 additions & 1 deletion dora/oidc/urls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import mozilla_django_oidc.urls # noqa: F401
from django.urls import path

import dora.oidc.views as views

oidc_patterns = [
inclusion_connect_patterns = [
path(
"inclusion-connect-get-login-info/",
views.inclusion_connect_get_login_info,
Expand All @@ -20,3 +21,18 @@
views.inclusion_connect_authenticate,
),
]

proconnect_patterns = [
# les patterns internes pour le callback et le logout sont définis
# dans le fichier `urls.py` de mozilla_django_oidc
# redirection vers ProConnect pour la connexion
path("oidc/login/", views.oidc_login, name="oidc_login"),
# redirection une fois la connexion terminée
path("oidc/logged_in/", views.oidc_logged_in, name="oidc_logged_in"),
# preparation au logout : 2 étapes nécessaires
# l'une de déconnexion sur ProConnect, l'autre locale de destruction de la session active
path("oidc/pre_logout/", views.oidc_pre_logout, name="oidc_pre_logout"),
]


oidc_patterns = inclusion_connect_patterns + proconnect_patterns
89 changes: 89 additions & 0 deletions dora/oidc/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.http import HttpResponseForbidden
from django.http.response import HttpResponseRedirect
from django.urls import reverse
from django.utils.crypto import get_random_string
from furl import furl
from mozilla_django_oidc.views import OIDCAuthenticationCallbackView, resolve_url
from rest_framework import permissions
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes
Expand Down Expand Up @@ -171,3 +175,88 @@ def inclusion_connect_authenticate(request):
except requests.exceptions.RequestException as e:
logging.exception(e)
raise APIException("Erreur de communication avec le fournisseur d'identité")


# Migration vers ProConnect :
# En parallèle des différents endpoints OIDC inclusion-connect (gardés pour problème éventuel).


@api_view(["GET"])
@permission_classes([permissions.AllowAny])
def oidc_login(request):
# Simple redirection vers la page d'identification ProConnect (si pas identifié)
return HttpResponseRedirect(
redirect_to=reverse("oidc_authentication_init")
+ f"?{request.META.get("QUERY_STRING")}"
)


@api_view(["GET"])
@permission_classes([permissions.AllowAny])
def oidc_logged_in(request):
# étape indispensable pour le passage du token au frontend_state :
# malheuresement, cette étape est "zappée" si un paramètre `next` est passé lors de l'identification
# mozilla-django-oidc ne le prends pas en compte, il faut pour modifier la vue de callback et le redirect final

# attention : l'utilisateur est toujours anonyme (a ce point il n'existe qu'un token DRF)
token = Token.objects.get(user_id=request.session["_auth_user_id"])

redirect_uri = f"{settings.FRONTEND_URL}/auth/pc-callback/{token}/"

# gestion du next :
if next := request.GET.get("next"):
redirect_uri += f"?next={next}"

# on redirige (pour l'instant) vers le front en faisant passer le token DRF
return HttpResponseRedirect(redirect_to=redirect_uri)


@api_view(["GET"])
@permission_classes([permissions.AllowAny])
def oidc_pre_logout(request):
# attention : le nom oidc_logout est pris par mozilla-django-oidc
# récuperation du token stocké en session:
if oidc_token := request.session.get("oidc_id_token"):
# construction de l'URL de logout
params = {
"id_token_hint": oidc_token,
"state": "todo_xxx",
"post_logout_redirect_uri": request.build_absolute_uri(
reverse("oidc_logout")
),
}
logout_url = furl(settings.OIDC_OP_LOGOUT_ENDPOINT, args=params)
return HttpResponseRedirect(redirect_to=logout_url.url)

# FIXME: URL de fallback ?
return HttpResponseForbidden("Déconnexion incorrecte")


class CustomAuthorizationCallbackView(OIDCAuthenticationCallbackView):
"""
Callback OIDC :
Vue personnalisée basée en grande partie sur celle définie par `mozilla-django-oidc`,
pour la gestion du retour OIDC après identification.
La gestion du `next_url` par la classe par défaut n'est pas satisfaisante dans le contexte de DORA,
la redirection vers le frontend nécessitant une étape supplémentaire pour l'enregistrement du token DRF.
Cette classe modifie la dernière redirection du flow pour y ajouter le paramètre d'URL suivant,
plutôt que d'effectuer une redirection directement vers ce paramètre.
A noter qu'il est trés simple de modifier les différentes étapes du flow OIDC pour les adapter,
`mozilla-django-oidc` disposant d'une série de settings pour spécifier les classes de vue à utiliser
pour chaque étape OIDC (dans ce cas via le setting `OIDC_CALLBACK_CLASS`).
"""

@property
def success_url(self):
# récupération du paramètre d'URL suivant stocké en session en début de flow OIDC

next_url = self.request.session.get("oidc_login_next", None)
next_fieldname = self.get_settings("OIDC_REDIRECT_FIELD_NAME", "next")

success_url = resolve_url(self.get_settings("LOGIN_REDIRECT_URL", "/"))
success_url += f"?{next_fieldname}={next_url}" if next_url else ""

# redirection vers le front via `oidc/logged_in`
return success_url

0 comments on commit a4bcf73

Please sign in to comment.