blender-id/bid_main/email.py
Oleg Komarov 86044e12b7 Send a email notification when a password was changed or reset
Reviewed-on: #93588
Reviewed-by: Anna Sirota <annasirota@noreply.localhost>
2024-08-05 16:45:45 +02:00

285 lines
9.2 KiB
Python

import base64
import binascii
import datetime
import enum
import hashlib
import hmac
import json
import logging
import smtplib
import dateutil.parser
from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
from django.core.mail import send_mail
from django.dispatch import receiver
from django.template import loader
from django.urls import reverse
from django.utils import timezone
from bid_api import signals as api_signals
log = logging.getLogger(__name__)
@receiver(api_signals.user_email_changed)
def user_email_changed(sender, signal, *, user, old_email, **kwargs):
"""Sends out an email to the old & new address."""
email_body_html, email_body_txt, subject = construct_email_changed_mail(
user, old_email
)
try:
send_mail(
subject,
message=email_body_txt,
html_message=email_body_html,
from_email=None, # just use the configured default From-address.
recipient_list=[user.email, old_email],
fail_silently=False,
)
except (smtplib.SMTPException, OSError):
log.exception(
"error sending email-changed notification to %s and %s",
user.email,
old_email,
)
else:
log.info("sent email-changed notification to %s and %s", user.email, old_email)
def construct_email_changed_mail(user, old_email) -> (str, str, str):
# Construct the link to Blender ID dynamically, to prevent hard-coding it in the email.
# TODO(Sybren): move this to a more suitable spot so we can use it elsewhere too.
domain = get_current_site(None).domain
url = reverse("bid_main:index")
context = {
"user": user,
"old_email": old_email,
"blender_id": f"https://{domain}{url}",
"subject": "Blender ID email change",
}
email_body_html = loader.render_to_string(
"bid_main/emails/user_email_changed.html", context
)
email_body_txt = loader.render_to_string(
"bid_main/emails/user_email_changed.txt", context
)
return email_body_html, email_body_txt, context["subject"]
def send_verify_address(user, scheme: str, extra: dict = None) -> bool:
"""Send out an email with address verification link.
:param user: the user object whose email needs verification
:param scheme: either 'http' or 'https', for link generation.
:param extra: extra payload to store in the link JSON.
:returns: True when mail was sent successfully, False otherwise.
The actual error (if any) is logged.
"""
email_body_html, email_body_txt, subject = construct_verify_address(
user, scheme, extra
)
email = user.email_to_confirm
try:
send_mail(
subject,
message=email_body_txt,
html_message=email_body_html,
from_email=None, # just use the configured default From-address.
recipient_list=[email],
fail_silently=False,
)
except (smtplib.SMTPException, OSError):
log.exception("error sending address verification mail to %s", email)
return False
log.info("sent address verification mail to %s", email)
return True
def send_deletion_request_received(user) -> bool:
"""Send out an email confirming tha an account deletion request was received.
:param user: the user deletion was requested for
:param scheme: either 'http' or 'https', for link generation.
:returns: True when mail was sent successfully, False otherwise.
The actual error (if any) is logged.
"""
email_body_html, email_body_txt, subject = construct_deletion_request_received(user)
email = user.email
try:
send_mail(
subject,
message=email_body_txt,
html_message=email_body_html,
from_email=None, # just use the configured default From-address.
recipient_list=[email],
fail_silently=False,
)
except (smtplib.SMTPException, OSError):
log.exception("error sending mail about deletion of account %s", user.pk)
return False
log.info("sent mail about deletion of account %s", user.pk)
return True
def _email_verification_hmac(payload: bytes) -> hmac.HMAC:
return hmac.new(settings.SECRET_KEY.encode(), payload, hashlib.sha1)
def construct_verify_address(user, scheme: str, extra: dict = None) -> (str, str, str):
"""Construct the mail to verify an email address.
:param user: the user object whose email needs verification
:param scheme: either 'http' or 'https', for link generation.
:param extra: extra payload to store in the link JSON.
:returns: a tuple (html, text, subject) with the email contents.
"""
# Construct the link to Blender ID dynamically, to prevent hard-coding it in the email.
# TODO(Sybren): move this to a more suitable spot so we can use it elsewhere too.
domain = get_current_site(None).domain
# Construct an expiring URL that we can verify later. Doing it with an
# HMAC makes it possible to verify without having to save anything to
# the database.
expire = timezone.now() + datetime.timedelta(hours=13)
verification_payload = {
"e": user.email_to_confirm,
"x": expire.isoformat(timespec="minutes"),
**(extra or {}),
}
info = json.dumps(verification_payload).encode()
b64info = base64.urlsafe_b64encode(info)
hmac_ob = _email_verification_hmac(b64info)
url = reverse(
"bid_main:confirm-email-verified",
kwargs={"info": b64info.decode("ascii"), "hmac": hmac_ob.hexdigest()},
)
context = {
"user": user,
"url": f"{scheme}://{domain}{url}",
"subject": "Blender ID email verification",
}
log.debug("Sending email confirm link %s to %s", url, user.email)
email_body_html = loader.render_to_string(
"bid_main/emails/confirm_email.html", context
)
email_body_txt = loader.render_to_string(
"bid_main/emails/confirm_email.txt", context
)
return email_body_html, email_body_txt, context["subject"]
def construct_deletion_request_received(user) -> (str, str, str):
"""Construct the mail about account deletion request.
:param user: the user deletion was requested for
:returns: a tuple (html, text, subject) with the email contents.
"""
context = {
"user": user,
"subject": "Blender ID account deletion",
}
log.debug("Sending email about deletion of account %s", user.pk)
email_body_html = loader.render_to_string(
"bid_main/emails/deletion_request_received.html", context
)
email_body_txt = loader.render_to_string(
"bid_main/emails/deletion_request_received.txt", context
)
return email_body_html, email_body_txt, context["subject"]
class VerificationResult(enum.Enum):
OK = 0
EXPIRED = 1
INVALID = 2 # invalid Base64, HMAC, JSON
OTHER_ACCOUNT = 3 # for another email address
def check_verification_payload(
info_b64: str, expected_hmac: str, expected_email: str
) -> (VerificationResult, dict):
"""Check the HMAC and decode the info to check for expiry.
:returns: the verification result and the info that was JSON+b64 encoded.
The latter is just an empty dict when INVALID is returned.
"""
my_log = log.getChild(f"check_verification_payload.{expected_email}")
hmac_ob = _email_verification_hmac(info_b64.encode())
actual_hmac = hmac_ob.hexdigest()
if not hmac.compare_digest(actual_hmac, expected_hmac):
my_log.warning(
"invalid HMAC, payload=%r, expected HMAC=%r, got %r",
info_b64,
expected_hmac,
actual_hmac,
)
return VerificationResult.INVALID, {}
try:
info = base64.urlsafe_b64decode(info_b64)
except (binascii.Error, ValueError):
my_log.warning("invalid Base64: %r", info_b64)
return VerificationResult.INVALID, {}
try:
payload = json.loads(info)
except (TypeError, ValueError, UnicodeDecodeError):
my_log.warning("invalid JSON, payload=%r", info, exc_info=True)
return VerificationResult.INVALID, {}
email = payload.get("e", "")
if email != expected_email:
my_log.warning("email does not match payload %r", email)
return VerificationResult.OTHER_ACCOUNT, payload
now = timezone.now()
expiry = dateutil.parser.parse(payload.get("x", "")).replace(tzinfo=timezone.utc)
if expiry < now:
my_log.info("link expired at %s", expiry)
return VerificationResult.EXPIRED, payload
log.debug("verification OK")
return VerificationResult.OK, payload
def construct_new_user_session(user, session_data):
context = {
"session_data": session_data,
"user": user,
}
email_body_txt = loader.render_to_string(
"bid_main/emails/new_user_session.txt", context
)
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