diff --git a/back/config/settings/base.py b/back/config/settings/base.py index 093f45d50..1463d66bb 100644 --- a/back/config/settings/base.py +++ b/back/config/settings/base.py @@ -53,6 +53,7 @@ "dora.stats", "dora.notifications", "dora.logs", + "dora.auth_links", ] MIDDLEWARE = [ @@ -70,11 +71,14 @@ "mozilla_django_oidc.middleware.SessionRefresh", ] -# OIDC / ProConnect +# OIDC / ProConnect / Sesame AUTHENTICATION_BACKENDS = [ # auth par défaut pour la partie admin : "django.contrib.auth.backends.ModelBackend", + # OIDC / ProConnect "dora.oidc.OIDCAuthenticationBackend", + # connexion par "lien magique" + "sesame.backends.ModelBackend", ] # Permet de garder le comportement d'identification "standard" (e-mail/password) @@ -549,3 +553,13 @@ # Profiling (Silk) : # Doit être explicitement activé (via env var) PROFILE = False + +# Sesame (liens magiques) : +# Nom du token +SESAME_TOKEN_NAME = "dora_link" +# Durée de validité des tokens (secondes) +SESAME_MAX_AGE = 5 * 60 +# Les liens ne sont valides qu'une fois +SESAME_ONE_TIME = True +# Nom de la variable de session indiquant une connexion via sesame +SESAME_SESSION_NAME = "sesame_magic_link" diff --git a/back/config/urls.py b/back/config/urls.py index b9e300856..420c9f74a 100644 --- a/back/config/urls.py +++ b/back/config/urls.py @@ -13,6 +13,7 @@ import dora.structures.views import dora.support.views import dora.users.views +from dora.auth_links.urls import auth_links_patterns from dora.oidc.urls import oidc_patterns from .url_converters import InseeCodeConverter, SiretConverter @@ -116,6 +117,8 @@ *oidc_patterns, # nouvelles routes OIDC pour ProConnect path("oidc/", include("mozilla_django_oidc.urls")), + # "magic links" + *auth_links_patterns, ] if settings.PROFILE: diff --git a/back/dora/auth_links/__init__.py b/back/dora/auth_links/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/back/dora/auth_links/emails.py b/back/dora/auth_links/emails.py new file mode 100644 index 000000000..500c471eb --- /dev/null +++ b/back/dora/auth_links/emails.py @@ -0,0 +1,16 @@ +from django.conf import settings +from django.template.loader import render_to_string +from mjml import mjml2html + +from dora.core.emails import send_mail + + +def send_authentication_link(email, magic_link): + context = {"magic_link": magic_link} + send_mail( + "Votre lien de connexion pour DORA", + email, + mjml2html(render_to_string("send_link.mjml", context)), + from_email=("La plateforme DORA", settings.NO_REPLY_EMAIL), + tags=["lien-magique"], + ) diff --git a/back/dora/auth_links/templates/send_link.mjml b/back/dora/auth_links/templates/send_link.mjml new file mode 100644 index 000000000..ab1383bfc --- /dev/null +++ b/back/dora/auth_links/templates/send_link.mjml @@ -0,0 +1,24 @@ +{% extends "email-base.mjml" %} + +{% block preview %}Vous avez demandé un lien de connexion sur DORA{% endblock %} + +{% block title %}{% endblock %} + +{% block content %} +

+ Bonjour, +

+

+ Vous avez demandé un lien de connexion automatique pour DORA. +

+

+ Ce lien vous permet de vous connecter directement à DORA si vous possédiez un compte sur Inclusion Connect. +

+

Il est valide pendant 5 minutes et à usage unique.

+

Cliquez sur le bouton ci-dessous pour vous connecter à DORA :

