Verified Commit a21e41ff authored by Mattia Rizzolo's avatar Mattia Rizzolo
Browse files

Merge branch 'email-validation' of salsa.debian.org:lyknode/debexpo into live

MR: !182


Signed-off-by: Mattia Rizzolo's avatarMattia Rizzolo <mattia@debian.org>
parents 7e0f2d08 c4aca2ee
Pipeline #287135 passed with stage
in 9 minutes and 44 seconds
......@@ -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,
......
......@@ -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"
......
{% extends "base.html" %}{% load i18n %}
{% block content %}<h1>{% trans 'Email changed successfully' %}</h1>
<p>{% trans 'Your email has been successfully updated' %}.</p>
<p>{% trans 'Go back to' %}
<a href="{% url 'profile' %}">{% trans 'my account' %}</a>.</p>
{% endblock %}
{% extends "base.html" %}{% load i18n %}
{% block content %}<h1>{% trans 'Change your email' %}</h1>
{% if validlink %}
<fieldset>
<form method="post">
{% csrf_token %}
<table id="form">
{{ form }}
</table>
<input id="commit" name="commit" type="submit"
value="{% trans 'Submit' %}">
</form>
</fieldset>
{% else %}
<p><span class="error-message">{% trans 'Invalid reset link' %}</span></p>
{% endif %}{% endblock %}
{% extends "base.html" %}{% load i18n %}
{% block content %}<h1>{% trans 'Check your email' %}</h1>
<p>
{% blocktrans trimmed %}
An email has been sent to your new email address. Check it for
instructions on how to validate it.
{% endblocktrans %}
</p>
{% endblock %}
{% 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 %}
......@@ -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')
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
......@@ -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 <lyknode@cilg.org>\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."
......
......@@ -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',),
......
# 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 <lyknode@debian.org>
#
# 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()
......@@ -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<uidb64>[0-9A-Za-z_\-]+)/'
r'(?P<token>[0-9A-Za-z]+-[0-9A-Za-z]+)/'
r'(?P<email>.*)/$',
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<uidb64>[0-9A-Za-z_\-]+)/'
r'(?P<token>[0-9A-Za-z]+-[0-9A-Za-z]+)/$',
......
......@@ -3,7 +3,7 @@
# This file is part of debexpo
# https://salsa.debian.org/mentors.debian.net-team/debexpo
#
# Copyright © 2021 Baptiste Beauplat <lyknode@cilg.org>
# Copyright © 2021 Baptiste Beauplat <lyknode@debian.org>
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
......
......@@ -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()
......
# 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 <lyknode@debian.org>
#
# 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'))
# 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 <lyknode@debian.org>
#
# 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'
))
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment