diff --git a/bid_main/email.py b/bid_main/email.py index 66e0305..0bf5491 100644 --- a/bid_main/email.py +++ b/bid_main/email.py @@ -270,3 +270,15 @@ def construct_new_user_session(user, session_data): subject = "Security alert: new sign-in" return email_body_txt, subject + + +def construct_password_changed(user): + context = { + "user": user, + } + email_body_txt = loader.render_to_string( + "bid_main/emails/password_changed.txt", context + ) + subject = "Security alert: password changed" + + return email_body_txt, subject diff --git a/bid_main/forms.py b/bid_main/forms.py index e3ae405..fc04797 100644 --- a/bid_main/forms.py +++ b/bid_main/forms.py @@ -4,6 +4,7 @@ import pathlib from django import forms from django.conf import settings from django.contrib.auth import forms as auth_forms, password_validation, authenticate +from django.db import transaction from django.forms import ValidationError from django.template import defaultfilters from django.utils import timezone @@ -11,6 +12,7 @@ from django.utils.translation import gettext_lazy as _ from .models import User, OAuth2Application from .recaptcha import check_recaptcha +import bid_main.tasks log = logging.getLogger(__name__) @@ -308,7 +310,14 @@ class UserProfileForm(BootstrapModelFormMixin, forms.ModelForm): class PasswordChangeForm(BootstrapModelFormMixin, auth_forms.PasswordChangeForm): - """Password change form with Bootstrap CSS classes.""" + """Password change form with Bootstrap CSS classes. Sends a password changed email.""" + + @transaction.atomic + def save(self, *args, **kwargs): + user = super().save(*args, **kwargs) + if user.has_confirmed_email: + bid_main.tasks.send_password_changed_email(user_pk=user.pk) + return user class PasswordResetForm(BootstrapModelFormMixin, auth_forms.PasswordResetForm): @@ -319,6 +328,17 @@ class PasswordResetForm(BootstrapModelFormMixin, auth_forms.PasswordResetForm): self.fields["email"].widget.attrs["placeholder"] = "Your email address" +class SetPasswordForm(auth_forms.SetPasswordForm): + """Sends a password changed email.""" + + @transaction.atomic + def save(self, *args, **kwargs): + user = super().save(*args, **kwargs) + if user.has_confirmed_email: + bid_main.tasks.send_password_changed_email(user_pk=user.pk) + return user + + class AppRevokeTokensForm(forms.Form): """Form for revoking OAuth tokens for a specific application.""" diff --git a/bid_main/tasks.py b/bid_main/tasks.py index ebcfe17..1e8e86c 100644 --- a/bid_main/tasks.py +++ b/bid_main/tasks.py @@ -26,3 +26,20 @@ def send_new_user_session_email(user_pk, session_data): from_email=None, # just use the configured default From-address. recipient_list=[email], ) + + +@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING}) +def send_password_changed_email(user_pk): + user = User.objects.get(pk=user_pk) + log.info("sending a password change email for account %s", user.pk) + + # sending only a text/plain email to reduce the room for look-alike phishing emails + email_body_txt, subject = bid_main.email.construct_password_changed(user) + + email = user.email + send_mail( + subject=subject, + message=email_body_txt, + from_email=None, # just use the configured default From-address. + recipient_list=[email], + ) diff --git a/bid_main/templates/bid_main/emails/password_changed.txt b/bid_main/templates/bid_main/emails/password_changed.txt new file mode 100644 index 0000000..c6e8146 --- /dev/null +++ b/bid_main/templates/bid_main/emails/password_changed.txt @@ -0,0 +1,11 @@ +{% autoescape off %} +Dear {{ user.full_name|default:user.email }}! + +Your password for Blender ID account {{ user.email }} has been changed. + +If this wasn't done by you, please reset your password. + +-- +Kind regards, +The Blender Web Team +{% endautoescape %} diff --git a/bid_main/tests/test_password_change.py b/bid_main/tests/test_password_change.py new file mode 100644 index 0000000..6d37da7 --- /dev/null +++ b/bid_main/tests/test_password_change.py @@ -0,0 +1,74 @@ +from unittest.mock import patch +import re + +from django.core import mail +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from django.utils.crypto import get_random_string + +from bid_main.tests.factories import UserFactory +import bid_main.tasks + + +@patch( + 'bid_main.tasks.send_password_changed_email', + new=bid_main.tasks.send_password_changed_email.task_function, +) +@patch( + 'django.contrib.auth.base_user.AbstractBaseUser.check_password', + new=lambda _, pwd: pwd == 'hunter2', +) +class PasswordChangedEmailTest(TestCase): + def test_password_change_sends_email(self): + user = UserFactory(confirmed_email_at=timezone.now()) + self.client.force_login(user) + new_password = get_random_string(16) + response = self.client.post( + reverse('bid_main:password_change'), + { + 'old_password': 'hunter2', + 'new_password1': new_password, + 'new_password2': new_password, + } + ) + self.assertEqual(response.status_code, 302) + sent_email = mail.outbox.pop() + self.assertRegex(sent_email.body, 'Your password .* has been changed') + + def test_password_reset_sends_email(self): + user = UserFactory(confirmed_email_at=timezone.now()) + response = self.client.post(reverse('bid_main:password_reset'), {'email': user.email}) + self.assertEqual(response.status_code, 302) + password_reset_email = mail.outbox.pop() + link_re = re.compile(r'^(https?://.*/reset/.*/)$', re.MULTILINE) + match = link_re.search(password_reset_email.body) + password_reset_link = match.group(1) + + new_password = get_random_string(16) + response = self.client.get(password_reset_link) + self.assertEqual(response.status_code, 302) + submit_url = response['Location'] + + # mess up the input, don't get a notification + response = self.client.post( + submit_url, + { + 'new_password1': new_password, + 'new_password2': 'some other string', + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + # submit correctly, get a notification + response = self.client.post( + submit_url, + { + 'new_password1': new_password, + 'new_password2': new_password, + }, + ) + self.assertEqual(response.status_code, 302) + sent_email = mail.outbox.pop() + self.assertRegex(sent_email.body, 'Your password .* has been changed') diff --git a/bid_main/urls.py b/bid_main/urls.py index 7d5ca44..3fdee9d 100644 --- a/bid_main/urls.py +++ b/bid_main/urls.py @@ -76,7 +76,8 @@ urlpatterns = [ re_path( r"^reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,33})/$", auth_views.PasswordResetConfirmView.as_view( - success_url=reverse_lazy("bid_main:password_reset_complete") + form_class=forms.SetPasswordForm, + success_url=reverse_lazy("bid_main:password_reset_complete"), ), name="password_reset_confirm", ),