+{% endblock %} + +{% block cta %} + Connexion à mon espace sur DORA + +{% endblock %} diff --git a/back/dora/auth_links/tests/__init__.py b/back/dora/auth_links/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/back/dora/auth_links/tests/test_views.py b/back/dora/auth_links/tests/test_views.py new file mode 100644 index 000000000..f77720560 --- /dev/null +++ b/back/dora/auth_links/tests/test_views.py @@ -0,0 +1,59 @@ +from uuid import uuid4 + +import sesame.utils +from django.conf import settings +from django.shortcuts import reverse + +from dora.core.test_utils import make_user + +# note : utilisation de `client` et pas `api_client` + +# note : inutile de tester la durée de validité et l'usage unique, +# c'est de la response de `django-sesame` + + +def test_send_link(client): + # aucun lien n'est envoyé si l'utilisateur n'a pas un compte IC + user_no_ic = make_user() + response = client.post( + reverse("send_link"), + data={"email": user_no_ic.email}, + ) + assert ( + response.status_code == 404 + ), "Une response 404 est attendue (utilisateur sans IC)" + + # on vérifie qu'un lien est envoyé si l'utilisateur à un compte IC + user_ic = make_user(ic_id=uuid4(), is_active=True) + + response = client.post( + reverse("send_link"), + data={"email": user_ic.email}, + ) + assert ( + response.status_code == 204 + ), "Une response 204 est attendue (utilisateur avec IC trouvé)" + + +def test_authenticate_with_link(client): + # Redirection vers l'accueil si le code fourni est invalide + response = client.get( + reverse("authenticate_with_link", kwargs={"sesame": "foo"}), + ) + assert response.status_code == 302, "Une redirection est attendue" + assert ( + response.url == settings.FRONTEND_URL + ), "En cas d'échec, on doit rediriger vers l'accueil" + + # Redirection vers l'identification du frontend si le code fourni est valide + user = make_user(ic_id=uuid4(), is_active=True) + + response = client.get( + reverse( + "authenticate_with_link", kwargs={"sesame": sesame.utils.get_token(user)} + ), + ) + assert response.status_code == 302, "Une redirection est attendue" + assert ( + "auth/pc-callback" in response.url + ), "On doit rediriger vers l'idetification du frontend" diff --git a/back/dora/auth_links/urls.py b/back/dora/auth_links/urls.py new file mode 100644 index 000000000..a30398d62 --- /dev/null +++ b/back/dora/auth_links/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +auth_links_patterns = [ + path("auth/send-link/", views.send_link, name="send_link"), + path( + "auth/authenticate-with-link//", + views.authenticate_with_link, + name="authenticate_with_link", + ), +] diff --git a/back/dora/auth_links/views.py b/back/dora/auth_links/views.py new file mode 100644 index 000000000..a9dd1347a --- /dev/null +++ b/back/dora/auth_links/views.py @@ -0,0 +1,50 @@ +from django.conf import settings +from django.http.response import HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +from rest_framework.authtoken.models import Token +from sesame.utils import get_token, get_user + +from dora.auth_links.emails import send_authentication_link +from dora.users.models import User + + +@csrf_exempt +@require_http_methods(["POST"]) +def send_link(request): + # on ne veut que des utilisateurs qui se sont déjà connectés via IC + user = get_object_or_404( + User, + email=request.POST.get("email"), + is_active=True, + is_valid=True, + ic_id__isnull=False, + ) + + magic_link = get_token(user) + url = request.build_absolute_uri( + reverse("authenticate_with_link", kwargs={"sesame": magic_link}) + ) + + send_authentication_link(user.email, url) + + return HttpResponse(status=204) + + +def authenticate_with_link(request, sesame): + if sesame: + if user := get_user(sesame): + token, _ = Token.objects.get_or_create(user=user) + + # on garde une trace de la connexion par lien magique + # pour ne pas effectuer le flow de déconnexion OIDC en entier + request.session[settings.SESAME_SESSION_NAME] = True + + redirect_uri = f"{settings.FRONTEND_URL}/auth/pc-callback/{token}/" + return HttpResponseRedirect(redirect_uri) + + # le lien est invalide ou expiré : + # redirection vers l'accueil (à défaut d'une page d'erreur) + return HttpResponseRedirect(settings.FRONTEND_URL) diff --git a/back/dora/oidc/views.py b/back/dora/oidc/views.py index fe2b4352c..b66ac03f3 100644 --- a/back/dora/oidc/views.py +++ b/back/dora/oidc/views.py @@ -234,7 +234,14 @@ def oidc_pre_logout(request): logout_url = furl(settings.OIDC_OP_LOGOUT_ENDPOINT, args=params) return HttpResponseRedirect(redirect_to=logout_url.url) - raise SuspiciousOperation("Tentative de déconnexion avec un token incorrect") + # si il n'y a pas de token présent en session, + # il est aussi possible que la session soit active suite à une connexion + # via "magic link" + if request.session.get(settings.SESAME_SESSION_NAME): + return HttpResponseRedirect(redirect_to=reverse("oidc_logout")) + + # Sinon + raise SuspiciousOperation("Tentative de déconnexion sans token ou lien magique") class CustomAuthorizationCallbackView(OIDCAuthenticationCallbackView): @@ -283,7 +290,7 @@ def post(self, request): if logout_state := request.session.pop("logout_state", None): if request.GET.get("state") != logout_state: raise SuspiciousOperation("La vérification de la déconnexion a échoué") - else: + elif not request.session.get(settings.SESAME_SESSION_NAME): raise SuspiciousOperation("Vérification de la déconnexion impossible") return super().post(request) diff --git a/back/requirements/base.txt b/back/requirements/base.txt index 3afb3b056..f0b4c0298 100644 --- a/back/requirements/base.txt +++ b/back/requirements/base.txt @@ -8,6 +8,7 @@ django-storages[boto3]==1.14.4 djangorestframework-camel-case==1.4.2 djangorestframework-gis==1.1 djangorestframework==3.15.2 +django-sesame==3.2.2 mozilla-django-oidc==4.0.1 furl==2.1.3 hiredis==3.0.0 diff --git a/front/src/lib/components/specialized/pc-button.svelte b/front/src/lib/components/specialized/pc-button.svelte index c51538a0c..501d38fea 100644 --- a/front/src/lib/components/specialized/pc-button.svelte +++ b/front/src/lib/components/specialized/pc-button.svelte @@ -13,15 +13,17 @@ {#if OIDC_AUTH_BACKEND === "proconnect"} - + + { + displayModal = true; + }} + > + Des difficultés à vous connecter ? + + {:else} {/if} @@ -206,4 +223,6 @@ + + diff --git a/front/src/routes/auth/connexion/send-magic-link.svelte b/front/src/routes/auth/connexion/send-magic-link.svelte new file mode 100644 index 000000000..78fb05664 --- /dev/null +++ b/front/src/routes/auth/connexion/send-magic-link.svelte @@ -0,0 +1,112 @@ + + + + +

+ Saisissez l'adresse e-mail de votre compte DORA ou de l’ancien compte + Inclusion Connect afin de recevoir un lien temporaire d'accès à votre + espace. +

+ {#if displayNotice} + +

+ Si un compte existe avec cette adresse e-mail, vous recevrez un lien + temporaire sur cette même adresse dans quelques instants. +

+
+
+
+ {:else} + +
+
+ +
+
+
+
+ {/if} +
+