Send a email notification when a password was changed or reset #93588

Merged
Oleg-Komarov merged 2 commits from password-changed-email into main 2024-08-05 16:45:46 +02:00
6 changed files with 137 additions and 2 deletions

View File

@ -270,3 +270,15 @@ def construct_new_user_session(user, session_data):
subject = "Security alert: new sign-in" subject = "Security alert: new sign-in"
return email_body_txt, subject 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

View File

@ -4,6 +4,7 @@ import pathlib
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import forms as auth_forms, password_validation, authenticate from django.contrib.auth import forms as auth_forms, password_validation, authenticate
from django.db import transaction
from django.forms import ValidationError from django.forms import ValidationError
from django.template import defaultfilters from django.template import defaultfilters
from django.utils import timezone from django.utils import timezone
@ -11,6 +12,7 @@ from django.utils.translation import gettext_lazy as _
from .models import User, OAuth2Application from .models import User, OAuth2Application
from .recaptcha import check_recaptcha from .recaptcha import check_recaptcha
import bid_main.tasks
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -308,7 +310,14 @@ class UserProfileForm(BootstrapModelFormMixin, forms.ModelForm):
class PasswordChangeForm(BootstrapModelFormMixin, auth_forms.PasswordChangeForm): 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): 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" 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): class AppRevokeTokensForm(forms.Form):
"""Form for revoking OAuth tokens for a specific application.""" """Form for revoking OAuth tokens for a specific application."""

View File

@ -26,3 +26,20 @@ def send_new_user_session_email(user_pk, session_data):
from_email=None, # just use the configured default From-address. from_email=None, # just use the configured default From-address.
recipient_list=[email], 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],
)

View File

@ -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 %}

View File

@ -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')

View File

@ -76,7 +76,8 @@ urlpatterns = [
re_path( re_path(
r"^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,33})/$", r"^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,33})/$",
auth_views.PasswordResetConfirmView.as_view( 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", name="password_reset_confirm",
), ),