From 30fa8b78405c56daa54d22796c037225401c7160 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sun, 20 Sep 2020 21:24:52 +0200 Subject: [PATCH] Validate emails --- .../0003_tfjmuser_email_confirmed.py | 18 ++++ apps/member/models.py | 33 ++++++- apps/member/tokens.py | 26 ++++++ apps/member/urls.py | 9 +- apps/member/views.py | 90 ++++++++++++++++++- locale/fr/LC_MESSAGES/django.po | 21 ----- .../email_validation_complete.html | 26 ++++-- .../email_validation_email_sent.html | 19 +++- .../mails/email_validation_email.html | 35 ++++++-- .../mails/email_validation_email.txt | 13 +++ 10 files changed, 244 insertions(+), 46 deletions(-) create mode 100644 apps/member/migrations/0003_tfjmuser_email_confirmed.py create mode 100644 apps/member/tokens.py create mode 100644 templates/registration/mails/email_validation_email.txt diff --git a/apps/member/migrations/0003_tfjmuser_email_confirmed.py b/apps/member/migrations/0003_tfjmuser_email_confirmed.py new file mode 100644 index 0000000..c4bb95b --- /dev/null +++ b/apps/member/migrations/0003_tfjmuser_email_confirmed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-09-19 20:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('member', '0002_auto_20200919_2015'), + ] + + operations = [ + migrations.AddField( + model_name='tfjmuser', + name='email_confirmed', + field=models.BooleanField(default=False, verbose_name='email confirmed'), + ), + ] diff --git a/apps/member/models.py b/apps/member/models.py index 82da7dd..5ec4ffc 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -2,12 +2,17 @@ import os from datetime import date from django.contrib.auth.models import AbstractUser +from django.contrib.sites.models import Site from django.db import models +from django.template import loader +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ from polymorphic.models import PolymorphicModel - from tournament.models import Team, Tournament +from .tokens import email_validation_token + class TFJMUser(AbstractUser): """ @@ -144,6 +149,11 @@ class TFJMUser(AbstractUser): verbose_name=_("year"), ) + email_confirmed = models.BooleanField( + verbose_name=_("email confirmed"), + default=False, + ) + @property def participates(self): """ @@ -179,6 +189,27 @@ class TFJMUser(AbstractUser): def __str__(self): return self.first_name + " " + self.last_name + def send_email_validation_link(self): + subject = "[TFJM²] " + str(_("Activate your Note Kfet account")) + token = email_validation_token.make_token(self) + uid = urlsafe_base64_encode(force_bytes(self.pk)) + site = Site.objects.first() + message = loader.render_to_string('registration/mails/email_validation_email.txt', + { + 'user': self, + 'domain': site.domain, + 'token': token, + 'uid': uid, + }) + html = loader.render_to_string('registration/mails/email_validation_email.html', + { + 'user': self, + 'domain': site.domain, + 'token': token, + 'uid': uid, + }) + self.email_user(subject, message, html_message=html) + class Document(PolymorphicModel): """ diff --git a/apps/member/tokens.py b/apps/member/tokens.py new file mode 100644 index 0000000..d089892 --- /dev/null +++ b/apps/member/tokens.py @@ -0,0 +1,26 @@ +from django.contrib.auth.tokens import PasswordResetTokenGenerator + + +class AccountActivationTokenGenerator(PasswordResetTokenGenerator): + """ + Create a unique token generator to confirm email addresses. + """ + def _make_hash_value(self, user, timestamp): + """ + Hash the user's primary key and some user state that's sure to change + after an account validation to produce a token that invalidated when + it's used: + 1. The user.profile.email_confirmed field will change upon an account + validation. + 2. The last_login field will usually be updated very shortly after + an account validation. + Failing those things, settings.PASSWORD_RESET_TIMEOUT_DAYS eventually + invalidates the token. + """ + # Truncate microseconds so that tokens are consistent even if the + # database doesn't support microseconds. + login_timestamp = '' if user.last_login is None else user.last_login.replace(microsecond=0, tzinfo=None) + return str(user.pk) + str(user.email) + str(user.email_confirmed) + str(login_timestamp) + str(timestamp) + + +email_validation_token = AccountActivationTokenGenerator() diff --git a/apps/member/urls.py b/apps/member/urls.py index 073f057..ffe31fa 100644 --- a/apps/member/urls.py +++ b/apps/member/urls.py @@ -1,12 +1,17 @@ from django.urls import path -from .views import CreateUserView, MyAccountView, UserDetailView, AddTeamView, JoinTeamView, MyTeamView,\ - ProfileListView, OrphanedProfileListView, OrganizersListView, ResetAdminView +from .views import CreateUserView, MyAccountView, UserDetailView, AddTeamView, JoinTeamView, MyTeamView, \ + ProfileListView, OrphanedProfileListView, OrganizersListView, ResetAdminView, UserValidationEmailSentView, \ + UserResendValidationEmailView, UserValidateView app_name = "member" urlpatterns = [ path('signup/', CreateUserView.as_view(), name="signup"), + path('validate_email/sent/', UserValidationEmailSentView.as_view(), name='email_validation_sent'), + path('validate_email/resend//', UserResendValidationEmailView.as_view(), + name='email_validation_resend'), + path('validate_email///', UserValidateView.as_view(), name='email_validation'), path("my-account/", MyAccountView.as_view(), name="my_account"), path("information//", UserDetailView.as_view(), name="information"), path("add-team/", AddTeamView.as_view(), name="add_team"), diff --git a/apps/member/views.py b/apps/member/views.py index cebde40..aef0c61 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -1,18 +1,20 @@ import random +from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin, AccessMixin from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError from django.db.models import Q from django.http import FileResponse, Http404 -from django.shortcuts import redirect +from django.shortcuts import redirect, resolve_url from django.urls import reverse_lazy from django.utils import timezone from django.utils.decorators import method_decorator +from django.utils.http import urlsafe_base64_decode from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.decorators.debug import sensitive_post_parameters -from django.views.generic import CreateView, UpdateView, DetailView, FormView +from django.views.generic import CreateView, UpdateView, DetailView, FormView, TemplateView from django_tables2 import SingleTableView from tournament.forms import TeamForm, JoinTeam from tournament.models import Team, Tournament, Pool @@ -21,6 +23,7 @@ from tournament.views import AdminMixin, TeamMixin, OrgaMixin from .forms import SignUpForm, TFJMUserForm, AdminUserForm, CoachUserForm from .models import TFJMUser, Document, Solution, MotivationLetter, Synthesis from .tables import UserTable +from .tokens import email_validation_token class CreateUserView(CreateView): @@ -36,8 +39,87 @@ class CreateUserView(CreateView): def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) + def form_valid(self, form): + form.instance.send_email_validation_link() + return super().form_valid(form) + def get_success_url(self): - return reverse_lazy('index') + return reverse_lazy('member:email_validation_sent') + + +class UserValidateView(TemplateView): + """ + A view to validate the email address. + """ + title = _("Email validation") + template_name = 'registration/email_validation_complete.html' + extra_context = {"title": _("Validate email")} + + def get(self, *args, **kwargs): + """ + With a given token and user id (in params), validate the email address. + """ + assert 'uidb64' in kwargs and 'token' in kwargs + + self.validlink = False + user = self.get_user(kwargs['uidb64']) + token = kwargs['token'] + + # Validate the token + if user is not None and email_validation_token.check_token(user, token): + self.validlink = True + user.email_confirmed = True + user.save() + return self.render_to_response(self.get_context_data(), status=200 if self.validlink else 400) + + def get_user(self, uidb64): + """ + Get user from the base64-encoded string. + """ + try: + # urlsafe_base64_decode() decodes to bytestring + uid = urlsafe_base64_decode(uidb64).decode() + user = TFJMUser.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, TFJMUser.DoesNotExist, ValidationError): + user = None + return user + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['user_object'] = self.get_user(self.kwargs["uidb64"]) + context['login_url'] = resolve_url(settings.LOGIN_URL) + if self.validlink: + context['validlink'] = True + else: + context.update({ + 'title': _('Email validation unsuccessful'), + 'validlink': False, + }) + return context + + +class UserValidationEmailSentView(TemplateView): + """ + Display the information that the validation link has been sent. + """ + template_name = 'registration/email_validation_email_sent.html' + extra_context = {"title": _('Email validation email sent')} + + +class UserResendValidationEmailView(LoginRequiredMixin, DetailView): + """ + Rensend the email validation link. + """ + model = TFJMUser + extra_context = {"title": _("Resend email validation link")} + + def get(self, request, *args, **kwargs): + user = self.get_object() + + user.profile.send_email_validation_link() + + url = 'member:user_detail' if user.profile.registration_valid else 'member:future_user_detail' + return redirect(url, user.id) class MyAccountView(LoginRequiredMixin, UpdateView): diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 07a9aaa..7c2977f 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -917,11 +917,6 @@ msgstr "Votre adresse e-mail a bien été validée." msgid "You can now log in." msgstr "Vous pouvez désormais vous connecter" -#: templates/registration/email_validation_complete.html:10 -msgid "" -"You must pay now your membership in the Kfet to complete your registration." -msgstr "" - #: templates/registration/email_validation_complete.html:13 msgid "" "The link was invalid. The token may have expired. Please send us an email to " @@ -956,32 +951,16 @@ msgstr "Mot de passe oublié ?" msgid "Hi" msgstr "Bonjour" -#: templates/registration/mails/email_validation_email.html:5 -msgid "" -"You recently registered on the Note Kfet. Please click on the link below to " -"confirm your registration." -msgstr "" - #: templates/registration/mails/email_validation_email.html:9 msgid "" "This link is only valid for a couple of days, after that you will need to " "contact us to validate your email." msgstr "" -#: templates/registration/mails/email_validation_email.html:11 -msgid "" -"After that, you'll have to wait that someone validates your account before " -"you can log in. You will need to pay your membership in the Kfet." -msgstr "" - #: templates/registration/mails/email_validation_email.html:13 msgid "Thanks" msgstr "Merci" -#: templates/registration/mails/email_validation_email.html:15 -msgid "The Note Kfet team." -msgstr "" - #: templates/registration/password_change_done.html:8 msgid "Your password was changed." msgstr "Votre mot de passe a été changé" diff --git a/templates/registration/email_validation_complete.html b/templates/registration/email_validation_complete.html index b54432f..f58a7e5 100644 --- a/templates/registration/email_validation_complete.html +++ b/templates/registration/email_validation_complete.html @@ -1,15 +1,27 @@ {% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} {% load i18n %} {% block content %} - {% if validlink %} - {% trans "Your email have successfully been validated." %} - {% if user_object.profile.registration_valid %} +
+

