From 26281af673cbdcb3c7aaad15d9015aa9f6f1e27a Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sun, 5 Apr 2020 04:26:42 +0200 Subject: [PATCH] Send an e-mail verification to a new registered user --- apps/member/forms.py | 9 +- apps/member/models.py | 6 + apps/member/tokens.py | 30 +++++ apps/member/urls.py | 3 + apps/member/views.py | 111 ++++++++++++++++-- .../account_activation_complete.html | 16 +++ .../account_activation_email.html | 11 ++ .../account_activation_email_sent.html | 7 ++ 8 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 apps/member/tokens.py create mode 100644 templates/registration/account_activation_complete.html create mode 100644 templates/registration/account_activation_email.html create mode 100644 templates/registration/account_activation_email_sent.html diff --git a/apps/member/forms.py b/apps/member/forms.py index a37d143e..e6e73612 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -4,6 +4,7 @@ from django import forms from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput from permission.models import PermissionMask @@ -23,10 +24,14 @@ class SignUpForm(UserCreationForm): super().__init__(*args, **kwargs) self.fields['username'].widget.attrs.pop("autofocus", None) self.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"}) + self.fields['first_name'].required = True + self.fields['last_name'].required = True + self.fields['email'].required = True + self.fields['email'].help_text = _("This address must be valid.") class Meta: model = User - fields = ['first_name', 'last_name', 'username', 'email'] + fields = ('first_name', 'last_name', 'username', 'email', ) class ProfileForm(forms.ModelForm): @@ -37,7 +42,7 @@ class ProfileForm(forms.ModelForm): class Meta: model = Profile fields = '__all__' - exclude = ['user'] + exclude = ('user', 'email_confirmed', ) class ClubForm(forms.ModelForm): diff --git a/apps/member/models.py b/apps/member/models.py index 693854af..3cf92ff1 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -45,6 +45,12 @@ class Profile(models.Model): ) paid = models.BooleanField( verbose_name=_("paid"), + help_text=_("Tells if the user receive a salary."), + default=False, + ) + + email_confirmed = models.BooleanField( + verbose_name=_("email confirmed"), default=False, ) diff --git a/apps/member/tokens.py b/apps/member/tokens.py new file mode 100644 index 00000000..f45c00de --- /dev/null +++ b/apps/member/tokens.py @@ -0,0 +1,30 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later +# Copied from https://gitlab.crans.org/bombar/codeflix/-/blob/master/codeflix/codeflix/tokens.py + +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.profile.email_confirmed) + str(login_timestamp) + str(timestamp) + + +account_activation_token = AccountActivationTokenGenerator() diff --git a/apps/member/urls.py b/apps/member/urls.py index 1214f024..9b6ccbd5 100644 --- a/apps/member/urls.py +++ b/apps/member/urls.py @@ -8,6 +8,9 @@ from . import views app_name = 'member' urlpatterns = [ path('signup/', views.UserCreateView.as_view(), name="signup"), + path('accounts/activate/sent', views.UserActivationEmailSentView.as_view(), name='account_activation_sent'), + path('accounts/activate//', views.UserActivateView.as_view(), name='account_activation'), + path('club/', views.ClubListView.as_view(), name="club_list"), path('club/create/', views.ClubCreateView.as_view(), name="club_create"), diff --git a/apps/member/views.py b/apps/member/views.py index f695002f..f1df5a47 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -9,12 +9,18 @@ from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.contrib.auth.views import LoginView +from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ValidationError from django.db.models import Q from django.forms import HiddenInput -from django.shortcuts import redirect +from django.shortcuts import redirect, resolve_url +from django.template import loader from django.urls import reverse_lazy +from django.utils.decorators import method_decorator +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_protect from django.views.generic import CreateView, DetailView, UpdateView, TemplateView from django.views.generic.base import View from django.views.generic.edit import FormMixin @@ -30,6 +36,7 @@ from permission.views import ProtectQuerysetMixin from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, CustomAuthenticationForm from .models import Club, Membership from .tables import ClubTable, UserTable, MembershipTable +from .tokens import account_activation_token class CustomLoginView(LoginView): @@ -57,15 +64,90 @@ class UserCreateView(CreateView): return context def form_valid(self, form): + """ + If the form is valid, then the user is created with is_active set to False + so that the user cannot log in until the email has been validated. + """ profile_form = ProfileForm(self.request.POST) - if form.is_valid() and profile_form.is_valid(): - user = form.save(commit=False) - user.profile = profile_form.save(commit=False) - user.save() - user.profile.save() + if not profile_form.is_valid(): + return self.form_invalid(form) + + user = form.save(commit=False) + user.is_active = False + user.profile = profile_form.save(commit=False) + user.save() + user.profile.save() + site = get_current_site(self.request) + subject = "Activate your {} account".format(site.name) + message = loader.render_to_string('registration/account_activation_email.html', + { + 'user': user, + 'domain': site.domain, + 'site_name': "La Note Kfet", + 'protocol': 'https', + 'token': account_activation_token.make_token(user), + 'uid': urlsafe_base64_encode(force_bytes(user.pk)).decode('UTF-8'), + }) + user.email_user(subject, message) return super().form_valid(form) +class UserActivateView(TemplateView): + title = _("Account Activation") + template_name = 'registration/account_activation_complete.html' + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + """ + The dispatch method looks at the request to determine whether it is a GET, POST, etc, + and relays the request to a matching method if one is defined, or raises HttpResponseNotAllowed + if not. We chose to check the token in the dispatch method to mimic PasswordReset from + django.contrib.auth + """ + assert 'uidb64' in kwargs and 'token' in kwargs + + self.validlink = False + user = self.get_user(kwargs['uidb64']) + token = kwargs['token'] + + if user is not None and account_activation_token.check_token(user, token): + self.validlink = True + user.is_active = True + user.profile.email_confirmed = True + user.save() + return super().dispatch(*args, **kwargs) + else: + # Display the "Account Activation unsuccessful" page. + return self.render_to_response(self.get_context_data()) + + def get_user(self, uidb64): + print(uidb64) + 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['login_url'] = resolve_url(settings.LOGIN_URL) + if self.validlink: + context['validlink'] = True + else: + context.update({ + 'title': _('Account Activation unsuccessful'), + 'validlink': False, + }) + return context + + +class UserActivationEmailSentView(TemplateView): + template_name = 'registration/account_activation_email_sent.html' + title = _('Account activation email sent') + + class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): model = User fields = ['first_name', 'last_name', 'username', 'email'] @@ -75,14 +157,20 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + + form = context['form'] + form.fields['username'].widget.attrs.pop("autofocus", None) + form.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"}) + form.fields['first_name'].required = True + form.fields['last_name'].required = True + form.fields['email'].required = True + form.fields['email'].help_text = _("This address must be valid.") + context['profile_form'] = self.profile_form(instance=context['user_object'].profile) context['title'] = _("Update Profile") return context - def get_form(self, form_class=None): - form = super().get_form(form_class) - if 'username' not in form.data: - return form + def form_valid(self, form): new_username = form.data['username'] # Si l'utilisateur cherche à modifier son pseudo, le nouveau pseudo ne doit pas être proche d'un alias existant note = NoteUser.objects.filter( @@ -90,9 +178,8 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): if note.exists() and note.get().user != self.object: form.add_error('username', _("An alias with a similar name already exists.")) - return form + return super().form_invalid(form) - def form_valid(self, form): profile_form = ProfileForm( data=self.request.POST, instance=self.object.profile, diff --git a/templates/registration/account_activation_complete.html b/templates/registration/account_activation_complete.html new file mode 100644 index 00000000..185fbfb0 --- /dev/null +++ b/templates/registration/account_activation_complete.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +

{% trans Activation %}

+ +{% if validlink %} +{% blocktrans trimmed %} +Your account have successfully been activated. You can now log in. +{% endblocktrans %} +{% else %} +{% blocktrans trimmed %} +The link was invalid. The token may have expired. Please send us an email to activate your account. +{% endblocktrans %} +{% endif %} +{% endblock %} diff --git a/templates/registration/account_activation_email.html b/templates/registration/account_activation_email.html new file mode 100644 index 00000000..e8f2032d --- /dev/null +++ b/templates/registration/account_activation_email.html @@ -0,0 +1,11 @@ +Hi {{ user.username }}, + +Welcome to {{ site_name }}. Please click on the link below to confirm your registration. + +{{ protocol }}://{{ domain }}{% url 'member:account_activation' uidb64=uid token=token %} + +This link is only valid for a couple of days, after that you will need to contact us to validate your email. + +Thanks, + +{{ site_name }} team. diff --git a/templates/registration/account_activation_email_sent.html b/templates/registration/account_activation_email_sent.html new file mode 100644 index 00000000..bd4cf8d8 --- /dev/null +++ b/templates/registration/account_activation_email_sent.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% 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