From 57ed5d39811544b1c63a9def8919995f7a55e208 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Thu, 16 May 2019 19:03:01 +0200 Subject: [PATCH 2/2] views: ask for reauthentication when deleting account (#28853) --- src/authentic2/forms/profile.py | 14 ---------- src/authentic2/views.py | 30 +++++++++++++++------ tests/test_views.py | 48 +++++++++++++++++++++++++++++---- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/src/authentic2/forms/profile.py b/src/authentic2/forms/profile.py index 5dd81a0d..7379e0a5 100644 --- a/src/authentic2/forms/profile.py +++ b/src/authentic2/forms/profile.py @@ -24,20 +24,6 @@ from .. import app_settings, models from .utils import NextUrlFormMixin -class DeleteAccountForm(forms.Form): - password = forms.CharField(widget=forms.PasswordInput, label=_("Password")) - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop('user') - super(DeleteAccountForm, self).__init__(*args, **kwargs) - - def clean_password(self): - password = self.cleaned_data.get('password') - if password and not self.user.check_password(password): - raise forms.ValidationError(ugettext('Password is invalid')) - return password - - class EmailChangeFormNoPassword(forms.Form): email = forms.EmailField(label=_('New email')) diff --git a/src/authentic2/views.py b/src/authentic2/views.py index 21f7aa28..4d54da92 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import time import collections import logging import random @@ -49,6 +50,7 @@ from django.utils.http import urlsafe_base64_decode from django.views.generic.edit import CreateView from django.forms import CharField, Form from django.core.urlresolvers import reverse_lazy +from django.core.signing import TimestampSigner, BadSignature, SignatureExpired from django.http import HttpResponseBadRequest from . import (utils, app_settings, compat, decorators, constants, @@ -1082,28 +1084,40 @@ class DeleteView(FormView): template_name = 'authentic2/accounts_delete.html' success_url = reverse_lazy('auth_logout') title = _('Delete account') + last_authentication_timeout = 60 + token_timeout = 10 * 60 def dispatch(self, request, *args, **kwargs): if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT: return utils.redirect(request, '..') + token = self.request.GET.get('token') + signer = TimestampSigner(salt='delete-account') + try: + token_valid = token and signer.unsign(token, max_age=self.token_timeout) == str(self.request.user.uuid) + except (BadSignature, SignatureExpired): + token_valid = False + if not token_valid: + if self.has_recent_authentication(request): + return utils.redirect(request, request.path, params={'token': signer.sign(str(self.request.user.uuid))}) + else: + messages.info(request, + _('Your last authentication is too old, ' + 'you need to reauthenticate to delete your account')) + return utils.login_require(request) return super(DeleteView, self).dispatch(request, *args, **kwargs) + def has_recent_authentication(self, request): + delta = time.time() - utils.last_authentication_event(request=request)['when'] + return delta < self.last_authentication_timeout + def post(self, request, *args, **kwargs): if 'cancel' in request.POST: return utils.redirect(request, 'account_management') return super(DeleteView, self).post(request, *args, **kwargs) def get_form_class(self): - if self.request.user.has_usable_password(): - return profile_forms.DeleteAccountForm return Form - def get_form_kwargs(self, **kwargs): - kwargs = super(DeleteView, self).get_form_kwargs(**kwargs) - if self.request.user.has_usable_password(): - kwargs['user'] = self.request.user - return kwargs - def form_valid(self, form): utils.send_account_deletion_mail(self.request, self.request.user) models.DeletedUser.objects.delete_user(self.request.user) diff --git a/tests/test_views.py b/tests/test_views.py index 70757fb4..c08c9563 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -15,6 +15,9 @@ # along with this program. If not, see . # authentic2 +import time +import datetime + from utils import login import pytest @@ -40,11 +43,46 @@ def test_password_change(app, simple_user): def test_account_delete(app, simple_user, mailoutbox): assert simple_user.is_active assert not len(mailoutbox) - page = login(app, simple_user, path=reverse('delete_account')) - page.form.set('password', simple_user.username) - # FIXME: webtest does not set the Referer header, so the logout page will always ask for - # confirmation under tests - response = page.form.submit(name='submit').follow() + response = login(app, simple_user, path=reverse('delete_account')).follow() + response = response.form.submit(name='submit').follow() + response = response.form.submit() + assert len(mailoutbox) == 1 + assert not User.objects.get(pk=simple_user.pk).is_active + assert urlparse(response.location).path == '/' + response = response.follow().follow() + assert response.request.url.endswith('/login/?next=/') + + +def test_account_delete_expired_authentication(app, simple_user, mailoutbox, freezer): + t = time.time() + assert simple_user.is_active + assert not len(mailoutbox) + response = login(app, simple_user, path='/accounts/') + + # move 80 seconds in the future, so that last authentication is at least 60 seconds old + freezer.move_to(datetime.timedelta(seconds=80)) + assert time.time() - t > 60 + + # check we are redirected to login page + response = response.click('Delete account') + assert urlparse(response.location).path == '/login/' + response = response.follow() + assert 'you need to reauthenticate' in response.text + + response.form.set('username', simple_user.username) + response.form.set('password', simple_user.username) + response = response.form.submit(name='login-password-submit') + + # check we are redirected to the delete account page + assert urlparse(response.location).path == '/accounts/delete/' + response = response.follow() + + # check a token is added + assert 'token' in response.location + response = response.follow() + + # check delete view is now working + response = response.form.submit(name='submit').follow() response = response.form.submit() assert len(mailoutbox) == 1 assert not User.objects.get(pk=simple_user.pk).is_active -- 2.20.1