diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 00b54d4b2..e6ee11f3d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,7 +18,8 @@ Core: mode [#3799, #3817]. - Changed format for elements send via autoupdate [#3926]. - Add a change-id system to get only new elements [#3938]. - - Switch from Yarn back to npm. + - Switch from Yarn back to npm [#3964]. + - Added password reset link (password reset via email) [#3914]. Motions: - Option to customly sort motions [#3894]. diff --git a/openslides/users/urls.py b/openslides/users/urls.py index 872b9245d..3039be7d8 100644 --- a/openslides/users/urls.py +++ b/openslides/users/urls.py @@ -20,4 +20,12 @@ urlpatterns = [ url(r'^setpassword/$', views.SetPasswordView.as_view(), name='user_setpassword'), + + url(r'^reset-password/$', + views.PasswordResetView.as_view(), + name='user_reset_password'), + + url(r'^reset-password-confirm/$', + views.PasswordResetConfirmView.as_view(), + name='password_reset_confirm'), ] diff --git a/openslides/users/views.py b/openslides/users/views.py index 84fa779bf..c984255e0 100644 --- a/openslides/users/views.py +++ b/openslides/users/views.py @@ -1,4 +1,5 @@ import smtplib +import textwrap from typing import List from asgiref.sync import async_to_sync @@ -10,11 +11,15 @@ from django.contrib.auth import ( ) from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.password_validation import validate_password +from django.contrib.auth.tokens import default_token_generator +from django.contrib.sites.shortcuts import get_current_site from django.core import mail from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction -from django.utils.encoding import force_text +from django.utils.encoding import force_bytes, force_text +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.utils.translation import ugettext as _ +from django.template import loader from ..core.config import config from ..core.signals import permission_change @@ -524,3 +529,113 @@ class SetPasswordView(APIView): else: raise ValidationError({'detail': _('Old password does not match.')}) return super().post(request, *args, **kwargs) + + +class PasswordResetView(APIView): + """ + Users can send an email to themselves to get a password reset email. + + Send POST request with {'email': } and all users with this + address will receive an email (means Django sends one or more emails to + this address) with a one-use only link. + """ + http_method_names = ['post'] + use_https = False #TODO: Do we use https? + + def post(self, request, *args, **kwargs): + """ + Loop over all users and send emails. + """ + to_email = request.data.get('email') + for user in self.get_users(to_email): + current_site = get_current_site(request) + site_name = current_site.name + context = { + 'email': to_email, + 'site_name': site_name, + 'protocol': 'https' if self.use_https else 'http', + 'domain': current_site.domain, + 'path': '/reset-password-confirm/', + 'user_id': urlsafe_base64_encode(force_bytes(user.pk)).decode(), + 'token': default_token_generator.make_token(user), + 'username': user.get_username(), + } + # Send a django.core.mail.EmailMessage to `to_email`. + subject = _('Password reset for {}').format(site_name) + subject = ''.join(subject.splitlines()) + body = self.get_email_body(**context) + from_email = None # TODO: Add nice from_email here. + email_message = mail.EmailMessage(subject, body, from_email, [to_email]) + email_message.send() + return super().post(request, *args, **kwargs) + + def get_users(self, email): + """Given an email, return matching user(s) who should receive a reset. + + This allows subclasses to more easily customize the default policies + that prevent inactive users and users with unusable passwords from + resetting their password. + """ + active_users = User.objects.filter(**{ + 'email__iexact': email, + 'is_active': True, + }) + return (u for u in active_users if u.has_usable_password()) + + def get_email_body(self, **context): + """ + Add context to email template and return the complete body. + """ + return textwrap.dedent( + """ + You're receiving this email because you requested a password reset for your user account at {site_name}. + + Please go to the following page and choose a new password: + + {protocol}://{domain}{path}?user_id={user_id}&token={token} + + Your username, in case you've forgotten: {username} + + Thanks for using our site! + + The {site_name} team. + """ + ).format(**context) + + +class PasswordResetConfirmView(APIView): + """ + View to reset the password. + + Send POST request with {'user_id': , 'token': , + 'password' } to set password of this user to the new one. + """ + http_method_names = ['post'] + + def post(self, request, *args, **kwargs): + uidb64 = request.data.get('user_id') + token = request.data.get('token') + password = request.data.get('password') + if not (uidb64 and token and password): + raise ValidationError({'detail': _('You have to provide user_id, token and password.')}) + user = self.get_user(uidb64) + if user is None: + raise ValidationError({'detail': _('User does not exist.')}) + if not default_token_generator.check_token(user, token): + raise ValidationError({'detail': _('Invalid token.')}) + try: + validate_password(password, user=user) + except DjangoValidationError as errors: + raise ValidationError({'detail': ' '.join(errors)}) + user.set_password(password) + user.save() + return super().post(request, *args, **kwargs) + + def get_user(self, uidb64): + try: + # urlsafe_base64_decode() decodes to bytestring + uid = urlsafe_base64_decode(uidb64).decode() + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + user = None + return user