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"
|
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
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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],
|
||||||
|
)
|
||||||
|
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 %}
|
60
bid_main/tests/test_password_change.py
Normal file
60
bid_main/tests/test_password_change.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
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)
|
||||||
|
response = self.client.post(
|
||||||
|
response['Location'],
|
||||||
|
{
|
||||||
|
'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(
|
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",
|
||||||
),
|
),
|
||||||
|
Loading…
Reference in New Issue
Block a user