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 16 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 @@ -708,6 +708,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["HTTP_USER_AGENT"]
varunsaral marked this conversation as resolved.
Show resolved Hide resolved

def generate_emailconfirmation_key(self, email):
key = get_random_string(64).lower()
return key
Expand Down Expand Up @@ -744,6 +747,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(
{
"current_site": get_current_site(self.request),
varunsaral marked this conversation as resolved.
Show resolved Hide resolved
"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
1 change: 1 addition & 0 deletions allauth/account/tests/test_change_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from unittest.mock import patch

from django.contrib.auth import get_user_model
from django.core import mail
varunsaral marked this conversation as resolved.
Show resolved Hide resolved
from django.urls import reverse

import pytest
Expand Down
17 changes: 17 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,20 @@ def test_confirm_logs_out_user(auth_client, settings, user, user_factory):
)
)
assert not auth_client.session.get(SESSION_KEY)


@patch("allauth.account.app_settings.EMAIL_NOTIFICATIONS", True)
def test_notification_on_email_remove(auth_client, user):
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},
**{
"HTTP_USER_AGENT": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
varunsaral marked this conversation as resolved.
Show resolved Hide resolved
}
)
assert resp.status_code == 302
assert len(mail.outbox) == 1
assert "Following email has been removed" in mail.outbox[0].body
49 changes: 49 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 @@ -322,3 +323,51 @@ def _create_user_and_login(self, usable_password=True):
def _password_set_or_change_redirect(self, urlname, usable_password):
self._create_user_and_login(usable_password)
return self.client.get(reverse(urlname))


@patch("allauth.account.app_settings.EMAIL_NOTIFICATIONS", True)
def test_notification_on_password_change(user_factory, client):
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",
},
**{
"HTTP_USER_AGENT": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
varunsaral marked this conversation as resolved.
Show resolved Hide resolved
}
)
assert len(mail.outbox) == 1
assert "Your password has been changed" in mail.outbox[0].body


@patch("allauth.account.app_settings.EMAIL_NOTIFICATIONS", True)
def test_notification_on_password_reset(user_factory, client, settings):
user = user_factory(
email="[email protected]",
password="password",
email_verified=True,
)

client.post(reverse("account_reset_password"), data={"email": user.email})
body = mail.outbox[0].body
url = body[body.find("/password/reset/") :].split()[0]
resp = client.get(url)
resp = client.post(
resp.url,
{"password1": "newpass123", "password2": "newpass123"},
**{
"HTTP_USER_AGENT": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
varunsaral marked this conversation as resolved.
Show resolved Hide resolved
}
)

assert len(mail.outbox) == 2
assert "Your password has been reset" in mail.outbox[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_removed",
varunsaral marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -698,11 +713,15 @@ def get_form_kwargs(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 @@ -754,14 +773,18 @@ def get_form_kwargs(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(PasswordSetView, self).form_valid(form)

def get_context_data(self, **kwargs):
Expand Down Expand Up @@ -913,6 +936,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)()
2 changes: 1 addition & 1 deletion allauth/mfa/migrations/0002_authenticator_timestamps.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Generated by Django 3.2.22 on 2023-11-06 12:04

from django.db import migrations, models
import django.utils.timezone
from django.db import migrations, models


class Migration(migrations.Migration):
Expand Down
56 changes: 55 additions & 1 deletion allauth/mfa/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

import django
from django.conf import settings
from django.core import mail
from django.urls import reverse

import pytest
from pytest_django.asserts import assertFormError

from allauth.account.authentication import AUTHENTICATION_METHODS_SESSION_KEY
from allauth.account.models import EmailAddress
from allauth.mfa import app_settings
from allauth.mfa import app_settings, signals
from allauth.mfa.adapter import get_adapter
from allauth.mfa.models import Authenticator

Expand Down Expand Up @@ -60,6 +61,7 @@ def test_activate_totp_with_unverified_email(
}


@patch("allauth.account.app_settings.EMAIL_NOTIFICATIONS", True)
varunsaral marked this conversation as resolved.
Show resolved Hide resolved
def test_activate_totp_success(
auth_client, totp_validation_bypass, user, reauthentication_bypass
):
Expand All @@ -71,7 +73,9 @@ def test_activate_totp_success(
{
"code": "123",
},
**{"HTTP_USER_AGENT": "test"},
)
assert len(mail.outbox) == 1
varunsaral marked this conversation as resolved.
Show resolved Hide resolved
varunsaral marked this conversation as resolved.
Show resolved Hide resolved
assert resp["location"] == reverse("mfa_view_recovery_codes")
assert Authenticator.objects.filter(
user=user, type=Authenticator.Type.TOTP
Expand Down Expand Up @@ -280,3 +284,53 @@ 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"]],
}


@patch("allauth.account.app_settings.EMAIL_NOTIFICATIONS", True)
def test_notification_on_mfa_activate_totp(
auth_client, reauthentication_bypass, totp_validation_bypass
):
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",
},
**{"HTTP_USER_AGENT": "test"},
)
assert len(mail.outbox) == 1
assert "Totp activated" in mail.outbox[0].subject
assert "Totp has been activated." in mail.outbox[0].body


@patch("allauth.account.app_settings.EMAIL_NOTIFICATIONS", True)
def test_notification_on_mfa_deactivate_totp(
auth_client, user_with_totp, user_password
):
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"), **{"HTTP_USER_AGENT": "test"}
)
assert len(mail.outbox) == 1
assert "Totp deactivated" in mail.outbox[0].subject
assert "Totp has been deactivated." in mail.outbox[0].body


@patch("allauth.account.app_settings.EMAIL_NOTIFICATIONS", True)
def test_notification_on_authenticator_reset(
auth_client, user_with_recovery_codes, user_password
):
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"], **{"HTTP_USER_AGENT": "test"})
assert len(mail.outbox) == 1
assert "Totp reset" in mail.outbox[0].subject
assert "Totp has been reset." in mail.outbox[0].body
7 changes: 7 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,7 @@ 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/totp_reset", self.request.user, {})
varunsaral marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -301,6 +301,9 @@ def get_requests_session(self):
)
return session

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
Loading
Loading