+ {{ title }} +

+
+ {% if validlink %} +

+ {% trans "Your email have successfully been validated." %} +

+

{% blocktrans %}You can now log in.{% endblocktrans %} +

{% else %} - {% trans "You must pay now your membership in the Kfet to complete your registration." %} +

+ {% trans "The link was invalid. The token may have expired. Please send us an email to activate your account." %} +

{% endif %} - {% else %} - {% trans "The link was invalid. The token may have expired. Please send us an email to activate your account." %} - {% endif %} +
+
{% endblock %} diff --git a/templates/registration/email_validation_email_sent.html b/templates/registration/email_validation_email_sent.html index bd4cf8d..adc0c02 100644 --- a/templates/registration/email_validation_email_sent.html +++ b/templates/registration/email_validation_email_sent.html @@ -1,7 +1,18 @@ {% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n %} {% block content %} -

Account Activation

- -An email has been sent. Please click on the link to activate your account. -{% endblock %} \ No newline at end of file +
+

+ {% trans "Account activation" %} +

+
+

+ {% trans "An email has been sent. Please click on the link to activate your account." %} +

+
+
+{% endblock %} diff --git a/templates/registration/mails/email_validation_email.html b/templates/registration/mails/email_validation_email.html index 577c122..9d002d6 100644 --- a/templates/registration/mails/email_validation_email.html +++ b/templates/registration/mails/email_validation_email.html @@ -1,15 +1,36 @@ {% load i18n %} -{% trans "Hi" %} {{ user.username }}, + + + + + + + -{% trans "You recently registered on the Note Kfet. Please click on the link below to confirm your registration." %} +

