Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Connexion automatique par lien #29

Merged
merged 6 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion back/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"dora.stats",
"dora.notifications",
"dora.logs",
"dora.auth_links",
]

MIDDLEWARE = [
Expand All @@ -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)
Expand Down Expand Up @@ -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"
3 changes: 3 additions & 0 deletions back/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Empty file.
16 changes: 16 additions & 0 deletions back/dora/auth_links/emails.py
Original file line number Diff line number Diff line change
@@ -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"],
)
24 changes: 24 additions & 0 deletions back/dora/auth_links/templates/send_link.mjml
Original file line number Diff line number Diff line change
@@ -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 %}
<p>
<strong>Bonjour,</strong>
</p>
<p>
Vous avez demandé un lien de connexion automatique pour DORA.
</p>
<p>
Ce lien vous permet de vous connecter directement à DORA si vous possédiez un compte sur Inclusion Connect.
</p>
<p>Il est valide pendant <strong>5 minutes</strong> et <strong>à usage unique</strong>.</p>
<p>Cliquez sur le bouton ci-dessous pour vous connecter à DORA :</p>
{% endblock %}

{% block cta %}
<mj-button mj-class="cta" href="{{ magic_link }}">Connexion à mon espace sur DORA</mj-button>
<mj-spacer height="24px"/>
{% endblock %}
Empty file.
59 changes: 59 additions & 0 deletions back/dora/auth_links/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 12 additions & 0 deletions back/dora/auth_links/urls.py
Original file line number Diff line number Diff line change
@@ -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/<str:sesame>/",
views.authenticate_with_link,
name="authenticate_with_link",
),
]
50 changes: 50 additions & 0 deletions back/dora/auth_links/views.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 9 additions & 2 deletions back/dora/oidc/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions back/requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 11 additions & 9 deletions front/src/lib/components/specialized/pc-button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@
</a>

<div class="text-center">
<a
class="text-magenta-cta underline"
target="_blank"
title="Aide DORA - ouverture dans une nouvelle fenêtre"
rel="noopener noreferrer"
href="https://aide.dora.inclusion.beta.gouv.fr/fr/category/inscription-et-gestion-du-compte-ha8m5b/"
>
Besoin d’aide&#8239;? Contactez-nous
</a>
<slot name="pc-help-link">
<a
class="text-magenta-cta underline"
target="_blank"
title="Aide DORA - ouverture dans une nouvelle fenêtre"
rel="noopener noreferrer"
href="https://aide.dora.inclusion.beta.gouv.fr/fr/category/inscription-et-gestion-du-compte-ha8m5b/"
>
Besoin d’aide&#8239;? Contactez-nous
</a>
</slot>
&nbsp;
<a
class="text-magenta-cta underline"
Expand Down
21 changes: 20 additions & 1 deletion front/src/routes/auth/connexion/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import IcButton from "$lib/components/specialized/ic-button.svelte";
import PcButton from "$lib/components/specialized/pc-button.svelte";
import { OIDC_AUTH_BACKEND } from "$lib/env";
import SendMagicLink from "./send-magic-link.svelte";

function getLoginHint() {
const loginHint = $page.url.searchParams.get("login_hint");
Expand All @@ -30,6 +31,8 @@

const loginHint = getLoginHint();
const nextPage = getNextPage($page.url);

let displayModal = false;
</script>

<CenteredGrid>
Expand Down Expand Up @@ -79,7 +82,21 @@
</div>

{#if OIDC_AUTH_BACKEND === "proconnect"}
<PcButton {nextPage}></PcButton>
<PcButton {nextPage}>
<a
slot="pc-help-link"
class="text-magenta-cta underline"
target="_blank"
title="Obtention d'un lien de connexion - ouverture dans une fenêtre modale"
rel="noopener noreferrer"
href="#"
on:click|preventDefault={() => {
displayModal = true;
}}
>
Des difficultés à vous connecter&#8239;?
</a>
</PcButton>
{:else}
<IcButton {nextPage} {loginHint}></IcButton>
{/if}
Expand Down Expand Up @@ -206,4 +223,6 @@
</div>
</div>
</div>

<SendMagicLink bind:displayModal />
</CenteredGrid>
Loading
Loading