From ae562039708b7839232383315f2d3019f5c3b7d8 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 22 Sep 2020 19:37:37 +0200 Subject: [PATCH] Confirm email addresses --- Dockerfile | 5 +- apps/registration/apps.py | 3 +- .../0003_registration_email_confirmed.py | 18 ++++ apps/registration/models.py | 36 ++++++++ apps/registration/signals.py | 15 +++- apps/registration/urls.py | 6 +- apps/registration/views.py | 84 ++++++++++++++++++- corres2math.cron | 8 +- corres2math/settings_dev.py | 3 +- corres2math/tokens.py | 26 ++++++ entrypoint.sh | 2 + locale/fr/LC_MESSAGES/django.po | 65 ++++++++------ .../mails/email_validation_email.html | 6 +- .../mails/email_validation_email.txt | 4 +- 14 files changed, 238 insertions(+), 43 deletions(-) create mode 100644 apps/registration/migrations/0003_registration_email_confirmed.py create mode 100644 corres2math/tokens.py diff --git a/Dockerfile b/Dockerfile index ba9dfc8..3f92665 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ FROM python:3-alpine ENV PYTHONUNBUFFERED 1 +ENV DJANGO_ALLOW_ASYNC_UNSAFE 1 # Install LaTeX requirements -RUN apk add --no-cache gettext texlive nginx gcc libc-dev libffi-dev postgresql-dev mariadb-connector-c-dev +RUN apk add --no-cache gettext texlive nginx gcc libc-dev libffi-dev postgresql-dev RUN apk add --no-cache bash @@ -20,7 +21,7 @@ RUN ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/ RUN ln -sf /code/nginx_corres2math.conf /etc/nginx/conf.d/corres2math.conf RUN rm /etc/nginx/conf.d/default.conf -RUN cp /code/corres2math.cron /etc/crontabs/corres2math +RUN crontab /code/corres2math.cron # With a bashrc, the shell is better RUN ln -s /code/.bashrc /root/.bashrc diff --git a/apps/registration/apps.py b/apps/registration/apps.py index 36bf8da..56b65e0 100644 --- a/apps/registration/apps.py +++ b/apps/registration/apps.py @@ -6,6 +6,7 @@ class RegistrationConfig(AppConfig): name = 'registration' def ready(self): - from registration.signals import set_username, create_admin_registration + from registration.signals import set_username, send_email_link, create_admin_registration pre_save.connect(set_username, "auth.User") + pre_save.connect(send_email_link, "auth.User") post_save.connect(create_admin_registration, "auth.User") diff --git a/apps/registration/migrations/0003_registration_email_confirmed.py b/apps/registration/migrations/0003_registration_email_confirmed.py new file mode 100644 index 0000000..a9cc7ae --- /dev/null +++ b/apps/registration/migrations/0003_registration_email_confirmed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.1 on 2020-09-22 16:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registration', '0002_auto_20200921_1948'), + ] + + operations = [ + migrations.AddField( + model_name='registration', + name='email_confirmed', + field=models.BooleanField(default=False, verbose_name='email confirmed'), + ), + ] diff --git a/apps/registration/models.py b/apps/registration/models.py index a8df9da..259788b 100644 --- a/apps/registration/models.py +++ b/apps/registration/models.py @@ -1,7 +1,13 @@ +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 corres2math.tokens import email_validation_token + class Registration(PolymorphicModel): user = models.OneToOneField( @@ -15,6 +21,36 @@ class Registration(PolymorphicModel): verbose_name=_("Grant Animath to contact me in the future about other actions"), ) + email_confirmed = models.BooleanField( + default=False, + verbose_name=_("email confirmed"), + ) + + def save(self, *args, **kwargs): + self.send_email_validation_link() + return super().save(*args, **kwargs) + + def send_email_validation_link(self): + subject = "[Corres2math] " + str(_("Activate your Correspondances account")) + token = email_validation_token.make_token(self.user) + uid = urlsafe_base64_encode(force_bytes(self.user.pk)) + site = Site.objects.first() + message = loader.render_to_string('registration/mails/email_validation_email.txt', + { + 'user': self.user, + 'domain': site.domain, + 'token': token, + 'uid': uid, + }) + html = loader.render_to_string('registration/mails/email_validation_email.html', + { + 'user': self.user, + 'domain': site.domain, + 'token': token, + 'uid': uid, + }) + self.user.email_user(subject, message, html_message=html) + @property def type(self): raise NotImplementedError diff --git a/apps/registration/signals.py b/apps/registration/signals.py index 65fd535..2f5a0d6 100644 --- a/apps/registration/signals.py +++ b/apps/registration/signals.py @@ -1,10 +1,23 @@ -from registration.models import AdminRegistration +from django.contrib.auth.models import User + +from registration.models import AdminRegistration, Registration def set_username(instance, **_): instance.username = instance.email +def send_email_link(instance, **_): + if instance.pk: + old_instance = User.objects.get(pk=instance.pk) + if old_instance.email != instance.email: + registration = Registration.objects.get(user=instance) + registration.email_confirmed = False + registration.save() + registration.user = instance + registration.send_email_validation_link() + + def create_admin_registration(instance, **_): if instance.is_superuser: AdminRegistration.objects.get_or_create(user=instance) diff --git a/apps/registration/urls.py b/apps/registration/urls.py index 514ea77..bf11ee6 100644 --- a/apps/registration/urls.py +++ b/apps/registration/urls.py @@ -1,9 +1,13 @@ from django.urls import path -from .views import SignupView +from .views import SignupView, UserValidationEmailSentView, UserResendValidationEmailView, UserValidateView app_name = "registration" urlpatterns = [ path("signup", SignupView.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'), ] diff --git a/apps/registration/views.py b/apps/registration/views.py index a0602e7..a58c98f 100644 --- a/apps/registration/views.py +++ b/apps/registration/views.py @@ -1,8 +1,15 @@ +from django.conf import settings +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User +from django.core.exceptions import ValidationError from django.db import transaction +from django.shortcuts import resolve_url, redirect from django.urls import reverse_lazy -from django.views.generic import CreateView +from django.utils.http import urlsafe_base64_decode +from django.utils.translation import gettext_lazy as _ +from django.views.generic import CreateView, TemplateView, DetailView +from corres2math.tokens import email_validation_token from .forms import SignupForm, StudentRegistrationForm, CoachRegistrationForm @@ -39,3 +46,78 @@ class SignupView(CreateView): def get_success_url(self): return reverse_lazy("index") + + +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.registration.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 = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.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 = User + extra_context = {"title": _("Resend email validation link")} + + def get(self, request, *args, **kwargs): + user = self.get_object() + + user.profile.send_email_validation_link() + + # TODO Change URL + return redirect('index') diff --git a/corres2math.cron b/corres2math.cron index 113ed0d..1e07e98 100644 --- a/corres2math.cron +++ b/corres2math.cron @@ -1,5 +1,5 @@ -# m h dom mon dow user command +# min hour day month weekday command # Envoyer les mails en attente - * * * * * root cd /code && python manage.py send_mail -c 1 - * * * * * root cd /code && python manage.py retry_deferred -c 1 - 00 0 * * * root cd /code && python manage.py purge_mail_log 7 -c 1 +* * * * * cd /code && python manage.py send_mail -c 1 +* * * * * cd /code && python manage.py retry_deferred -c 1 +0 0 * * * cd /code && python manage.py purge_mail_log 7 -c 1 diff --git a/corres2math/settings_dev.py b/corres2math/settings_dev.py index a52990e..bf6e856 100644 --- a/corres2math/settings_dev.py +++ b/corres2math/settings_dev.py @@ -1,5 +1,4 @@ # Database # https://docs.djangoproject.com/en/3.0/ref/settings/#databases -EMAIL_BACKEND = 'mailer.backend.DbBackend' -MAILER_EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' diff --git a/corres2math/tokens.py b/corres2math/tokens.py new file mode 100644 index 0000000..c194980 --- /dev/null +++ b/corres2math/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(login_timestamp) + str(timestamp) + + +email_validation_token = AccountActivationTokenGenerator() diff --git a/entrypoint.sh b/entrypoint.sh index 0029acc..8827c4d 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,5 +1,7 @@ #!/bin/sh +crond -l 0 + python manage.py compilemessages python manage.py migrate diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 1c9b256..c48f172 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Corres2math\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-09-22 18:21+0200\n" +"POT-Creation-Date: 2020-09-22 18:49+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Yohann D'ANELLO \n" "Language-Team: LANGUAGE \n" @@ -25,7 +25,7 @@ msgstr "API" msgid "Logs" msgstr "Logs" -#: apps/logs/models.py:22 apps/registration/models.py:10 +#: apps/logs/models.py:22 apps/registration/models.py:16 msgid "user" msgstr "utilisateur" @@ -115,7 +115,8 @@ msgid "" "Give the authorisation to publish the video on the main website to promote " "the action." msgstr "" -"Donner l'autorisation de publier la vidéo sur le site principal pour promouvoir les Correspondances." +"Donner l'autorisation de publier la vidéo sur le site principal pour " +"promouvoir les Correspondances." #: apps/participation/models.py:35 #, python-brace-format @@ -123,7 +124,7 @@ msgid "Team {name} ({trigram})" msgstr "Équipe {name} ({trigram})" #: apps/participation/models.py:38 apps/participation/models.py:49 -#: apps/registration/models.py:38 apps/registration/models.py:71 +#: apps/registration/models.py:70 apps/registration/models.py:103 msgid "team" msgstr "équipe" @@ -202,85 +203,93 @@ msgstr "rôle" msgid "participant" msgstr "participant" -#: apps/registration/forms.py:14 apps/registration/models.py:80 +#: apps/registration/forms.py:14 apps/registration/models.py:112 msgid "coach" msgstr "encadrant" -#: apps/registration/models.py:15 +#: apps/registration/models.py:21 msgid "Grant Animath to contact me in the future about other actions" msgstr "" "Autoriser Animath à me recontacter à l'avenir à propos d'autres actions" -#: apps/registration/models.py:23 +#: apps/registration/models.py:26 +msgid "email confirmed" +msgstr "email confirmé" + +#: apps/registration/models.py:30 +msgid "Activate your Correspondances account" +msgstr "Activez votre compte des Correspondances" + +#: apps/registration/models.py:55 #, python-brace-format msgid "registration of {first_name} {last_name}" msgstr "inscription de {first_name} {last_name}" -#: apps/registration/models.py:27 +#: apps/registration/models.py:59 msgid "registration" msgstr "inscription" -#: apps/registration/models.py:28 +#: apps/registration/models.py:60 msgid "registrations" msgstr "inscriptions" -#: apps/registration/models.py:43 +#: apps/registration/models.py:75 msgid "12th grade" msgstr "Terminale" -#: apps/registration/models.py:44 +#: apps/registration/models.py:76 msgid "11th grade" msgstr "Première" -#: apps/registration/models.py:45 +#: apps/registration/models.py:77 msgid "10th grade or lower" msgstr "Seconde ou inférieur" -#: apps/registration/models.py:47 +#: apps/registration/models.py:79 msgid "student class" msgstr "classe" -#: apps/registration/models.py:52 +#: apps/registration/models.py:84 msgid "school" msgstr "école" -#: apps/registration/models.py:57 +#: apps/registration/models.py:89 msgid "student" msgstr "étudiant" -#: apps/registration/models.py:60 +#: apps/registration/models.py:92 msgid "student registration" msgstr "inscription d'élève" -#: apps/registration/models.py:61 +#: apps/registration/models.py:93 msgid "student registrations" msgstr "inscriptions d'élève" -#: apps/registration/models.py:75 +#: apps/registration/models.py:107 msgid "professional activity" msgstr "activité professionnelle" -#: apps/registration/models.py:83 +#: apps/registration/models.py:115 msgid "coach registration" msgstr "inscription d'encadrant" -#: apps/registration/models.py:84 +#: apps/registration/models.py:116 msgid "coach registrations" msgstr "inscriptions d'encadrants" -#: apps/registration/models.py:89 +#: apps/registration/models.py:121 msgid "role of the administrator" msgstr "rôle de l'administrateur" -#: apps/registration/models.py:94 +#: apps/registration/models.py:126 msgid "admin" msgstr "admin" -#: apps/registration/models.py:97 +#: apps/registration/models.py:129 msgid "admin registration" msgstr "inscription d'administrateur" -#: apps/registration/models.py:98 +#: apps/registration/models.py:130 msgid "admin registrations" msgstr "inscriptions d'administrateur" @@ -443,6 +452,8 @@ msgid "" "You recently registered on the Correspondances platform. Please click on the " "link below to confirm your registration." msgstr "" +"Vous vous êtes inscrits sur la plateforme des Correspondances. Merci de " +"cliquer sur le lien ci-dessous pour confirmer votre inscription." #: templates/registration/mails/email_validation_email.html:26 #: templates/registration/mails/email_validation_email.txt:9 @@ -450,6 +461,8 @@ msgid "" "This link is only valid for a couple of days, after that you will need to " "contact us to validate your email." msgstr "" +"Ce lien n'est valide que pendant quelques jours, après cela vous devrez nous " +"contacter pour valider votre email." #: templates/registration/mails/email_validation_email.html:30 #: templates/registration/mails/email_validation_email.txt:11 @@ -458,8 +471,8 @@ msgstr "Merci" #: templates/registration/mails/email_validation_email.html:35 #: templates/registration/mails/email_validation_email.txt:13 -msgid "The CNO." -msgstr "" +msgid "The Correspondances team." +msgstr "L'équipe des Correspondances" #: templates/registration/password_change_done.html:8 msgid "Your password was changed." diff --git a/templates/registration/mails/email_validation_email.html b/templates/registration/mails/email_validation_email.html index cd0bfed..416c496 100644 --- a/templates/registration/mails/email_validation_email.html +++ b/templates/registration/mails/email_validation_email.html @@ -17,8 +17,8 @@

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

@@ -32,5 +32,5 @@ --

- {% trans "The CNO." %}
+ {% trans "The Correspondances team." %}

diff --git a/templates/registration/mails/email_validation_email.txt b/templates/registration/mails/email_validation_email.txt index b12d3fa..e9219c4 100644 --- a/templates/registration/mails/email_validation_email.txt +++ b/templates/registration/mails/email_validation_email.txt @@ -4,10 +4,10 @@ {% trans "You recently registered on the Correspondances platform. Please click on the link below to confirm your registration." %} -https://{{ domain }}{% url 'member:email_validation' uidb64=uid token=token %} +https://{{ domain }}{% url 'registration: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." %} +{% trans "The Correspondances team." %}