From 26281af673cbdcb3c7aaad15d9015aa9f6f1e27a Mon Sep 17 00:00:00 2001
From: Yohann D'ANELLO <yohann.danello@gmail.com>
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/<uidb64>/<token>', 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 %}
+<h2>{% trans Activation %}</h2>
+
+{% if validlink %}
+{% blocktrans trimmed %}
+Your account have successfully been activated. You can now <a href="{{ login_url }}">log in</a>.
+{% 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 %}
+<h2>Account Activation</h2>
+
+An email has been sent. Please click on the link to activate your account.
+{% endblock %}
\ No newline at end of file
-- 
GitLab