Send a email notification when a password was changed or reset #93588
@ -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
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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],
|
||||
)
|
||||
|
11
bid_main/templates/bid_main/emails/password_changed.txt
Normal file
11
bid_main/templates/bid_main/emails/password_changed.txt
Normal 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 %}
|
74
bid_main/tests/test_password_change.py
Normal file
74
bid_main/tests/test_password_change.py
Normal 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')
|
@ -76,7 +76,8 @@ urlpatterns = [
|
||||
re_path(
|
||||
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(
|
||||
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",
|
||||
),
|
||||
|
Loading…
Reference in New Issue
Block a user