From 6a04de984d8fbfc127c6bb650bc93907c6eb4e04 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 26 Jan 2022 15:02:56 +0100 Subject: [PATCH 6/6] views: support any kind of authentication for email change (#61125) --- src/authentic2/views.py | 56 +++++++++++++++++++++++++---------- tests/auth_fc/test_auth_fc.py | 36 +++++++++++++++++++++- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/src/authentic2/views.py b/src/authentic2/views.py index c93365d2..c2ed53f2 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -183,13 +183,36 @@ class EditRequired(EditProfile): edit_required_profile = login_required(EditRequired.as_view()) -class EmailChangeView(cbv.TemplateNamesMixin, FormView): +class RecentAuthenticationMixin: + last_authentication_max_age = 600 # 10 minutes + + def reauthenticate(self, action, message): + methods = [event['how'] for event in utils_misc.get_authentication_events(self.request)] + return utils_misc.login_require( + self.request, + token={ + 'action': action, + 'message': message, + 'methods': methods, + }, + ) + + def has_recent_authentication(self): + age = time.time() - utils_misc.last_authentication_event(request=self.request)['when'] + return age < self.last_authentication_max_age + + +class EmailChangeView(RecentAuthenticationMixin, cbv.TemplateNamesMixin, FormView): template_names = ['profiles/email_change.html', 'authentic2/change_email.html'] title = _('Email Change') success_url = '..' + def can_validate_with_password(self): + last_event = utils_misc.last_authentication_event(self.request) + return last_event and last_event['how'].startswith('password-') + def get_form_class(self): - if self.request.user.has_usable_password(): + if self.can_validate_with_password(): return profile_forms.EmailChangeForm return profile_forms.EmailChangeFormNoPassword @@ -198,6 +221,18 @@ class EmailChangeView(cbv.TemplateNamesMixin, FormView): kwargs['user'] = self.request.user return kwargs + def has_recent_authentication(self): + age = time.time() - utils_misc.last_authentication_event(request=self.request)['when'] + return age < self.last_authentication_max_age + + def dispatch(self, request, *args, **kwargs): + if not self.can_validate_with_password() and not self.has_recent_authentication(): + return self.reauthenticate( + action='email-change', + message=_('You must re-authenticate to change your email address.'), + ) + return super().dispatch(request, *args, **kwargs) + def post(self, request, *args, **kwargs): if 'cancel' in request.POST: return utils_misc.redirect(request, 'account_management') @@ -1335,30 +1370,19 @@ class RegistrationCompletionView(CreateView): registration_completion = RegistrationCompletionView.as_view() -class AccountDeleteView(TemplateView): +class AccountDeleteView(RecentAuthenticationMixin, TemplateView): template_name = 'authentic2/accounts_delete_request.html' title = _('Request account deletion') - last_authentication_max_age = 600 # 10 minutes def dispatch(self, request, *args, **kwargs): if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT: return utils_misc.redirect(request, '..') if not self.request.user.email_verified and not self.has_recent_authentication(): - methods = [event['how'] for event in utils_misc.get_authentication_events(request)] - return utils_misc.login_require( - request, - token={ - 'action': 'account-delete', - 'message': _('You must re-authenticate to delete your account.'), - 'methods': methods, - }, + return self.reauthenticate( + action='account-delete', message=_('You must re-authenticate to delete your account.') ) return super().dispatch(request, *args, **kwargs) - def has_recent_authentication(self): - age = time.time() - utils_misc.last_authentication_event(request=self.request)['when'] - return age < self.last_authentication_max_age - def post(self, request, *args, **kwargs): if 'cancel' in request.POST: return utils_misc.redirect(request, 'account_management') diff --git a/tests/auth_fc/test_auth_fc.py b/tests/auth_fc/test_auth_fc.py index 6f899372..90f2b683 100644 --- a/tests/auth_fc/test_auth_fc.py +++ b/tests/auth_fc/test_auth_fc.py @@ -37,7 +37,7 @@ from authentic2_auth_fc import models from authentic2_auth_fc.backends import FcBackend from authentic2_auth_fc.utils import requests_retry_session -from ..utils import get_link_from_mail, login +from ..utils import assert_event, get_link_from_mail, login User = get_user_model() @@ -598,3 +598,37 @@ def test_update_fc_email(settings, app, franceconnect): assert User.objects.get(pk=user.pk).email == 'john.doe@example.com' assert User.objects.get(pk=user.pk).first_name == 'Ÿuñe' assert app.session['_auth_user_id'] == str(user.pk) + + +def test_change_email(settings, app, franceconnect, mailoutbox, freezer): + response = app.get('/login/?service=portail&next=/idp/') + response = response.click(href='callback') + response = franceconnect.handle_authorization(app, response.location, status=302) + freezer.move_to(datetime.timedelta(hours=1)) + redirect = app.get('/accounts/change-email/') + display_message_redirect = redirect.follow() + display_message_page = display_message_redirect.follow() + assert 'You must re-authenticate' in display_message_page + callback_url = display_message_page.pyquery('#a2-continue')[0].attrib['href'] + change_email_page = franceconnect.handle_authorization(app, callback_url, status=302).follow() + user = User.objects.get() + assert user.email == 'john.doe@example.com' + change_email_page.form.set('email', 'jane.doe@example.com') + redirect = change_email_page.form.submit() + assert_event( + 'user.email.change.request', + user=user, + session=app.session, + old_email='john.doe@example.com', + email='jane.doe@example.com', + ) + link = get_link_from_mail(mailoutbox[-1]) + app.get(link) + assert_event( + 'user.email.change', + user=user, + session=app.session, + old_email='john.doe@example.com', + email='jane.doe@example.com', + ) + assert User.objects.get().email == 'jane.doe@example.com' -- 2.34.1