+ {% trans "Hi" %} {{ user.username }}, +

-https://{{ domain }}{% url 'registration:email_validation' uidb64=uid token=token %} +

+ {% trans "You recently registered on the TFJM² platform. Please click on the link below to confirm your registration." %} +

-{% trans "This link is only valid for a couple of days, after that you will need to contact us to validate your email." %} +

+ + https://{{ domain }}{% url 'member:email_validation' uidb64=uid token=token %} + +

-{% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet." %} +

+ {% trans "This link is only valid for a couple of days, after that you will need to contact us to validate your email." %} +

-{% trans "Thanks" %}, +

+ {% trans "Thanks" %}, +

-{% trans "The Note Kfet team." %} +-- +

+ {% trans "The CNO." %}
+

diff --git a/templates/registration/mails/email_validation_email.txt b/templates/registration/mails/email_validation_email.txt new file mode 100644 index 0000000..2022294 --- /dev/null +++ b/templates/registration/mails/email_validation_email.txt @@ -0,0 +1,13 @@ +{% load i18n %} + +{% trans "Hi" %} {{ user.username }}, + +{% trans "You recently registered on the TFJM² platform. Please click on the link below to confirm your registration." %} + +https://{{ domain }}{% url 'member:email_validation' uidb64=uid token=token %} + +{% trans "This link is only valid for a couple of days, after that you will need to contact us to validate your email." %} + +{% trans "Thanks" %}, + +{% trans "The CNO." %}