From 0240a654a33ed7b8c3dedc28d6b46087394415e3 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 22 Apr 2020 18:07:03 +0200 Subject: [PATCH 2/4] views: use one-time token for password reset (#41792) --- src/authentic2/compat/misc.py | 3 --- src/authentic2/urls.py | 2 +- src/authentic2/utils/__init__.py | 12 ++++++----- src/authentic2/views.py | 37 ++++++++++++++------------------ 4 files changed, 24 insertions(+), 30 deletions(-) diff --git a/src/authentic2/compat/misc.py b/src/authentic2/compat/misc.py index 47f47667..c0e90aa9 100644 --- a/src/authentic2/compat/misc.py +++ b/src/authentic2/compat/misc.py @@ -18,7 +18,6 @@ from datetime import datetime import inspect from django.conf import settings -from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.utils import six try: @@ -35,8 +34,6 @@ except ImportError: user_model_label = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') -default_token_generator = PasswordResetTokenGenerator() - if six.PY2: Base64Error = TypeError else: diff --git a/src/authentic2/urls.py b/src/authentic2/urls.py index e06250de..2195c41c 100644 --- a/src/authentic2/urls.py +++ b/src/authentic2/urls.py @@ -75,7 +75,7 @@ accounts_urlpatterns = [ name='password_change_done'), # Password reset - url(r'^password/reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + url(r'^password/reset/confirm/(?P[A-Za-z0-9_ -]+)/$', views.password_reset_confirm, name='password_reset_confirm'), url(r'^password/reset/$', diff --git a/src/authentic2/utils/__init__.py b/src/authentic2/utils/__init__.py index 4f287b75..a7463c54 100644 --- a/src/authentic2/utils/__init__.py +++ b/src/authentic2/utils/__init__.py @@ -794,16 +794,18 @@ def send_account_deletion_mail(request, user): def build_reset_password_url(user, request=None, next_url=None, set_random_password=True, sign_next_url=True): '''Build a reset password URL''' - from authentic2.compat.misc import default_token_generator + from authentic2.models import Token if set_random_password: user.set_password(uuid.uuid4().hex) user.save() - uid = urlsafe_base64_encode(force_bytes(user.pk)) - token = default_token_generator.make_token(user) + lifetime = settings.PASSWORD_RESET_TIMEOUT_DAYS * 3600 * 24 + # invalidate any token associated with this user + Token.objects.filter(kind='pw-reset', content__user=user.pk).delete() + token = Token.create('pw-reset', {'user': user.pk}, duration=lifetime) reset_url = make_url( 'password_reset_confirm', - kwargs={'uidb64': uid, 'token': token}, + kwargs={'token': token.uuid_b64url}, next_url=next_url, sign_next_url=sign_next_url, request=request, @@ -848,7 +850,7 @@ def send_password_reset_mail(user, template_names=None, request=None, legacy_body_templates=legacy_body_templates, per_ou_templates=True, **kwargs) logger.info(u'password reset request for user %s, email sent to %s ' - 'with token %s', user, user.email, token[:9]) + 'with token %s', user, user.email, token.uuid) def batch(iterable, size): diff --git a/src/authentic2/views.py b/src/authentic2/views.py index d40c3903..07392080 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -54,7 +54,6 @@ from django.forms import CharField from django.http import HttpResponseBadRequest from django.template import loader -from authentic2.compat.misc import default_token_generator from . import (utils, app_settings, decorators, constants, models, cbv, hooks, validators) from .utils import switch_user @@ -710,27 +709,24 @@ class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView): ] def dispatch(self, request, *args, **kwargs): - validlink = True - uidb64 = kwargs['uidb64'] - self.token = token = kwargs['token'] + token = kwargs['token'].replace(' ', '') + try: + self.token = models.Token.use('pw-reset', token, delete=False) + except models.Token.DoesNotExist: + messages.warning(request, _('Password reset token is unknown or expired')) + return utils.redirect(request, self.get_success_url()) + except (TypeError, ValueError): + messages.warning(request, _('Password reset token is invalid')) + return utils.redirect(request, self.get_success_url()) - UserModel = get_user_model() - # checked by URLconf - assert uidb64 is not None and token is not None + uid = self.token.content['user'] try: - uid = urlsafe_base64_decode(uidb64) # use authenticate to eventually get an LDAPUser - self.user = utils.authenticate(request, user=UserModel._default_manager.get(pk=uid)) - except (TypeError, ValueError, OverflowError, - UserModel.DoesNotExist): - validlink = False + self.user = utils.authenticate(request, user=User._default_manager.get(pk=uid)) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): messages.warning(request, _('User not found')) - - if validlink and not default_token_generator.check_token(self.user, token): - validlink = False - messages.warning(request, _('You reset password link is invalid or has expired')) - if not validlink: return utils.redirect(request, self.get_success_url()) + can_reset_password = utils.get_user_flag(user=self.user, name='can_reset_password', default=self.user.has_usable_password()) @@ -739,8 +735,7 @@ class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView): request, _('It\'s not possible to reset your password. Please contact an administrator.')) return utils.redirect(request, self.get_success_url()) - return super(PasswordResetConfirmView, self).dispatch(request, *args, - **kwargs) + return super(PasswordResetConfirmView, self).dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super(PasswordResetConfirmView, self).get_context_data(**kwargs) @@ -760,8 +755,8 @@ class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView): form.save() hooks.call_hooks('event', name='password-reset-confirm', user=form.user, token=self.token, form=form) - logger.info(u'password reset for user %s with token %r', - self.user, self.token[:9]) + logger.info(u'password reset for user %s with token %r', self.user, self.token.uuid) + self.token.delete() return self.finish() def finish(self): -- 2.20.1