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}
+
+