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): ...@@ -128,6 +128,19 @@ class PasswordResetForm(DjangoPasswordResetForm):
email.send(_('You requested a password reset'), [to_email], **context) 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): class ProfileForm(forms.ModelForm):
status = forms.ChoiceField(choices=( status = forms.ChoiceField(choices=(
UserStatus.contributor.tuple, UserStatus.contributor.tuple,
......
...@@ -7,8 +7,8 @@ msgid "" ...@@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-09-19 14:03+0000\n" "POT-Creation-Date: 2021-08-31 16:30+0000\n"
"PO-Revision-Date: 2020-09-19 16:04+0200\n" "PO-Revision-Date: 2021-08-31 18:36+0200\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
"Language: fr\n" "Language: fr\n"
...@@ -16,7 +16,7 @@ msgstr "" ...@@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\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 #: accounts/forms.py:48
msgid "Full name" msgid "Full name"
...@@ -75,31 +75,31 @@ msgstr "Mainteneur Debian (DM)" ...@@ -75,31 +75,31 @@ msgstr "Mainteneur Debian (DM)"
msgid "Debian Developer (DD)" msgid "Debian Developer (DD)"
msgstr "Développeur Debian (DD)" msgstr "Développeur Debian (DD)"
#: accounts/models.py:101 #: accounts/models.py:102
msgid "email address" msgid "email address"
msgstr "adresse de courriel" msgstr "adresse de courriel"
#: accounts/models.py:102 #: accounts/models.py:103
msgid "full name" msgid "full name"
msgstr "nom complet" msgstr "nom complet"
#: accounts/models.py:126 #: accounts/models.py:127
msgid "Country" msgid "Country"
msgstr "Pays" msgstr "Pays"
#: accounts/models.py:129 #: accounts/models.py:130
msgid "IRC Nickname" msgid "IRC Nickname"
msgstr "Pseudo IRC" msgstr "Pseudo IRC"
#: accounts/models.py:130 #: accounts/models.py:131
msgid "Jabber address" msgid "Jabber address"
msgstr "Adresse Jabber" msgstr "Adresse Jabber"
#: accounts/models.py:134 #: accounts/models.py:135
msgid "Status" msgid "Status"
msgstr "Statut" msgstr "Statut"
#: accounts/templates/activate.html:3 #: accounts/templates/activate.html:3 accounts/templates/change-email.html:3
msgid "Check your email" msgid "Check your email"
msgstr "Vérifiez votre messagerie" msgstr "Vérifiez votre messagerie"
...@@ -135,6 +135,75 @@ msgstr "" ...@@ -135,6 +135,75 @@ msgstr ""
"La clef de vérification que vous avez entré n'est pas reconnue. Merci de " "La clef de vérification que vous avez entré n'est pas reconnue. Merci de "
"réessayer." "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 #: accounts/templates/email-password-creation.html:1
msgid "" msgid ""
"Hello,\n" "Hello,\n"
...@@ -156,11 +225,6 @@ msgstr "" ...@@ -156,11 +225,6 @@ msgstr ""
"Si vous n'avez pas créé de compte sur %(site_name)s,\n" "Si vous n'avez pas créé de compte sur %(site_name)s,\n"
"vous pouvez ignorer ce courriel." "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 #: accounts/templates/email-password-reset.html:1
msgid "" msgid ""
"Hello,\n" "Hello,\n"
...@@ -237,18 +301,6 @@ msgstr "connecter" ...@@ -237,18 +301,6 @@ msgstr "connecter"
msgid "Reset your password" msgid "Reset your password"
msgstr "Réinitialisez votre mot de passe" 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 #: accounts/templates/password-reset-done.html:3
msgid "Password recovery in progress" msgid "Password recovery in progress"
msgstr "Récupération de mot de passe en cours" msgstr "Récupération de mot de passe en cours"
...@@ -335,7 +387,7 @@ msgstr "Inscrivez un compte" ...@@ -335,7 +387,7 @@ msgstr "Inscrivez un compte"
msgid "Account details" msgid "Account details"
msgstr "Détail du compte" msgstr "Détail du compte"
#: accounts/views.py:70 #: accounts/views.py:88
msgid "Next step: Confirm your email address" msgid "Next step: Confirm your email address"
msgstr "Prochaine étape : Confirmer votre courriel" 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 ...@@ -35,23 +35,32 @@ from django.conf import settings
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm 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.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.utils.translation import gettext as _
from django.shortcuts import render from django.shortcuts import render
from django.utils.decorators import method_decorator
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode 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 .models import Profile, User, UserStatus
from debexpo.keyring.models import Key from debexpo.keyring.models import Key
from debexpo.tools.email import Email from debexpo.tools.email import Email
from debexpo.tools.token import email_change_token_generator
log = logging.getLogger(__name__) 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. Sends an activation email to the potential new user.
...@@ -62,11 +71,20 @@ def _send_activate_email(request, uid, token, recipient): ...@@ -62,11 +71,20 @@ def _send_activate_email(request, uid, token, recipient):
Email address to send to. Email address to send to.
""" """
log.debug('Sending activation email') log.debug('Sending activation email')
email = Email('email-password-creation.html')
activate_url = request.scheme + '://' + request.site.domain + \ if new_email:
reverse('password_reset_confirm', kwargs={ email = Email('email-change.html')
'uidb64': uid, 'token': token 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], email.send(_('Next step: Confirm your email address'), [recipient],
activate_url=activate_url, settings=settings) activate_url=activate_url, settings=settings)
...@@ -97,9 +115,19 @@ def _register_submit(request, info): ...@@ -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): def _update_account(request, info):
request.user.name = info.get('name') request.user.name = info.get('name')
request.user.email = info.get('email')
request.user.save() request.user.save()
...@@ -177,6 +205,11 @@ def profile(request): ...@@ -177,6 +205,11 @@ def profile(request):
'{}'.format(request.user.email)) '{}'.format(request.user.email))
_update_account(request, account_form.cleaned_data) _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: if 'commit_password' in request.POST:
password_form = PasswordChangeForm(user=request.user, password_form = PasswordChangeForm(user=request.user,
data=request.POST) data=request.POST)
...@@ -227,3 +260,67 @@ def profile(request): ...@@ -227,3 +260,67 @@ def profile(request):
'gpg_form': gpg_form, 'gpg_form': gpg_form,
'gpg_fingerprint': gpg_fingerprint, '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 "" ...@@ -6,8 +6,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-02-02 18:17+0100\n" "POT-Creation-Date: 2021-08-31 16:41+0000\n"
"PO-Revision-Date: 2019-11-13 19:40+0100\n" "PO-Revision-Date: 2021-08-31 18:42+0200\n"
"Last-Translator: Baptiste Beauplat <lyknode@cilg.org>\n" "Last-Translator: Baptiste Beauplat <lyknode@cilg.org>\n"
"Language-Team: French <>\n" "Language-Team: French <>\n"
"Language: fr\n" "Language: fr\n"
...@@ -15,24 +15,27 @@ msgstr "" ...@@ -15,24 +15,27 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\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" msgid "English"
msgstr "Anglais" msgstr "Anglais"
#: settings/common.py:90 #: settings/common.py:144
msgid "French"
msgstr "Français"
#: settings/common.py:141
msgid "Helps you get your packages into Debian" msgid "Helps you get your packages into Debian"
msgstr "Vous aide à envoyer vos paquets dans Debian" msgstr "Vous aide à envoyer vos paquets dans Debian"
#: tools/gnupg.py:169 #: tools/gnupg.py:181
msgid "Cannot add key: {}" msgid "Cannot add key: {}"
msgstr "Impossible d'ajouter la clef : {}" 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" #~ msgid "Type"
#~ msgstr "Type" #~ msgstr "Type"
...@@ -46,9 +49,7 @@ msgstr "Impossible d'ajouter la clef : {}" ...@@ -46,9 +49,7 @@ msgstr "Impossible d'ajouter la clef : {}"
#~ msgstr "Multiples clefs non supporté" #~ msgstr "Multiples clefs non supporté"
#~ msgid "Key algorithm not supported. Key must be one of the following: {}" #~ msgid "Key algorithm not supported. Key must be one of the following: {}"
#~ msgstr "" #~ msgstr "L'algorithme de la clef n'est pas supportée. La clef doit faire partie de l'une des suivantes : {}"
#~ "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." #~ msgid "Key size too small. Need at least {} bits."
#~ msgstr "Clef trop petite. Nécessite au moins {} bits." #~ msgstr "Clef trop petite. Nécessite au moins {} bits."
......
...@@ -188,6 +188,9 @@ CELERY_BEAT_SCHEDULE = { ...@@ -188,6 +188,9 @@ CELERY_BEAT_SCHEDULE = {
# Account registration expiration # Account registration expiration
REGISTRATION_EXPIRATION_DAYS = 7 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 # Plugins to load
IMPORTER_PLUGINS = ( IMPORTER_PLUGINS = (
('debexpo.plugins.distribution', 'PluginDistribution',), ('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, \ ...@@ -33,12 +33,13 @@ from django.contrib.auth.views import PasswordResetConfirmView, \
PasswordResetDoneView PasswordResetDoneView
from django.http.response import HttpResponsePermanentRedirect from django.http.response import HttpResponsePermanentRedirect
from django.urls import reverse, include from django.urls import reverse, include
from django.utils.translation import gettext_lazy as _
from django.views.static import serve from django.views.static import serve
from rest_framework_extensions.routers import ExtendedDefaultRouter from rest_framework_extensions.routers import ExtendedDefaultRouter
from debexpo.base.views import index, contact, intro_reviewers, \ from debexpo.base.views import index, contact, intro_reviewers, \
intro_maintainers, qa, sponsor_overview, sponsor_guidelines, sponsor_rfs 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.accounts.forms import PasswordResetForm
from debexpo.packages.views import package, packages, packages_my, \ from debexpo.packages.views import package, packages, packages_my, \
PackagesFeed, sponsor_package, delete_package, delete_upload, \ PackagesFeed, sponsor_package, delete_package, delete_upload, \
...@@ -69,6 +70,22 @@ urlpatterns = [ ...@@ -69,6 +70,22 @@ urlpatterns = [
url(r'^api/', include(api.urls)), url(r'^api/', include(api.urls)),
# Accounts # 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/' url(r'^accounts/reset/'
r'(?P<uidb64>[0-9A-Za-z_\-]+)/' r'(?P<uidb64>[0-9A-Za-z_\-]+)/'
r'(?P<token>[0-9A-Za-z]+-[0-9A-Za-z]+)/$', r'(?P<token>[0-9A-Za-z]+-[0-9A-Za-z]+)/$',
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
# This file is part of debexpo # This file is part of debexpo
# https://salsa.debian.org/mentors.debian.net-team/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 # Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation # obtaining a copy of this software and associated documentation
......
...@@ -408,14 +408,12 @@ Xcgnuh6Rlywt6uiaFIGYnGefYPGXRAA= ...@@ -408,14 +408,12 @@ Xcgnuh6Rlywt6uiaFIGYnGefYPGXRAA=
self.assertIn('This field is required.', str(response.content)) self.assertIn('This field is required.', str(response.content))
response = self.client.post(reverse('profile'), { response = self.client.post(reverse('profile'), {
'name': 'Test user2', 'name': 'Test user2',
'email': 'email2@example.com', 'email': 'email@example.com',
'commit_account': 'submit' 'commit_account': 'submit'
}) })
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertNotIn('errorlist', str(response.content)) self.assertNotIn('errorlist', str(response.content))
user = User.objects.filter(email='email@example.com') user = User.objects.get(email='email@example.com')
self.assertFalse(user)
user = User.objects.get(email='email2@example.com')
self.assertEquals(user.name, 'Test user2') self.assertEquals(user.name, 'Test user2')
user.delete() 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