Skip to content

Commit

Permalink
feat(user): authentification via un lien magique envoyé par email (#804)
Browse files Browse the repository at this point in the history
## Description

🎸 En complément de l'authentification via `Pro Connect` (#731),
permettre à un utlisateur de s'authentifier via un lien magique envoyé
par email.
🎸 Nécessaire pour les utilisateurs n'appartenant pas à une organisation,
car ils ne peuvent pas utiliser `Pro Connect`

🐻 La vue principale de connexion est désormais `LoginView`. Elle permet
de recevoir un magic link ou de se connecter avec ProConnect.
🐻 Si l'utilisateur se connecte avec un lien magic, une variable est
positionnée dans sa session pour determiner le mécanisme de déconnection
à actionner.
🐻 `LoginView` n'est plus accessible si l'utilisateur est authentifié.

⚠️ edge case 
Un utilisateur demande un magic link, puis se connecter avec ProConnect,
puis clique sur le magic link. La déconnection pourrait être celle du
magic link (pas de déco ProConnect). Pas d'effet de bord catastrophique
attendu.

## Type de changement

🎢 Nouvelle fonctionnalité (changement non cassant qui ajoute une
fonctionnalité).
🚧 technique

### Points d'attention

🦺 ajout de la méthode `clean_next_url` pour limiter les risques sur les
redirections
🦺 en dev, les magic link sont enregistrés dans `EmailSentTrack`
🦺 ref
https://www.honeybadger.io/blog/options-for-passwordless-authentication-in-django/
🦺 ref https://stackoverflow.com/a/46236585

🦺 pour une PR suivante : ajouter le contrôle sur `BlockedEmail` et sur
`BlockedDomainName` pour prévenir d'eventuels spammers


### Captures d'écran (optionnel)

LoginView

![image](https://github.com/user-attachments/assets/85b209f4-2ee3-4294-9499-a5bf89d69374)

CreateUserView

![image](https://github.com/user-attachments/assets/f1434872-fcf0-42ca-9f3c-7143232a4788)

Login Link Sent

![image](https://github.com/user-attachments/assets/a0e26f6d-bf13-4f13-8aee-60e6e684b42f)
  • Loading branch information
vincentporte authored Nov 14, 2024
1 parent c0f89a2 commit 64e04a3
Show file tree
Hide file tree
Showing 29 changed files with 1,141 additions and 25 deletions.
3 changes: 2 additions & 1 deletion config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,14 +353,15 @@

# SENDINBLUE
# ---------------------------------------
SIB_URL = "https://api.brevo.com/v3/"
SIB_URL = os.getenv("SIB_URL", "http://test.com")
SIB_SMTP_URL = os.path.join(SIB_URL, "smtp/email")
SIB_CONTACTS_URL = os.path.join(SIB_URL, "contacts/import")
SIB_CONTACT_LIST_URL = os.path.join(SIB_URL, "contacts/lists")

SIB_API_KEY = os.getenv("SIB_API_KEY", "set-sib-api-key")
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "[email protected]")

SIB_MAGIC_LINK_TEMPLATE = 31
SIB_UNANSWERED_QUESTION_TEMPLATE = 10
SIB_ONBOARDING_LIST = 5
SIB_NEW_MESSAGES_TEMPLATE = 28
Expand Down
3 changes: 3 additions & 0 deletions config/settings/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@

CSP_DEFAULT_SRC = ("*",)
CSP_IMG_SRC += ("localhost:9000",) # noqa: F405

COMMU_PROTOCOL = "http"
COMMU_FQDN = "127.0.0.1:8000"
2 changes: 2 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from lacommunaute.search import urls as search_urls
from lacommunaute.stats import urls as stats_urls
from lacommunaute.surveys import urls as surveys_urls
from lacommunaute.users import urls as users_urls


conversation_urlpatterns_factory = get_class("forum_conversation.urls", "urlpatterns_factory")
Expand All @@ -31,6 +32,7 @@
# www.
path("", include(pages_urls)),
path("members/", include(forum_member_urls)),
path("users/", include(users_urls)),
path("", include(forum_conversation_extension_urls)),
path("", include(forum_extension_urls)),
path("", include(forum_polls_extension_urls)),
Expand Down
6 changes: 5 additions & 1 deletion lacommunaute/notification/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ def send_email(to, params, template_id, kind, bcc=None):
if bcc:
payload["bcc"] = bcc

response = httpx.post(settings.SIB_SMTP_URL, headers=headers, json=payload)
if settings.DEBUG:
# We don't want to send emails in debug mode, payload is saved in the database
response = httpx.Response(200, json={"message": "OK"})
else:
response = httpx.post(settings.SIB_SMTP_URL, headers=headers, json=payload)

EmailSentTrack.objects.create(
status_code=response.status_code,
Expand Down
1 change: 1 addition & 0 deletions lacommunaute/notification/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class EmailSentTrackKind(models.TextChoices):
FOLLOWING_REPLIES = "following_replies", "Réponses suivantes"
ONBOARDING = "onboarding", "Onboarding d'un nouvel utilisateur"
PENDING_TOPIC = "pending_topic", "Question sans réponse"
MAGIC_LINK = "magic_link", "Lien de connexion magique"


class NotificationDelay(models.TextChoices):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Generated by Django 5.0.9 on 2024-11-13 13:44

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("notification", "0009_alter_emailsenttrack_kind_notification"),
]

operations = [
migrations.AlterField(
model_name="emailsenttrack",
name="kind",
field=models.CharField(
choices=[
("first_reply", "Première réponse à un sujet"),
("following_replies", "Réponses suivantes"),
("onboarding", "Onboarding d'un nouvel utilisateur"),
("pending_topic", "Question sans réponse"),
("magic_link", "Lien de connexion magique"),
],
max_length=20,
verbose_name="type",
),
),
migrations.AlterField(
model_name="notification",
name="kind",
field=models.CharField(
choices=[
("first_reply", "Première réponse à un sujet"),
("following_replies", "Réponses suivantes"),
("onboarding", "Onboarding d'un nouvel utilisateur"),
("pending_topic", "Question sans réponse"),
("magic_link", "Lien de connexion magique"),
],
max_length=20,
verbose_name="type",
),
),
]
5 changes: 4 additions & 1 deletion lacommunaute/notification/tests/tests_emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,11 @@ def setUpTestData(cls):
return_value=httpx.Response(200, json=cls.contact_list_response)
)

@respx.mock
def test_collect_users_from_list_bad_status_code(self):
self.assertIsNone(collect_users_from_list(faker.random_int()))
list_id = faker.random_int()
respx.get(SIB_CONTACT_LIST_URL + f"/{list_id}/contacts").mock(return_value=httpx.Response(500))
self.assertIsNone(collect_users_from_list(list_id))

@respx.mock
def test_collect_users_from_list(self):
Expand Down
4 changes: 2 additions & 2 deletions lacommunaute/openid_connect/tests/tests_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ class OpenID_LoginTest(OpenID_BaseTestCase):
@respx.mock
def test_normal_signin(self):
"""
A user has created an account with Inclusion Connect.
A user has created an account with Pro Connect.
He logs out.
He can log in again later.
"""
Expand All @@ -179,7 +179,7 @@ def test_normal_signin(self):

# Then log in again.
response = self.client.get(reverse("pages:home"))
self.assertContains(response, reverse("openid_connect:authorize"))
self.assertContains(response, reverse("users:login"))

response = mock_oauth_dance(self, assert_redirects=False)
expected_redirection = reverse("pages:home")
Expand Down
8 changes: 4 additions & 4 deletions lacommunaute/pages/tests/__snapshots__/test_homepage.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@
</li>

<li>
<a class="btn" href="/pro_connect/authorize" rel="nofollow">Se connecter | S'inscrire</a>
</li>
<a class="btn btn-outline-primary btn-ico btn-block" href="/users/login/?next=/" rel="nofollow">Se connecter | S'inscrire</a>
</li>

</ul>
</nav>
Expand Down Expand Up @@ -257,8 +257,8 @@
<nav aria-label="Menu de navigation principale pour mobile" role="navigation">

<div>
<a class="btn" href="/pro_connect/authorize" rel="nofollow">Se connecter | S'inscrire</a>
</div>
<a class="btn btn-outline-primary btn-ico btn-block" href="/users/login/?next=/" rel="nofollow">Se connecter | S'inscrire</a>
</div>

</nav>
</div>
Expand Down
2 changes: 1 addition & 1 deletion lacommunaute/surveys/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
location_field_list = ["location", "city_code"]

form_html = '<form method="post">'
login_with_next_url = reverse("openid_connect:authorize") + "?next=" + reverse("surveys:dsp_create")
login_with_next_url = reverse("users:login") + "?next=" + reverse("surveys:dsp_create")


class TestDSPCreateView:
Expand Down
16 changes: 4 additions & 12 deletions lacommunaute/templates/partials/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,12 @@
<li>
<div class="dropdown-divider"></div>
</li>
<li>
<a class="dropdown-item text-danger" id="js-logout" href="{% url 'openid_connect:logout' %}">Déconnexion</a>
</li>
<li>{% include "registration/includes/logout_link.html" %}</li>
</ul>
</div>
</li>
{% else %}
<li>
<a href="{% url 'openid_connect:authorize' %}" rel="nofollow" class="btn">Se connecter | S'inscrire</a>
</li>
<li>{% include "registration/includes/login_link.html" %}</li>
{% endif %}
</ul>
</nav>
Expand Down Expand Up @@ -277,16 +273,12 @@ <h4 class="h5 mb-0 btn-ico align-items-center" id="offcanvasApplyFiltersLabel">
<li>
<div class="dropdown-divider"></div>
</li>
<li>
<a class="dropdown-item text-danger" id="js-logout" href="{% url 'openid_connect:logout' %}">Déconnexion</a>
</li>
<li>{% include "registration/includes/logout_link.html" %}</li>
</ul>
</div>
</div>
{% else %}
<div>
<a href="{% url 'openid_connect:authorize' %}" rel="nofollow" class="btn">Se connecter | S'inscrire</a>
</div>
<div>{% include "registration/includes/login_link.html" %}</div>
{% endif %}
</nav>
</div>
Expand Down
51 changes: 51 additions & 0 deletions lacommunaute/templates/registration/create_user.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{% extends "board_base.html" %}
{% load i18n %}
{% block sub_title %}
{% trans "Sign in" %}
{% endblock sub_title %}
{% block content %}
<section class="s-title-01 mt-lg-5">
<div class="s-title-01__container container">
<div class="s-title-01__row row">
<div class="s-title-01__col col-12">
<h1 class="s-title-01__title h1">
<strong>{% trans "Sign in" %}</strong>
</h1>
</div>
</div>
</div>
</section>
<section class="s-section">
<div class="s-section__container container">
<div class="s-section__row row">
<div class="s-section__col col-12 col-lg-7">
<div class="card">
<div class="card-body">
Bonjour {{ email }} et bienvenue dans la communauté de l'inclusion, encore quelques informations avant de vous envoyer le lien.
</div>
<div class="c-form">
<form method="post" action="." enctype="multipart/form-data" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
<div class="alert alert-danger">
<i class="icon-exclamation-sign"></i>
{{ error }}
</div>
{% endfor %}
{% endif %}
{% include "partials/form_field.html" with field=form.email %}
{% include "partials/form_field.html" with field=form.first_name %}
{% include "partials/form_field.html" with field=form.last_name %}
<div class="form-actions">
<input type="hidden" name="next" value="{{ request.GET.next }}" />
<input type="submit" class="btn btn-large btn-primary" value="{% trans "Login with your email" %}" />
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock content %}
2 changes: 2 additions & 0 deletions lacommunaute/templates/registration/includes/login_link.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{% load i18n %}
<a href="{% url 'users:login' %}?next={{ request.path }}" rel="nofollow" class="btn btn-outline-primary btn-ico btn-block">{% trans "Login | Sign in" %}</a>
6 changes: 6 additions & 0 deletions lacommunaute/templates/registration/includes/logout_link.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{% if request.session.MAGIC_LINK %}
{% url 'users:logout' as logout_url %}
{% else %}
{% url 'openid_connect:logout' as logout_url %}
{% endif %}
<a class="dropdown-item text-danger" id="js-logout" href="{{ logout_url }}"">Déconnexion</a>
28 changes: 28 additions & 0 deletions lacommunaute/templates/registration/login_link_sent.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% extends "layouts/base.html" %}
{% load static %}
{% load i18n %}
{% load theme_inclusion %}
{% block title %}Connexion {{ block.super }}{% endblock %}
{% block meta_description %}
{% trans "Login | Sign in" %}
{% endblock meta_description %}
{% block content %}
<section class="s-title-01 mt-lg-5">
<div class="s-title-01__container container">
<div class="s-title-01__row row">
<div class="s-title-01__col col-lg-8 col-12">
<h1 class="s-title-01__title h1">{% trans "Login | Sign in" %}</h1>
</div>
</div>
</div>
</section>
<section class="s-section">
<div class="s-section__container container">
<div class="s-section__row row">
<div class="s-section__col col-12 col-lg-7">
Un lien de connexion vous a été envoyé à l'adresse {{ email }}. Veuillez vérifier votre boîte de réception et cliquer sur le lien pour vous connecter.
</div>
</div>
</div>
</section>
{% endblock content %}
49 changes: 49 additions & 0 deletions lacommunaute/templates/registration/login_with_magic_link.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{% extends "layouts/base.html" %}
{% load static %}
{% load i18n %}
{% load theme_inclusion %}
{% block title %}Connexion {{ block.super }}{% endblock %}
{% block meta_description %}
{% trans "Login | Sign in" %}
{% endblock meta_description %}
{% block content %}
<section class="s-title-01 mt-lg-5">
<div class="s-title-01__container container">
<div class="s-title-01__row row">
<div class="s-title-01__col col-lg-8 col-12">
<h1 class="s-title-01__title h1">{% trans "Login | Sign in" %}</h1>
</div>
</div>
</div>
</section>
<section class="s-section">
<div class="s-section__container container">
<div class="s-section__row row">
<div class="s-section__col col-12 col-lg-7">
<div class="card">
<div class="c-form">
<a href="{% url 'openid_connect:authorize' %}?next={{ next }}" rel="nofollow" class="btn btn-outline-primary btn-ico btn-block">Se connecter avec Pro Connect</a>
<hr class="my-5" data-it-text="ou">
<form method="post" action="." enctype="multipart/form-data" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
<div class="alert alert-danger">
<i class="icon-exclamation-sign"></i>
{{ error }}
</div>
{% endfor %}
{% endif %}
{% include "partials/form_field.html" with field=form.email %}
<div class="form-actions">
<input type="hidden" name="next" value="{{ request.GET.next }}" />
<input type="submit" class="btn btn-large btn-primary" value="{% trans "Login with your email" %}" />
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock content %}
4 changes: 1 addition & 3 deletions lacommunaute/templates/surveys/dsp_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,7 @@ <h1 class="s-title-01__title h1">Diagnostic Parcours IAE</h1>
<div class="c-box mb-5">
<div class="row">
<div class="col-lg col-12 mb-3">Je me connecte pour accéder à l'aide au diagnostic pour le parcours IAE</div>
<div class="col-lg-auto col-12">
<a href="{% url 'openid_connect:authorize' %}?next={% url 'surveys:dsp_create' %}" rel="nofollow" class="btn btn-outline-primary btn-ico btn-block">Me connecter</a>
</div>
<div class="col-lg-auto col-12">{% include "registration/includes/login_link.html" %}</div>
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions lacommunaute/users/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
class IdentityProvider(models.TextChoices):
INCLUSION_CONNECT = "IC", "Inclusion Connect"
PRO_CONNECT = "PC", "Pro Connect"
MAGIC_LINK = "ML", "Magic Link"
14 changes: 14 additions & 0 deletions lacommunaute/users/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from django import forms


class LoginForm(forms.Form):
email = forms.EmailField(
label="",
widget=forms.EmailInput(attrs={"placeholder": "Votre adresse email"}),
)


class CreateUserForm(forms.Form):
first_name = forms.CharField(label="Votre prénom", max_length=150)
last_name = forms.CharField(label="Votre nom", max_length=150)
email = forms.EmailField(label="Votre adresse email")
19 changes: 19 additions & 0 deletions lacommunaute/users/migrations/0003_alter_user_identity_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 5.0.9 on 2024-11-12 10:54

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0002_user_identity_provider"),
]

operations = [
migrations.AlterField(
model_name="user",
name="identity_provider",
field=models.CharField(
choices=[("IC", "Inclusion Connect"), ("PC", "Pro Connect"), ("ML", "Magic Link")], max_length=2
),
),
]
Empty file.
Loading

0 comments on commit 64e04a3

Please sign in to comment.