From 3e8f55e3a993feefb9f92589e2e79f247eb1274f Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Sun, 4 Jul 2021 15:51:41 +0200 Subject: [PATCH 01/12] Update lisence template with my @debian.org address --- docs/templates/new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/templates/new.py b/docs/templates/new.py index d40047de..62f1a89f 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 -- GitLab From ebbaf80952ab07a04f8db24837948961d3527d11 Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Mon, 23 Aug 2021 21:26:42 +0200 Subject: [PATCH 02/12] Add EmailChangeTokenGenerator, a token generator hashing the user email instead of its password --- debexpo/settings/common.py | 3 ++ debexpo/tools/token.py | 91 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 debexpo/tools/token.py diff --git a/debexpo/settings/common.py b/debexpo/settings/common.py index 876c572e..874975bc 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 00000000..72f8e493 --- /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() -- GitLab From 25c32a11fcfa3c4216ff026f5d49b232ce800d78 Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Mon, 23 Aug 2021 21:27:50 +0200 Subject: [PATCH 03/12] Add EmailChangeForm, a form to allow user to change their email --- debexpo/accounts/forms.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/debexpo/accounts/forms.py b/debexpo/accounts/forms.py index cc6569bd..8977d12b 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, -- GitLab From 14fbbd7f1e41683f105acb581ef8edbab578f9fb Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Mon, 23 Aug 2021 21:30:47 +0200 Subject: [PATCH 04/12] Add EmailChangeConfirmView, a class based view to allow users to change their email --- debexpo/accounts/views.py | 76 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/debexpo/accounts/views.py b/debexpo/accounts/views.py index a763f93d..00e8edf9 100644 --- a/debexpo/accounts/views.py +++ b/debexpo/accounts/views.py @@ -35,20 +35,28 @@ 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 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): @@ -227,3 +235,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 -- GitLab From 00d0e41c009134b473ab03bc4d161851892c4d61 Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Mon, 23 Aug 2021 21:31:41 +0200 Subject: [PATCH 05/12] Add html templates for email update pages (request, confirm and complete) --- .../templates/change-email-complete.html | 8 ++++++++ .../templates/change-email-confirm.html | 18 ++++++++++++++++++ debexpo/accounts/templates/change-email.html | 11 +++++++++++ 3 files changed, 37 insertions(+) create mode 100644 debexpo/accounts/templates/change-email-complete.html create mode 100644 debexpo/accounts/templates/change-email-confirm.html create mode 100644 debexpo/accounts/templates/change-email.html diff --git a/debexpo/accounts/templates/change-email-complete.html b/debexpo/accounts/templates/change-email-complete.html new file mode 100644 index 00000000..b6650a25 --- /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 00000000..672b55d7 --- /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 %} +
+
+ {% csrf_token %} + + {{ form }} +
+ +
+
+{% 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 00000000..e086abe7 --- /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 %} -- GitLab From 3364cde0b7d3277ffd9683302a2c5c5bb699792c Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Mon, 23 Aug 2021 21:32:06 +0200 Subject: [PATCH 06/12] Add email template for email update (token link) --- debexpo/accounts/templates/email-change.html | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 debexpo/accounts/templates/email-change.html diff --git a/debexpo/accounts/templates/email-change.html b/debexpo/accounts/templates/email-change.html new file mode 100644 index 00000000..c6767394 --- /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 %} -- GitLab From 667b28401cbe9b7147144ce051047c8c49d7b667 Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Mon, 23 Aug 2021 21:33:32 +0200 Subject: [PATCH 07/12] Add code to send a token by email to validate the new address --- debexpo/accounts/views.py | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/debexpo/accounts/views.py b/debexpo/accounts/views.py index 00e8edf9..5936fde1 100644 --- a/debexpo/accounts/views.py +++ b/debexpo/accounts/views.py @@ -53,13 +53,14 @@ 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. @@ -70,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) @@ -105,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() @@ -185,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) -- GitLab From 7962bd1255e7255101fd42f7b56f038745031356 Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Mon, 23 Aug 2021 21:33:52 +0200 Subject: [PATCH 08/12] Register email update urls --- debexpo/urls.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/debexpo/urls.py b/debexpo/urls.py index ba920db4..5c39b24c 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]+)/$', -- GitLab From 67386b9b9e6621ff1f195fa8e822d7618cd79362 Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Mon, 23 Aug 2021 21:34:11 +0200 Subject: [PATCH 09/12] Add functional tests for email update --- .../functional/accounts/test_update_email.py | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tests/functional/accounts/test_update_email.py diff --git a/tests/functional/accounts/test_update_email.py b/tests/functional/accounts/test_update_email.py new file mode 100644 index 00000000..eafb4a90 --- /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')) -- GitLab From fa39bcc81b5b4904e9502d4c081f0c04613a482d Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Tue, 24 Aug 2021 21:43:43 +0200 Subject: [PATCH 10/12] Remove email update test from test_profile (now done in test_email_update) --- tests/functional/accounts/test_profile.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/functional/accounts/test_profile.py b/tests/functional/accounts/test_profile.py index 004f7e1a..6e564167 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() -- GitLab From 084305037866447f365d22ebffd008230c6798ac Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Mon, 30 Aug 2021 21:55:01 +0200 Subject: [PATCH 11/12] Add unit test for the email token generator --- tests/unit/tools/test_token.py | 114 +++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/unit/tools/test_token.py diff --git a/tests/unit/tools/test_token.py b/tests/unit/tools/test_token.py new file mode 100644 index 00000000..42263649 --- /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' + )) -- GitLab From c4aca2ee94719bf92aeefdd82528fc77efd00a60 Mon Sep 17 00:00:00 2001 From: Baptiste Beauplat Date: Tue, 31 Aug 2021 18:36:59 +0200 Subject: [PATCH 12/12] Update french translation --- .../accounts/locale/fr/LC_MESSAGES/django.po | 108 +++++++++++++----- debexpo/locale/fr/LC_MESSAGES/django.po | 27 ++--- 2 files changed, 94 insertions(+), 41 deletions(-) diff --git a/debexpo/accounts/locale/fr/LC_MESSAGES/django.po b/debexpo/accounts/locale/fr/LC_MESSAGES/django.po index 56c83a03..32cc7add 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/locale/fr/LC_MESSAGES/django.po b/debexpo/locale/fr/LC_MESSAGES/django.po index d142b156..5b1a86d5 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." -- GitLab