diff --git a/debexpo/accounts/forms.py b/debexpo/accounts/forms.py
index cc6569bdcefc2e09a5dbc30605652f413399cfc7..8977d12b392fc160dfc53de9fff78df9e7197d07 100644
--- a/debexpo/accounts/forms.py
+++ b/debexpo/accounts/forms.py
@@ -128,6 +128,19 @@ class PasswordResetForm(DjangoPasswordResetForm):
email.send(_('You requested a password reset'), [to_email], **context)
+class EmailChangeForm(forms.ModelForm):
+ read_only = True
+
+ def __init__(self, user, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.user = user
+ self.fields['email'].disabled = True
+
+ class Meta:
+ model = User
+ fields = ('email',)
+
+
class ProfileForm(forms.ModelForm):
status = forms.ChoiceField(choices=(
UserStatus.contributor.tuple,
diff --git a/debexpo/accounts/locale/fr/LC_MESSAGES/django.po b/debexpo/accounts/locale/fr/LC_MESSAGES/django.po
index 56c83a03d3cb3c096ffcd1ca8b4d1f9ba0d0d163..32cc7add295c47c7fcee05e66c2a394150107318 100644
--- a/debexpo/accounts/locale/fr/LC_MESSAGES/django.po
+++ b/debexpo/accounts/locale/fr/LC_MESSAGES/django.po
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-09-19 14:03+0000\n"
-"PO-Revision-Date: 2020-09-19 16:04+0200\n"
+"POT-Creation-Date: 2021-08-31 16:30+0000\n"
+"PO-Revision-Date: 2021-08-31 18:36+0200\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: fr\n"
@@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
-"X-Generator: Poedit 2.4.1\n"
+"X-Generator: Poedit 3.0\n"
#: accounts/forms.py:48
msgid "Full name"
@@ -75,31 +75,31 @@ msgstr "Mainteneur Debian (DM)"
msgid "Debian Developer (DD)"
msgstr "Développeur Debian (DD)"
-#: accounts/models.py:101
+#: accounts/models.py:102
msgid "email address"
msgstr "adresse de courriel"
-#: accounts/models.py:102
+#: accounts/models.py:103
msgid "full name"
msgstr "nom complet"
-#: accounts/models.py:126
+#: accounts/models.py:127
msgid "Country"
msgstr "Pays"
-#: accounts/models.py:129
+#: accounts/models.py:130
msgid "IRC Nickname"
msgstr "Pseudo IRC"
-#: accounts/models.py:130
+#: accounts/models.py:131
msgid "Jabber address"
msgstr "Adresse Jabber"
-#: accounts/models.py:134
+#: accounts/models.py:135
msgid "Status"
msgstr "Statut"
-#: accounts/templates/activate.html:3
+#: accounts/templates/activate.html:3 accounts/templates/change-email.html:3
msgid "Check your email"
msgstr "Vérifiez votre messagerie"
@@ -135,6 +135,75 @@ msgstr ""
"La clef de vérification que vous avez entré n'est pas reconnue. Merci de "
"réessayer."
+#: accounts/templates/change-email-complete.html:3
+msgid "Email changed successfully"
+msgstr "Courriel changé avec succès"
+
+#: accounts/templates/change-email-complete.html:5
+msgid "Your email has been successfully updated"
+msgstr "Votre courriel a été mis à jour avec succès"
+
+#: accounts/templates/change-email-complete.html:6
+msgid "Go back to"
+msgstr "Revenir à"
+
+#: accounts/templates/change-email-complete.html:7
+msgid "my account"
+msgstr "mon compte"
+
+#: accounts/templates/change-email-confirm.html:3 accounts/views.py:267
+msgid "Change your email"
+msgstr "Changer votre courriel"
+
+#: accounts/templates/change-email-confirm.html:13
+#: accounts/templates/password-reset-confirm.html:13
+#: accounts/templates/password-reset-form.html:27
+#: accounts/templates/profile.html:14 accounts/templates/profile.html:40
+#: accounts/templates/profile.html:58 accounts/templates/profile.html:72
+#: accounts/templates/profile.html:87 accounts/templates/register.html:14
+msgid "Submit"
+msgstr "Continuer"
+
+#: accounts/templates/change-email-confirm.html:17
+#: accounts/templates/password-reset-confirm.html:17
+msgid "Invalid reset link"
+msgstr "Lien de réinitialisation invalide"
+
+#: accounts/templates/change-email.html:6
+msgid ""
+"An email has been sent to your new email address. Check it for instructions "
+"on how to validate it."
+msgstr ""
+"Un courriel a été envoyé à votre nouvelle adresse. Consultez-le pour les "
+"instructions sur comment la confirmer."
+
+#: accounts/templates/email-change.html:1
+msgid ""
+"Hello,\n"
+"\n"
+"Confirm your new email by visiting the following address\n"
+"in your web-browser:"
+msgstr ""
+"Bonjour,\n"
+"\n"
+"Confirmez votre courriel en consultant l'adresse suivante\n"
+"dans votre navigateur :"
+
+#: accounts/templates/email-change.html:8
+#, python-format
+msgid ""
+"If you didn't change your email on your account on %(site_name)s,\n"
+"you can safely ignore this email."
+msgstr ""
+"Si vous n'avez pas changé votre courriel sur %(site_name)s,\n"
+"vous pouvez ignorer ce courriel."
+
+#: accounts/templates/email-change.html:11
+#: accounts/templates/email-password-creation.html:11
+#: accounts/templates/email-password-reset.html:8
+msgid "Thanks,"
+msgstr "Merci,"
+
#: accounts/templates/email-password-creation.html:1
msgid ""
"Hello,\n"
@@ -156,11 +225,6 @@ msgstr ""
"Si vous n'avez pas créé de compte sur %(site_name)s,\n"
"vous pouvez ignorer ce courriel."
-#: accounts/templates/email-password-creation.html:11
-#: accounts/templates/email-password-reset.html:8
-msgid "Thanks,"
-msgstr "Merci,"
-
#: accounts/templates/email-password-reset.html:1
msgid ""
"Hello,\n"
@@ -237,18 +301,6 @@ msgstr "connecter"
msgid "Reset your password"
msgstr "Réinitialisez votre mot de passe"
-#: accounts/templates/password-reset-confirm.html:13
-#: accounts/templates/password-reset-form.html:27
-#: accounts/templates/profile.html:14 accounts/templates/profile.html:40
-#: accounts/templates/profile.html:58 accounts/templates/profile.html:72
-#: accounts/templates/profile.html:87 accounts/templates/register.html:14
-msgid "Submit"
-msgstr "Continuer"
-
-#: accounts/templates/password-reset-confirm.html:17
-msgid "Invalid reset link"
-msgstr "Lien de réinitialisation invalide"
-
#: accounts/templates/password-reset-done.html:3
msgid "Password recovery in progress"
msgstr "Récupération de mot de passe en cours"
@@ -335,7 +387,7 @@ msgstr "Inscrivez un compte"
msgid "Account details"
msgstr "Détail du compte"
-#: accounts/views.py:70
+#: accounts/views.py:88
msgid "Next step: Confirm your email address"
msgstr "Prochaine étape : Confirmer votre courriel"
diff --git a/debexpo/accounts/templates/change-email-complete.html b/debexpo/accounts/templates/change-email-complete.html
new file mode 100644
index 0000000000000000000000000000000000000000..b6650a25f786b6ecd324a658a9349ebbf9a09832
--- /dev/null
+++ b/debexpo/accounts/templates/change-email-complete.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}{% load i18n %}
+
+{% block content %}
{% trans 'Email changed successfully' %}
+
+{% trans 'Your email has been successfully updated' %}.
+{% trans 'Go back to' %}
+{% trans 'my account' %} .
+{% endblock %}
diff --git a/debexpo/accounts/templates/change-email-confirm.html b/debexpo/accounts/templates/change-email-confirm.html
new file mode 100644
index 0000000000000000000000000000000000000000..672b55d764bbd5d7259e48b3e89a2ed121f8fdfa
--- /dev/null
+++ b/debexpo/accounts/templates/change-email-confirm.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}{% load i18n %}
+
+{% block content %}{% trans 'Change your email' %}
+
+{% if validlink %}
+
+
+
+{% else %}
+{% trans 'Invalid reset link' %}
+{% endif %}{% endblock %}
diff --git a/debexpo/accounts/templates/change-email.html b/debexpo/accounts/templates/change-email.html
new file mode 100644
index 0000000000000000000000000000000000000000..e086abe7db8695152bd66ca89ef188952f93f045
--- /dev/null
+++ b/debexpo/accounts/templates/change-email.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}{% load i18n %}
+
+{% block content %}{% trans 'Check your email' %}
+
+
+ {% blocktrans trimmed %}
+ An email has been sent to your new email address. Check it for
+ instructions on how to validate it.
+ {% endblocktrans %}
+
+{% endblock %}
diff --git a/debexpo/accounts/templates/email-change.html b/debexpo/accounts/templates/email-change.html
new file mode 100644
index 0000000000000000000000000000000000000000..c67673945d62b3a0e29c07133966e13485f22d65
--- /dev/null
+++ b/debexpo/accounts/templates/email-change.html
@@ -0,0 +1,12 @@
+{% extends "email-base.html" %}{% load i18n %}{% block content %}{% blocktrans %}Hello,
+
+Confirm your new email by visiting the following address
+in your web-browser:{% endblocktrans %}
+
+{{ args.activate_url }}
+
+{% blocktrans with site_name=args.settings.SITE_NAME %}If you didn't change your email on your account on {{ site_name }},
+you can safely ignore this email.{% endblocktrans %}
+
+{% trans 'Thanks,' %}
+{% endblock %}
diff --git a/debexpo/accounts/views.py b/debexpo/accounts/views.py
index a763f93deac25d9eb61a2c11e28c0f4a8b4131ca..5936fde129caf26bf8cb44106bab370bf31b1cd2 100644
--- a/debexpo/accounts/views.py
+++ b/debexpo/accounts/views.py
@@ -35,23 +35,32 @@ from django.conf import settings
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
+from django.contrib.auth.views import PasswordResetConfirmView
from django.contrib.auth.tokens import default_token_generator
-from django.urls import reverse
+from django.http import HttpResponseRedirect
+from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.shortcuts import render
+from django.utils.decorators import method_decorator
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
+from django.views.decorators.cache import never_cache
+from django.views.decorators.debug import sensitive_post_parameters
-from .forms import RegistrationForm, AccountForm, ProfileForm, GPGForm
+from .forms import RegistrationForm, AccountForm, ProfileForm, GPGForm, \
+ EmailChangeForm
from .models import Profile, User, UserStatus
from debexpo.keyring.models import Key
from debexpo.tools.email import Email
+from debexpo.tools.token import email_change_token_generator
log = logging.getLogger(__name__)
+INTERNAL_EMAIL_URL_TOKEN = 'change-email'
+INTERNAL_EMAIL_SESSION_TOKEN = '_change_email_token'
-def _send_activate_email(request, uid, token, recipient):
+def _send_activate_email(request, uid, token, recipient, new_email=False):
"""
Sends an activation email to the potential new user.
@@ -62,11 +71,20 @@ def _send_activate_email(request, uid, token, recipient):
Email address to send to.
"""
log.debug('Sending activation email')
- email = Email('email-password-creation.html')
- activate_url = request.scheme + '://' + request.site.domain + \
- reverse('password_reset_confirm', kwargs={
- 'uidb64': uid, 'token': token
- })
+
+ if new_email:
+ email = Email('email-change.html')
+ activate_url = request.scheme + '://' + request.site.domain + \
+ reverse('email_change_confirm', kwargs={
+ 'uidb64': uid, 'token': token, 'email': recipient,
+ })
+ else:
+ email = Email('email-password-creation.html')
+ activate_url = request.scheme + '://' + request.site.domain + \
+ reverse('password_reset_confirm', kwargs={
+ 'uidb64': uid, 'token': token
+ })
+
email.send(_('Next step: Confirm your email address'), [recipient],
activate_url=activate_url, settings=settings)
@@ -97,9 +115,19 @@ def _register_submit(request, info):
})
+def _request_email_change(request, email):
+ uid = urlsafe_base64_encode(force_bytes(request.user.pk))
+ token = email_change_token_generator.make_token(request.user, email)
+
+ _send_activate_email(request, uid, token, email, new_email=True)
+
+ return render(request, 'change-email.html', {
+ 'settings': settings
+ })
+
+
def _update_account(request, info):
request.user.name = info.get('name')
- request.user.email = info.get('email')
request.user.save()
@@ -177,6 +205,11 @@ def profile(request):
'{}'.format(request.user.email))
_update_account(request, account_form.cleaned_data)
+ email = account_form.cleaned_data.get('email')
+
+ if request.user.email != email:
+ return _request_email_change(request, email)
+
if 'commit_password' in request.POST:
password_form = PasswordChangeForm(user=request.user,
data=request.POST)
@@ -227,3 +260,67 @@ def profile(request):
'gpg_form': gpg_form,
'gpg_fingerprint': gpg_fingerprint,
})
+
+
+class EmailChangeConfirmView(PasswordResetConfirmView):
+ form_class = EmailChangeForm
+ title = _('Change your email')
+ token_generator = email_change_token_generator
+ success_url = reverse_lazy('email_change_complete')
+
+ def get_initial(self):
+ return {'email': self.email}
+
+ @method_decorator(sensitive_post_parameters())
+ @method_decorator(never_cache)
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ assert 'uidb64' in kwargs and 'token' in kwargs and 'email' in kwargs
+
+ self.validlink = False
+ self.user = self.get_user(kwargs['uidb64'])
+
+ if self.user is not None:
+ token = kwargs['token']
+ self.email = kwargs['email']
+
+ if token == INTERNAL_EMAIL_URL_TOKEN:
+ session_token = \
+ self.request.session.get(INTERNAL_EMAIL_SESSION_TOKEN)
+
+ if self.token_generator.check_token(self.user, session_token,
+ self.email):
+ self.validlink = True
+ return super(PasswordResetConfirmView, self) \
+ .dispatch(*args, **kwargs)
+ else:
+ if self.token_generator.check_token(self.user, token,
+ self.email):
+ self.request.session[INTERNAL_EMAIL_SESSION_TOKEN] = token
+ redirect_url = self.request.path.replace(
+ token,
+ INTERNAL_EMAIL_URL_TOKEN
+ )
+
+ return HttpResponseRedirect(redirect_url)
+
+ return self.render_to_response(self.get_context_data())
+
+ def form_valid(self, form):
+ if self.validlink:
+ self.user.email = self.email
+ self.user.full_clean()
+ self.user.save()
+
+ del self.request.session[INTERNAL_EMAIL_SESSION_TOKEN]
+
+ return super(PasswordResetConfirmView, self).form_valid(form)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ context.update({
+ 'email': self.email
+ })
+
+ return context
diff --git a/debexpo/locale/fr/LC_MESSAGES/django.po b/debexpo/locale/fr/LC_MESSAGES/django.po
index d142b156cff5e71562c82da45e0257958182e9d8..5b1a86d557177b60be69f15342430cd86a3be80a 100644
--- a/debexpo/locale/fr/LC_MESSAGES/django.po
+++ b/debexpo/locale/fr/LC_MESSAGES/django.po
@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-02-02 18:17+0100\n"
-"PO-Revision-Date: 2019-11-13 19:40+0100\n"
+"POT-Creation-Date: 2021-08-31 16:41+0000\n"
+"PO-Revision-Date: 2021-08-31 18:42+0200\n"
"Last-Translator: Baptiste Beauplat \n"
"Language-Team: French <>\n"
"Language: fr\n"
@@ -15,24 +15,27 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
-"X-Generator: Poedit 2.2.4\n"
+"X-Generator: Poedit 3.0\n"
-#: settings/common.py:89
+#: settings/common.py:91
msgid "English"
msgstr "Anglais"
-#: settings/common.py:90
-msgid "French"
-msgstr "Français"
-
-#: settings/common.py:141
+#: settings/common.py:144
msgid "Helps you get your packages into Debian"
msgstr "Vous aide à envoyer vos paquets dans Debian"
-#: tools/gnupg.py:169
+#: tools/gnupg.py:181
msgid "Cannot add key: {}"
msgstr "Impossible d'ajouter la clef : {}"
+#: urls.py:85
+msgid "Email change complete"
+msgstr "Changement de courriel terminé"
+
+#~ msgid "French"
+#~ msgstr "Français"
+
#~ msgid "Type"
#~ msgstr "Type"
@@ -46,9 +49,7 @@ msgstr "Impossible d'ajouter la clef : {}"
#~ msgstr "Multiples clefs non supporté"
#~ msgid "Key algorithm not supported. Key must be one of the following: {}"
-#~ msgstr ""
-#~ "L'algorithme de la clef n'est pas supportée. La clef doit faire partie de "
-#~ "l'une des suivantes : {}"
+#~ msgstr "L'algorithme de la clef n'est pas supportée. La clef doit faire partie de l'une des suivantes : {}"
#~ msgid "Key size too small. Need at least {} bits."
#~ msgstr "Clef trop petite. Nécessite au moins {} bits."
diff --git a/debexpo/settings/common.py b/debexpo/settings/common.py
index 876c572ee5d981b63242f82edf56f5c14ee0fd6b..874975bc135697c212e47b23fa889402f568b0e9 100644
--- a/debexpo/settings/common.py
+++ b/debexpo/settings/common.py
@@ -188,6 +188,9 @@ CELERY_BEAT_SCHEDULE = {
# Account registration expiration
REGISTRATION_EXPIRATION_DAYS = 7
+# Email update token expiration (2 days to get at the very least 24h)
+EMAIL_CHANGE_TIMEOUT_DAYS = 2
+
# Plugins to load
IMPORTER_PLUGINS = (
('debexpo.plugins.distribution', 'PluginDistribution',),
diff --git a/debexpo/tools/token.py b/debexpo/tools/token.py
new file mode 100644
index 0000000000000000000000000000000000000000..72f8e493068ae1d2646e0def89b80635ba32b499
--- /dev/null
+++ b/debexpo/tools/token.py
@@ -0,0 +1,91 @@
+# token.py - token manipulation for email validation
+#
+# This file is part of debexpo
+# https://salsa.debian.org/mentors.debian.net-team/debexpo
+#
+# Copyright © 2021 Baptiste Beauplat
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+
+
+from django.conf import settings
+from django.utils.crypto import constant_time_compare, salted_hmac
+from django.utils.http import base36_to_int, int_to_base36
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+
+
+class EmailChangeTokenGenerator(PasswordResetTokenGenerator):
+ def make_token(self, user, email):
+ return self._make_token_with_timestamp(
+ user,
+ self._num_days(self._today()),
+ email,
+ )
+
+ def check_token(self, user, token, email):
+ if not (user and token and email):
+ return False
+
+ try:
+ timestamp_base36, _ = token.split("-")
+ timestamp = base36_to_int(timestamp_base36)
+ except ValueError:
+ return False
+
+ if not constant_time_compare(
+ self._make_token_with_timestamp(user, timestamp, email),
+ token
+ ):
+ return False
+
+ if ((self._num_days(self._today()) - timestamp) >
+ settings.EMAIL_CHANGE_TIMEOUT_DAYS):
+ return False
+
+ return True
+
+ def _make_token_with_timestamp(self, user, timestamp, email):
+ timestamp_base36 = int_to_base36(timestamp)
+ hash_string = salted_hmac(
+ self.key_salt,
+ self._make_hash_value(user, timestamp, email),
+ secret=self.secret,
+ ).hexdigest()[::2]
+
+ return f'{timestamp_base36}-{hash_string}'
+
+ def _make_hash_value(self, user, timestamp, email):
+ if not user.last_login:
+ login_timestamp = ''
+ else:
+ login_timestamp = user.last_login.replace(microsecond=0,
+ tzinfo=None)
+
+ return str(user.pk) + \
+ user.password + \
+ user.email + \
+ email + \
+ str(login_timestamp) + \
+ str(timestamp)
+
+
+email_change_token_generator = EmailChangeTokenGenerator()
diff --git a/debexpo/urls.py b/debexpo/urls.py
index ba920db4c6479b90605af61856f08ee1d359519a..5c39b24c2460983af5e9b1d6792d60cd60c7609a 100644
--- a/debexpo/urls.py
+++ b/debexpo/urls.py
@@ -33,12 +33,13 @@ from django.contrib.auth.views import PasswordResetConfirmView, \
PasswordResetDoneView
from django.http.response import HttpResponsePermanentRedirect
from django.urls import reverse, include
+from django.utils.translation import gettext_lazy as _
from django.views.static import serve
from rest_framework_extensions.routers import ExtendedDefaultRouter
from debexpo.base.views import index, contact, intro_reviewers, \
intro_maintainers, qa, sponsor_overview, sponsor_guidelines, sponsor_rfs
-from debexpo.accounts.views import register, profile
+from debexpo.accounts.views import register, profile, EmailChangeConfirmView
from debexpo.accounts.forms import PasswordResetForm
from debexpo.packages.views import package, packages, packages_my, \
PackagesFeed, sponsor_package, delete_package, delete_upload, \
@@ -69,6 +70,22 @@ urlpatterns = [
url(r'^api/', include(api.urls)),
# Accounts
+ url(r'^accounts/email_change/'
+ r'(?P[0-9A-Za-z_\-]+)/'
+ r'(?P[0-9A-Za-z]+-[0-9A-Za-z]+)/'
+ r'(?P.*)/$',
+ EmailChangeConfirmView.as_view(
+ template_name='change-email-confirm.html',
+ extra_context={'settings': settings}
+ ),
+ name='email_change_confirm'),
+ url(r'^accounts/email_change/done/$',
+ PasswordResetCompleteView.as_view(
+ template_name='change-email-complete.html',
+ title=_('Email change complete'),
+ extra_context={'settings': settings}
+ ),
+ name='email_change_complete'),
url(r'^accounts/reset/'
r'(?P[0-9A-Za-z_\-]+)/'
r'(?P[0-9A-Za-z]+-[0-9A-Za-z]+)/$',
diff --git a/docs/templates/new.py b/docs/templates/new.py
index d40047de23cff1fe6e6cc40fcd84a00eb0b4316f..62f1a89f819d91606db84accd42a6939b500b0f7 100644
--- a/docs/templates/new.py
+++ b/docs/templates/new.py
@@ -3,7 +3,7 @@
# This file is part of debexpo
# https://salsa.debian.org/mentors.debian.net-team/debexpo
#
-# Copyright © 2021 Baptiste Beauplat
+# Copyright © 2021 Baptiste Beauplat
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
diff --git a/tests/functional/accounts/test_profile.py b/tests/functional/accounts/test_profile.py
index 004f7e1a46a02cf8e8c645d37a7cf19a3d694bca..6e564167aaa19f8f1ac6aee2a115689e8a676739 100644
--- a/tests/functional/accounts/test_profile.py
+++ b/tests/functional/accounts/test_profile.py
@@ -408,14 +408,12 @@ Xcgnuh6Rlywt6uiaFIGYnGefYPGXRAA=
self.assertIn('This field is required.', str(response.content))
response = self.client.post(reverse('profile'), {
'name': 'Test user2',
- 'email': 'email2@example.com',
+ 'email': 'email@example.com',
'commit_account': 'submit'
})
self.assertEquals(response.status_code, 200)
self.assertNotIn('errorlist', str(response.content))
- user = User.objects.filter(email='email@example.com')
- self.assertFalse(user)
- user = User.objects.get(email='email2@example.com')
+ user = User.objects.get(email='email@example.com')
self.assertEquals(user.name, 'Test user2')
user.delete()
diff --git a/tests/functional/accounts/test_update_email.py b/tests/functional/accounts/test_update_email.py
new file mode 100644
index 0000000000000000000000000000000000000000..eafb4a905ae06a01d0bf057e0fc370f11e081ec0
--- /dev/null
+++ b/tests/functional/accounts/test_update_email.py
@@ -0,0 +1,199 @@
+# test_update_email.py - Functional tests for the profile page
+#
+# This file is part of debexpo
+# https://salsa.debian.org/mentors.debian.net-team/debexpo
+#
+# Copyright © 2019-2021 Baptiste Beauplat
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+
+import logging
+
+from re import search, sub
+
+from django.core import mail
+from django.urls import reverse
+from django.utils.encoding import force_bytes
+from django.utils.http import urlsafe_base64_encode
+
+from debexpo.accounts.models import User
+from debexpo.tools.token import email_change_token_generator
+
+from tests import TransactionTestController
+
+log = logging.getLogger(__name__)
+
+
+class TestUpdateEmail(TransactionTestController):
+ def setUp(self):
+ self._setup_example_user()
+ self._setup_example_user(True, 'another@example.org')
+ self.client.post(reverse('login'), self._AUTHDATA)
+
+ def tearDown(self):
+ self._remove_example_user()
+
+ def _get_update_token(self, email, error=None):
+ data = {
+ 'name': 'Test user',
+ 'email': email,
+ 'commit_account': 'submit'
+ }
+ submit_data = {
+ 'uidb64': 'a',
+ 'token': 'x-x',
+ 'email': 'a',
+ }
+ mail.outbox = []
+ response = self.client.post(reverse('profile'), data)
+ submit_url = sub(r'(/[^/]*){4}$', '',
+ reverse('email_change_confirm', kwargs=submit_data))
+
+ self.assertEquals(response.status_code, 200)
+
+ if error:
+ self.assertIn('errorlist', str(response.content))
+ self.assertIn(error, str(response.content))
+
+ return None
+
+ self.assertIn('Check your email', str(response.content))
+ self.assertIn('Confirm your new email', str(mail.outbox[0].body))
+ self.assertIn(submit_url, str(mail.outbox[0].body))
+
+ token = search(rf'{submit_url}[^/]*/[^/]*/([^/]*)',
+ str(mail.outbox[0].body))[1]
+
+ return token
+
+ def _submit_token(self, token, email, invalid=False, error=None,
+ redirect=None):
+ user = User.objects.get(email=self._AUTHDATA['username'])
+ submit_data = {
+ 'uidb64': urlsafe_base64_encode(force_bytes(user.pk)),
+ 'token': token,
+ 'email': email,
+ }
+ submit_url = reverse('email_change_confirm', kwargs=submit_data)
+ response = self.client.get(submit_url)
+
+ if invalid:
+ self.assertIn('Invalid reset link', str(response.content))
+
+ return
+
+ submit_data['token'] = 'change-email'
+ submit_url = reverse('email_change_confirm', kwargs=submit_data)
+ complete_url = reverse('email_change_complete')
+
+ self.assertEquals(response.status_code, 302)
+
+ if redirect:
+ self.assertTrue(str(response.url).startswith(redirect))
+
+ return
+
+ self.assertEquals(response.url,
+ reverse('email_change_confirm', kwargs=submit_data))
+
+ data = {
+ 'commit': 'submit'
+ }
+ response = self.client.post(reverse('email_change_confirm',
+ kwargs=submit_data), data)
+
+ if error:
+ self.assertEquals(response.status_code, 200)
+ self.assertIn(error, str(response.content))
+
+ return
+
+ self.assertEquals(response.status_code, 302)
+ self.assertIn(complete_url, str(response.url))
+
+ response = self.client.get(complete_url)
+
+ self.assertEquals(response.status_code, 200)
+ self.assertIn('Email changed successfully', str(response.content))
+
+ self.assertRaises(User.DoesNotExist,
+ User.objects.get,
+ email=self._AUTHDATA['username'])
+ User.objects.get(email=email)
+
+ def _generate_update_token(self, email):
+ """
+ Used to generated a token bypassing address validation. It shouldn't be
+ possible to do within the app but we use it to test token submission
+ thoroughly.
+ """
+ return email_change_token_generator.make_token(
+ User.objects.get(email=self._AUTHDATA['username']), email
+ )
+
+ def _change_user_email(self, old_email, new_email):
+ user = User.objects.get(email=old_email)
+ user.email = new_email
+
+ user.save()
+
+ def test_update_email_ok(self):
+ token = self._get_update_token('new@example.org')
+
+ self._submit_token(token, 'new@example.org')
+
+ def test_update_email_already_taken(self):
+ # Test on token request
+ self._get_update_token('another@example.org',
+ 'this email address is already registered')
+
+ # Test on token submission
+ token = self._get_update_token('new@example.org')
+
+ self._change_user_email('another@example.org', 'new@example.org')
+ self._submit_token(token, 'new@example.org',
+ error='address already exists')
+
+ def test_update_email_mismatch_email(self):
+ token = self._get_update_token('new@example.org')
+
+ self._submit_token(token, 'new2@example.org', True)
+
+ def test_update_email_invalid_email(self):
+ # Test on token request
+ self._get_update_token('This is not an email address',
+ 'Enter a valid email address.')
+
+ # Test on token submission
+ token = self._generate_update_token('This is not an email address')
+
+ self._submit_token(token, 'This is not an email address',
+ error='Enter a valid email address.')
+
+ def test_update_email_no_login(self):
+ # Test on token submission
+ self.client.get(reverse('logout'))
+
+ token = self._generate_update_token('new@example.org')
+
+ self._submit_token(token, 'new@example.org',
+ redirect=reverse('login'))
diff --git a/tests/unit/tools/test_token.py b/tests/unit/tools/test_token.py
new file mode 100644
index 0000000000000000000000000000000000000000..4226364972b27328d5348bd5930ef1501d55c3d6
--- /dev/null
+++ b/tests/unit/tools/test_token.py
@@ -0,0 +1,114 @@
+# test_token.py - Test token class
+#
+# This file is part of debexpo
+# https://salsa.debian.org/mentors.debian.net-team/debexpo
+#
+# Copyright © 2021 Baptiste Beauplat
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+
+from datetime import date, timedelta
+
+from django.utils import timezone
+
+from tests import TestController
+
+from debexpo.accounts.models import User
+from debexpo.tools.token import email_change_token_generator as token_generator
+
+
+class TestToken(TestController):
+ def setUp(self):
+ self._setup_example_user()
+
+ self._today = date.today()
+ token_generator._today = self._now
+ self.user = User.objects.get(email='email@example.com')
+
+ def _now(self):
+ return self._today
+
+ def _expired(self):
+ return self._today - timedelta(days=999)
+
+ def tearDown(self):
+ self._remove_example_user()
+
+ def test_make_token(self):
+ # Different emails produce different tokens
+ token1 = token_generator.make_token(self.user, 'new@example.org')
+ token2 = token_generator.make_token(self.user, 'another@example.org')
+
+ self.assertEquals(token1.split('-')[0], token2.split('-')[0])
+ self.assertNotEquals(token1.split('-')[1], token2.split('-')[1])
+
+ # Same email+account+time produce same token
+ token2 = token_generator.make_token(self.user, 'new@example.org')
+
+ self.assertEquals(token1, token2)
+
+ # Different time produce different tokens
+ token_generator._today = self._expired
+ token2 = token_generator.make_token(self.user, 'new@example.org')
+
+ self.assertNotEquals(token1.split('-')[0], token2.split('-')[0])
+ self.assertNotEquals(token1.split('-')[1], token2.split('-')[1])
+
+ def test_check_token(self):
+ # Valid token: ok
+ token = token_generator.make_token(self.user, 'new@example.org')
+
+ self.assertTrue(token_generator.check_token(
+ self.user, token, 'new@example.org'
+ ))
+
+ # Bad user: ko
+ self.assertFalse(token_generator.check_token(
+ None, token, 'wrong@example.org'
+ ))
+
+ # Email changed: ko
+ self.assertFalse(token_generator.check_token(
+ self.user, token, 'wrong@example.org'
+ ))
+
+ # Invalid token: ko
+ self.assertFalse(token_generator.check_token(
+ self.user, 'invalid_token', 'new@example.org'
+ ))
+
+ # user updated: ko
+ self.user.last_login = timezone.now()
+ self.user.save()
+
+ self.assertFalse(token_generator.check_token(
+ self.user, token, 'new@example.org'
+ ))
+
+ # Expired token: ko
+ token_generator._today = self._expired
+ token = token_generator.make_token(self.user, 'new@example.org')
+ token_generator._today = self._now
+
+ self.assertFalse(token_generator.check_token(
+ self.user, token, 'new@example.org'
+ ))