Skip to content

Commit

Permalink
Migration vers ProConnect (#19)
Browse files Browse the repository at this point in the history
* Ajout du bouton ProConnect officiel

* oidc: redirection après callback d'identification OIDC

* oidc: effacement des données de connexion avant logout OIDC

En 2 phases :
- effacement du token coté frontend
- finalisation de la déconnexion coté backend

* Ajout du bouton de déconnexion ProConnect

* models: ajout du champ `sub_pc` pour ProConnect

* oidc: modification de l'app

- 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

* libs: ajout de `mozilla-django-oidc`

Ainsi que sa configuration et la modification des routes pour permettre
à la fois l'utilisation de ProConnect et d'Inclusion-Connect

* Vérification de la déconnexion ProConnect

En 2 étapes déjà gérées, mais cette fois-ci en véfifiant l'état du
paramètre `state` passé par ProConnect (sécurité).

* fix: ajout du backend d'identification par défaut

Dans la configuration précédente, seule l'identification par OIDC était
possible.
Mais la partie admin de Django à besoin de l'identification par "modéle"
(qui est celle par défaut).
Les deux oexistent sereinement maintenant.

* safir: récupération du code SAFIR

Même si on n'en fait encore rien, le point de récupération de la donnée
est identifié dans le code.

* fix: modification d'un log en exception

Résidu des tests, maintenant l'absence d'e-mail doit lever une
exception.

* Ajout d'une variable d'environnement pour identifier le backend OIDC

On peut encore choisir entre Inclusion Connect et ProConnect via la
var-env OIDC_AUTH_BACKEND (par défaut `proconnect`)

* revue: typos

* Vérification complémentaire de la validité du `sub`

Moyen détourné de reporter la responsabilité de la validité des subs
fournis vers ProConnect (ce qui est présentement le cas).

* Ajout des assets ProConnect

* Modification de la page de connexion

L'affichage vers Inclusion Connect est toujours possible.

* Modification des différentes pages mentionnant Inclusion Connect

* fix: récupération de la var-env OIDC_AUTH_BACKEND

Doit-être préfixée par 'VITE_'

* fix: correction du `next_url` avec multiples paramètres

* revue: corrections et typos

---------

Co-authored-by: Gérald Gounot <[email protected]>
  • Loading branch information
ikarius and ggounot authored Oct 26, 2024
1 parent 588ecd8 commit 8fd1460
Show file tree
Hide file tree
Showing 22 changed files with 657 additions and 115 deletions.
72 changes: 69 additions & 3 deletions back/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
INSTALLED_APPS = [
"django.contrib.gis",
"django.contrib.auth",
# OIDC / ProConnect : doit être chargé après `django.contrib.auth`
"mozilla_django_oidc",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
Expand Down Expand Up @@ -64,8 +66,21 @@
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"csp.middleware.CSPMiddleware",
# Rafraichissement du token ProConnect
"mozilla_django_oidc.middleware.SessionRefresh",
]

# OIDC / ProConnect
AUTHENTICATION_BACKENDS = [
# auth par défaut pour la partie admin :
"django.contrib.auth.backends.ModelBackend",
"dora.oidc.OIDCAuthenticationBackend",
]

# Permet de garder le comportement d'identification "standard" (e-mail/password)
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_AUTHENTICATION_METHOD = "email"

ROOT_URLCONF = "config.urls"

TEMPLATES = [
Expand Down Expand Up @@ -287,7 +302,7 @@
# Modération :
MATTERMOST_HOOK_KEY = os.getenv("MATTERMOST_HOOK_KEY")

# INCLUSION-CONNECT / PRO-CONNECT
# INCLUSION-CONNECT
IC_ISSUER_ID = os.getenv("IC_ISSUER_ID")
IC_AUTH_URL = os.getenv("IC_AUTH_URL")
IC_TOKEN_URL = os.getenv("IC_TOKEN_URL")
Expand All @@ -296,7 +311,59 @@
IC_CLIENT_ID = os.getenv("IC_CLIENT_ID")
IC_CLIENT_SECRET = os.getenv("IC_CLIENT_SECRET")

# Recherches sauvagardées :
# OIDC / PROCONNECT
PC_CLIENT_ID = os.getenv("PC_CLIENT_ID")
PC_CLIENT_SECRET = os.getenv("PC_CLIENT_SECRET")
PC_DOMAIN = os.getenv("PC_DOMAIN", "fca.integ01.dev-agentconnect.fr")
PC_ISSUER = os.getenv("PC_ISSUER", f"{PC_DOMAIN}/api/v2")
PC_AUTHORIZE_PATH = os.getenv("PC_AUTHORIZE_PATH", "authorize")
PC_TOKEN_PATH = os.getenv("PC_TOKEN_PATH", "token")
PC_USERINFO_PATH = os.getenv("PC_USERINFO_PATH", "userinfo")

# ProConnect à besoin de ce setting pour le logout
FRONTEND_URL = os.getenv("FRONTEND_URL")

# mozilla_django_oidc:
OIDC_RP_CLIENT_ID = os.getenv("PC_CLIENT_ID")
OIDC_RP_CLIENT_SECRET = os.getenv("PC_CLIENT_SECRET")
OIDC_RP_SCOPES = "openid given_name usual_name email siret custom uid"

# `mozilla_django_oidc` n'utilise pas de discovery / .well-known
# on définit donc chaque endpoint
OIDC_RP_SIGN_ALGO = "RS256"
OIDC_OP_JWKS_ENDPOINT = f"https://{PC_ISSUER}/jwks"
OIDC_OP_AUTHORIZATION_ENDPOINT = f"https://{PC_ISSUER}/authorize"
OIDC_OP_TOKEN_ENDPOINT = f"https://{PC_ISSUER}/token"
OIDC_OP_USER_ENDPOINT = f"https://{PC_ISSUER}/userinfo"
OIDC_OP_LOGOUT_ENDPOINT = f"https://{PC_ISSUER}/session/end"

# Les paramètres suivants servent à adapter la configuration OIDC
# de `mozilla-django_oidc` pour pouvoir fonctionner dans le contexte
# spécifique à DORA et ProConnect.

# OIDC : intervalle de rafraichissement du token (4h)
OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS = 4 * 60 * 60

# OIDC : nécessaire pour la gestion de la fin de session coté ProConnect
OIDC_STORE_ID_TOKEN = True
ALLOW_LOGOUT_GET_METHOD = True

# obligatoire pour ProConnect: à passer en paramètre de requête supplémentaire
# lors de la première phase du flow OIDC
OIDC_AUTH_REQUEST_EXTRA_PARAMS = {"acr_values": "eidas1"}

# OIDC : redirection vers le front DORA en cas de succès de l'identification
# necessaire pour la gestion de "l'URL suivant" (`next_url`)
LOGIN_REDIRECT_URL = "/oidc/logged_in/"

# OIDC : redirection vers l'acceuil du front DORA pour la déconnexion
LOGOUT_REDIRECT_URL = FRONTEND_URL

# OIDC : permet de préciser quelle est la class/vue en charge du callback dans le flow OIDC
# (essentiellement pour la gestion du `next_url`).
OIDC_CALLBACK_CLASS = "dora.oidc.views.CustomAuthorizationCallbackView"

# Recherches sauvegardées :
INCLUDES_DI_SERVICES_IN_SAVED_SEARCH_NOTIFICATIONS = (
os.getenv("INCLUDES_DI_SERVICES_IN_SAVED_SEARCH_NOTIFICATIONS") == "true"
)
Expand Down Expand Up @@ -353,7 +420,6 @@
EMAIL_PORT = os.getenv("EMAIL_PORT")
EMAIL_USE_TLS = True
EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN")
FRONTEND_URL = os.getenv("FRONTEND_URL")
SUPPORT_EMAIL = os.getenv("SUPPORT_EMAIL")

SUPPORT_LINK = "https://aide.dora.inclusion.beta.gouv.fr"
Expand Down
4 changes: 3 additions & 1 deletion back/config/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,7 @@
IC_TOKEN_URL = os.getenv("IC_TOKEN_URL", "https://whatever-oidc-token-url.com")
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "dora")
SIB_ONBOARDING_LIST = os.getenv("SIB_ONBOARDING_LIST", "1")
SIB_ONBOARDING_PUTATIVE_MEMBER_LIST = os.getenv("SIB_ONBOARDING_PUTATIVE_MEMBER_LIST", "2")
SIB_ONBOARDING_PUTATIVE_MEMBER_LIST = os.getenv(
"SIB_ONBOARDING_PUTATIVE_MEMBER_LIST", "2"
)
SIB_ONBOARDING_MEMBER_LIST = os.getenv("SIB_ONBOARDING_MEMBER_LIST", "3")
3 changes: 3 additions & 0 deletions back/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@
urlpatterns = [
*private_api_patterns,
*di_api_patterns,
# anciennes routes Inclusion-Connect (en attente de suppression)
*oidc_patterns,
# nouvelles routes OIDC pour ProConnect
path("oidc/", include("mozilla_django_oidc.urls")),
]

if settings.PROFILE:
Expand Down
124 changes: 124 additions & 0 deletions back/dora/oidc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,126 @@
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 incluse dans les `claims`"
)

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

# 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,
)

# recupération du code SAFIR :
# même pour l'instant inutilisé, on pourra par la suite le passer au frontend
# pour rattachement direct à une agence France Travail
if custom := claims.get("custom"):
code_safir = custom.get("structureTravail") # noqa F481
# TODO: une fois le code SAFIR récupéré, voir quoi en faire (redirection vers un rattachement)

# 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
sub = claims.get("sub")

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

if user.sub_pc and str(user.sub_pc) != sub:
raise SuspiciousOperation(
"Le sub enregistré est différent de celui fourni par ProConnect"
)

if not user.sub_pc:
# utilisateur existant, mais non-enregistré sur ProConnect
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:
raise SuspiciousOperation(
"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 back/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"
21 changes: 20 additions & 1 deletion back/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,21 @@
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"),
# la plupart des vues de `mozilla-django-oidc` sont paramètrables
# pas le logout
path("oidc/logout/", views.CustomLogoutView.as_view(), name="oidc_logout"),
]


oidc_patterns = inclusion_connect_patterns + proconnect_patterns
Loading

0 comments on commit 8fd1460

Please sign in to comment.