Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(accounts): Add notifications for account events #3458

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions allauth/account/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,9 @@ def get_client_ip(self, request):
ip = request.META.get("REMOTE_ADDR")
return ip

def get_http_user_agent(self, request):
return request.META.get("HTTP_USER_AGENT", "Unspecified")

def generate_emailconfirmation_key(self, email):
key = get_random_string(64).lower()
return key
Expand Down Expand Up @@ -758,6 +761,22 @@ def get_reauthentication_methods(self, user):
)
return ret

def send_notification_mail(self, template_prefix, user, context):
from allauth.account.models import EmailAddress

if app_settings.EMAIL_NOTIFICATIONS:
context.update(
{
"site": get_current_site(self.request),
"timestamp": timezone.now(),
"ip": self.get_client_ip(self.request),
"user_agent": self.get_http_user_agent(self.request),
}
)
email = EmailAddress.objects.get_primary(user)
if email:
self.send_mail(template_prefix, email.email, context)


def get_adapter(request=None):
return import_attribute(app_settings.ADAPTER)(request)
4 changes: 4 additions & 0 deletions allauth/account/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,10 @@ def EMAIL_UNKNOWN_ACCOUNTS(self):
def REAUTHENTICATION_TIMEOUT(self):
return self._setting("REAUTHENTICATION_TIMEOUT", 300)

@property
def EMAIL_NOTIFICATIONS(self):
return self._setting("EMAIL_NOTIFICATIONS", False)

@property
def REAUTHENTICATION_REQUIRED(self):
return self._setting("REAUTHENTICATION_REQUIRED", False)
Expand Down
13 changes: 13 additions & 0 deletions allauth/account/tests/test_confirm_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,16 @@ def test_confirm_logs_out_user(auth_client, settings, user, user_factory):
)
)
assert not auth_client.session.get(SESSION_KEY)


def test_notification_on_email_remove(auth_client, user, settings, mailoutbox):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
secondary = EmailAddress.objects.create(
email="[email protected]", user=user, verified=False, primary=False
)
resp = auth_client.post(
reverse("account_email"), {"action_remove": "", "email": secondary.email}
)
assert resp.status_code == 302
assert len(mailoutbox) == 1
assert "Following email has been removed" in mailoutbox[0].body
40 changes: 40 additions & 0 deletions allauth/account/tests/test_reset_password.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from unittest.mock import patch

from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
Expand Down Expand Up @@ -294,3 +295,42 @@ def _create_user_and_login(self, usable_password=True):
user = self._create_user(password=password)
self.client.force_login(user)
return user


def test_notification_on_password_change(user_factory, client, settings, mailoutbox):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
user = user_factory(
email="[email protected]",
password="password",
email_verified=True,
)
client.force_login(user)

client.post(
reverse("account_change_password"),
data={
"oldpassword": "password",
"password1": "change_password",
"password2": "change_password",
},
)
assert len(mailoutbox) == 1
assert "Your password has been changed" in mailoutbox[0].body


def test_notification_on_password_reset(user_factory, client, settings, mailoutbox):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
user = user_factory(
email="[email protected]",
password="password",
email_verified=True,
)

client.post(reverse("account_reset_password"), data={"email": user.email})
body = mailoutbox[0].body
url = body[body.find("/password/reset/") :].split()[0]
resp = client.get(url)
resp = client.post(resp.url, {"password1": "newpass123", "password2": "newpass123"})

assert len(mailoutbox) == 2
assert "Your password has been reset" in mailoutbox[1].body
34 changes: 30 additions & 4 deletions allauth/account/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,8 @@ def get_form_kwargs(self):

def form_valid(self, form):
email_address = form.save(self.request)
get_adapter(self.request).add_message(
adapter = get_adapter(self.request)
adapter.add_message(
self.request,
messages.INFO,
"account/messages/email_confirmation_sent.txt",
Expand Down Expand Up @@ -572,6 +573,11 @@ def _action_remove(self, request, *args, **kwargs):
"account/messages/email_deleted.txt",
{"email": email_address.email},
)
adapter.send_notification_mail(
"account/email/email_deleted",
request.user,
{"email": email_address.email},
)
return HttpResponseRedirect(self.get_success_url())

def _action_primary(self, request, *args, **kwargs):
Expand Down Expand Up @@ -602,7 +608,8 @@ def _action_primary(self, request, *args, **kwargs):
except EmailAddress.DoesNotExist:
from_email_address = None
email_address.set_as_primary()
get_adapter().add_message(
adapter = get_adapter()
adapter.add_message(
request,
messages.SUCCESS,
"account/messages/primary_email_set.txt",
Expand All @@ -614,6 +621,14 @@ def _action_primary(self, request, *args, **kwargs):
from_email_address=from_email_address,
to_email_address=email_address,
)
adapter.send_notification_mail(
"account/email/email_changed",
request.user,
{
"from_emailaddress": from_email_address.email,
"to_emailaddress": email_address.email,
},
)
return HttpResponseRedirect(self.get_success_url())

def get_context_data(self, **kwargs):
Expand Down Expand Up @@ -691,11 +706,15 @@ def get_success_url(self):
def form_valid(self, form):
form.save()
logout_on_password_change(self.request, form.user)
get_adapter(self.request).add_message(
adapter = get_adapter(self.request)
adapter.add_message(
self.request,
messages.SUCCESS,
"account/messages/password_changed.txt",
)
adapter.send_notification_mail(
"account/email/password_changed", self.request.user, {}
)
signals.password_changed.send(
sender=self.request.user.__class__,
request=self.request,
Expand Down Expand Up @@ -746,14 +765,18 @@ def get_success_url(self):
def form_valid(self, form):
form.save()
logout_on_password_change(self.request, form.user)
get_adapter(self.request).add_message(
adapter = get_adapter(self.request)
adapter.add_message(
self.request, messages.SUCCESS, "account/messages/password_set.txt"
)
signals.password_set.send(
sender=self.request.user.__class__,
request=self.request,
user=self.request.user,
)
adapter.send_notification_mail(
"account/email/password_set", self.request.user, {}
)
return super().form_valid(form)

def get_context_data(self, **kwargs):
Expand Down Expand Up @@ -905,6 +928,9 @@ def form_valid(self, form):
request=self.request,
user=self.reset_user,
)
adapter.send_notification_mail(
"account/email/password_reset", self.reset_user, {}
)

if app_settings.LOGIN_ON_PASSWORD_RESET:
return perform_login(
Expand Down
4 changes: 4 additions & 0 deletions allauth/mfa/adapter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.utils.translation import gettext_lazy as _

from allauth import app_settings as allauth_settings
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.utils import user_email, user_username
from allauth.core import context
from allauth.mfa import app_settings
Expand Down Expand Up @@ -68,6 +69,9 @@ def decrypt(self, encrypted_text: str) -> str:
def can_delete_authenticator(self, authenticator):
return True

def send_notification_mail(self, *args, **kwargs):
return get_account_adapter().send_notification_mail(*args, **kwargs)


def get_adapter():
return import_attribute(app_settings.ADAPTER)()
47 changes: 47 additions & 0 deletions allauth/mfa/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,50 @@ def test_cannot_deactivate_totp(auth_client, user_with_totp, user_password):
assert resp.context["form"].errors == {
"__all__": [get_adapter().error_messages["cannot_delete_authenticator"]],
}


def test_notification_on_mfa_activate_totp(
auth_client, reauthentication_bypass, totp_validation_bypass, settings, mailoutbox
):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
with reauthentication_bypass():
resp = auth_client.get(reverse("mfa_activate_totp"))
with totp_validation_bypass():
resp = auth_client.post(
reverse("mfa_activate_totp"),
{
"code": "123",
},
)
assert len(mailoutbox) == 1
assert "Authenticator App Activated" in mailoutbox[0].subject
assert "Authenticator App has been activated." in mailoutbox[0].body


def test_notification_on_mfa_deactivate_totp(
auth_client, user_with_totp, user_password, settings, mailoutbox
):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
resp = auth_client.get(reverse("mfa_deactivate_totp"))
assert resp.status_code == 302
assert resp["location"].startswith(reverse("account_reauthenticate"))
resp = auth_client.post(resp["location"], {"password": user_password})
assert resp.status_code == 302
resp = auth_client.post(reverse("mfa_deactivate_totp"))
assert len(mailoutbox) == 1
assert "Authenticator App deactivated" in mailoutbox[0].subject
assert "Authenticator App has been deactivated." in mailoutbox[0].body


def test_notification_on_authenticator_reset(
auth_client, user_with_recovery_codes, user_password, settings, mailoutbox
):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
resp = auth_client.get(reverse("mfa_generate_recovery_codes"))
assert resp["location"].startswith(reverse("account_reauthenticate"))
resp = auth_client.post(resp["location"], {"password": user_password})
assert resp.status_code == 302
resp = auth_client.post(resp["location"])
assert len(mailoutbox) == 1
assert "Recovery codes generated" in mailoutbox[0].subject
assert "Recovery codes has been generated" in mailoutbox[0].body
9 changes: 9 additions & 0 deletions allauth/mfa/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ def form_valid(self, form):
adapter.add_message(
self.request, messages.SUCCESS, "mfa/messages/totp_activated.txt"
)
adapter.send_notification_mail(
"mfa/email/totp_activated", self.request.user, {}
)
return super().form_valid(form)


Expand Down Expand Up @@ -212,6 +215,9 @@ def form_valid(self, form):
adapter.add_message(
self.request, messages.SUCCESS, "mfa/messages/totp_deactivated.txt"
)
adapter.send_notification_mail(
"mfa/email/totp_deactivated", self.request.user, {}
)
return super().form_valid(form)


Expand All @@ -236,6 +242,9 @@ def form_valid(self, form):
signals.authenticator_reset.send(
sender=Authenticator, user=self.request.user, authenticator=rc_auth.instance
)
adapter.send_notification_mail(
"mfa/email/recovery_codes_generated", self.request.user, {}
)
return super().form_valid(form)

def get_context_data(self, **kwargs):
Expand Down
3 changes: 3 additions & 0 deletions allauth/socialaccount/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,9 @@ def can_authenticate_by_email(self, login, email):
)
return ret

def send_notification_mail(self, *args, **kwargs):
return get_account_adapter().send_notification_mail(*args, **kwargs)


def get_adapter(request=None):
return import_attribute(app_settings.ADAPTER)(request)
3 changes: 3 additions & 0 deletions allauth/socialaccount/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,6 @@ def save(self):
signals.social_account_removed.send(
sender=SocialAccount, request=self.request, socialaccount=account
)
get_adapter().send_notification_mail(
"socialaccount/email/account_disconnected", self.request.user, {}
)
4 changes: 4 additions & 0 deletions allauth/socialaccount/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ def connect(self, request, user):
sender=SocialLogin, request=request, sociallogin=self
)

get_adapter().send_notification_mail(
"socialaccount/email/account_added", self.user, {}
)

def serialize(self):
serialize_instance = get_adapter().serialize_instance
ret = dict(
Expand Down
16 changes: 16 additions & 0 deletions allauth/templates/account/email/base_notification.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% extends "account/email/base_message.txt" %}
{% load account %}
{% load i18n %}

{% block content %}{% autoescape off %}{% user_display user as user_display %}{% blocktrans with site_name=current_site.name %}You are receiving this mail because the following change was made to your account:{% endblocktrans %}

{% block notification_message %}
{% endblock notification_message%}

{% blocktrans with site_name=current_site.name %}
Change details:
- When: {{timestamp}}
- From what IP address : {{ip}}
- The browser that was used : {{user_agent}}

If this change was not made by you, please contact us immediately at support of {{site_name}}.{% endblocktrans %}{% endautoescape %}{% endblock %}
4 changes: 4 additions & 0 deletions allauth/templates/account/email/email_changed_message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends "account/email/base_notification.txt" %}
{% load i18n %}

{% block notification_message %}{% blocktrans %}Your email has been changed.{% endblocktrans %}{% endblock notification_message %}
4 changes: 4 additions & 0 deletions allauth/templates/account/email/email_changed_subject.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Email Changed{% endblocktrans %}
{% endautoescape %}
4 changes: 4 additions & 0 deletions allauth/templates/account/email/email_confirm_message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends "account/email/base_notification.txt" %}
{% load i18n %}

{% block notification_message %}{% blocktrans %}Your email has been confirmed.{% endblocktrans %}{% endblock notification_message %}
4 changes: 4 additions & 0 deletions allauth/templates/account/email/email_confirm_subject.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Email Confirmation{% endblocktrans %}
{% endautoescape %}
4 changes: 4 additions & 0 deletions allauth/templates/account/email/email_deleted_message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends "account/email/base_notification.txt" %}
{% load i18n %}

{% block notification_message %}{% blocktrans %}Following email has been removed {{email}}.{% endblocktrans %}{% endblock notification_message %}
4 changes: 4 additions & 0 deletions allauth/templates/account/email/email_deleted_subject.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Email Removed{% endblocktrans %}
{% endautoescape %}
4 changes: 4 additions & 0 deletions allauth/templates/account/email/password_changed_message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends "account/email/base_notification.txt" %}
{% load i18n %}

{% block notification_message %}{% blocktrans %}Your password has been changed.{% endblocktrans %}{% endblock notification_message %}
4 changes: 4 additions & 0 deletions allauth/templates/account/email/password_changed_subject.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Password Changed{% endblocktrans %}
{% endautoescape %}
4 changes: 4 additions & 0 deletions allauth/templates/account/email/password_reset_message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends "account/email/base_notification.txt" %}
{% load i18n %}

{% block notification_message %}{% blocktrans %}Your password has been reset.{% endblocktrans %}{% endblock notification_message %}
4 changes: 4 additions & 0 deletions allauth/templates/account/email/password_reset_subject.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Password Reset{% endblocktrans %}
{% endautoescape %}
Loading
Loading