feat: add e-mail change with confirmation and undo
Page at /email asks for the new address and the current password (shared failure counter with the password page, five requests per hour) and mails a one-hour confirmation link to the new address. Confirming writes the address and mails the old address a 24-hour undo link; undoing restores it, ends every session of the user and notifies the Admin (ADMIN_NOTIFY_EMAIL). Link pages show on GET and apply on POST; tokens are stored only as hashes, single use, and are blanked from every log line. New table owned by profile, German text, tests; review findings logged in the plan. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FocPJ75XPma7TcuYEMy8zm
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3fd8f27127
commit
94c1d39c9d
+17
-1
@@ -10,6 +10,8 @@ from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from portal.mailer.address import is_valid_address
|
||||
|
||||
SMTP_SECURITY_MODES = ("starttls", "ssl")
|
||||
SMTP_DEFAULT_PORTS = {"starttls": 587, "ssl": 465}
|
||||
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||
@@ -80,6 +82,7 @@ class Config:
|
||||
directory: DirectoryConfig
|
||||
oidc: OidcConfig
|
||||
smtp: SmtpConfig
|
||||
admin_notify_email: str = "" # where the notice of an undone e-mail change goes; may be empty
|
||||
|
||||
|
||||
class _Reader:
|
||||
@@ -251,12 +254,25 @@ def _read_smtp(r: _Reader) -> SmtpConfig:
|
||||
)
|
||||
|
||||
|
||||
def _read_admin_notify_email(r: _Reader) -> str:
|
||||
value = r.raw("ADMIN_NOTIFY_EMAIL")
|
||||
if value is None:
|
||||
return ""
|
||||
if not is_valid_address(value):
|
||||
r.problem("ADMIN_NOTIFY_EMAIL: must be an e-mail address, or left empty")
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def load_config(environ: Mapping[str, str]) -> Config:
|
||||
reader = _Reader(environ)
|
||||
core = _read_core(reader)
|
||||
directory = _read_directory(reader)
|
||||
oidc = _read_oidc(reader, debug=core["debug"])
|
||||
smtp = _read_smtp(reader)
|
||||
admin_notify_email = _read_admin_notify_email(reader)
|
||||
if reader.problems:
|
||||
raise ConfigError(reader.problems)
|
||||
return Config(**core, directory=directory, oidc=oidc, smtp=smtp)
|
||||
return Config(
|
||||
**core, directory=directory, oidc=oidc, smtp=smtp, admin_notify_email=admin_notify_email
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from contextvars import ContextVar
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -13,6 +14,25 @@ request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
_STANDARD = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"}
|
||||
|
||||
|
||||
# One-time links carry their token in the path (AD-9); a log line must never hold it.
|
||||
_LINK_TOKEN = re.compile(r"(/email/(?:confirm|undo)/)[^/\s\"']+")
|
||||
|
||||
|
||||
def redact(text: str) -> str:
|
||||
return _LINK_TOKEN.sub(r"\1<redacted>", text)
|
||||
|
||||
|
||||
class RedactLinkTokens(logging.Filter):
|
||||
"""Blank the token of a one-time link in a record's message."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
message = record.getMessage()
|
||||
redacted = redact(message)
|
||||
if redacted != message:
|
||||
record.msg, record.args = redacted, ()
|
||||
return True
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, object] = {
|
||||
@@ -37,11 +57,13 @@ def build_logging(level: str) -> dict:
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {"json": {"()": "portal.config.logging.JsonFormatter"}},
|
||||
"filters": {"redact_link_tokens": {"()": "portal.config.logging.RedactLinkTokens"}},
|
||||
"handlers": {
|
||||
"stdout": {
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": "ext://sys.stdout",
|
||||
"formatter": "json",
|
||||
"filters": ["redact_link_tokens"],
|
||||
}
|
||||
},
|
||||
"loggers": {"django.request": {"level": "ERROR"}},
|
||||
|
||||
@@ -7,7 +7,7 @@ import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from .logging import request_id_var
|
||||
from .logging import redact, request_id_var
|
||||
|
||||
logger = logging.getLogger("portal.request")
|
||||
|
||||
@@ -48,7 +48,7 @@ class RequestIdMiddleware:
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.path,
|
||||
"path": redact(request.path),
|
||||
"status": status,
|
||||
"duration_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
},
|
||||
|
||||
@@ -26,6 +26,8 @@ DISPLAY_TIME_ZONE = _config.default_timezone
|
||||
DIRECTORY = _config.directory
|
||||
OIDC = _config.oidc
|
||||
SMTP = _config.smtp
|
||||
# Gets the notice when an e-mail change is undone; empty means no notice (an error is logged).
|
||||
ADMIN_NOTIFY_EMAIL = _config.admin_notify_email
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
|
||||
@@ -314,3 +314,137 @@ msgstr "Ihre Angaben wurden möglicherweise geändert, aber das Portal konnte ni
|
||||
|
||||
msgid "Back to the profile"
|
||||
msgstr "Zurück zum Profil"
|
||||
|
||||
msgid "This is already your e-mail address."
|
||||
msgstr "Das ist bereits Ihre E-Mail-Adresse."
|
||||
|
||||
msgid "This e-mail address is too long."
|
||||
msgstr "Diese E-Mail-Adresse ist zu lang."
|
||||
|
||||
msgid "Please enter a valid e-mail address."
|
||||
msgstr "Bitte geben Sie eine gültige E-Mail-Adresse ein."
|
||||
|
||||
msgid "Change e-mail address"
|
||||
msgstr "E-Mail-Adresse ändern"
|
||||
|
||||
msgid "Announcements and security notices are sent to this address. We send a link to the new address. Your address changes only after you open the link and confirm."
|
||||
msgstr "Ankündigungen und Sicherheitshinweise werden an diese Adresse gesendet. Wir senden einen Link an die neue Adresse. Ihre Adresse ändert sich erst, wenn Sie den Link öffnen und bestätigen."
|
||||
|
||||
msgid "No link was sent."
|
||||
msgstr "Es wurde kein Link gesendet."
|
||||
|
||||
msgid "Current e-mail address"
|
||||
msgstr "Aktuelle E-Mail-Adresse"
|
||||
|
||||
msgid "New e-mail address"
|
||||
msgstr "Neue E-Mail-Adresse"
|
||||
|
||||
msgid "We check it before anything is sent."
|
||||
msgstr "Wir prüfen es, bevor etwas gesendet wird."
|
||||
|
||||
msgid "Send confirmation link"
|
||||
msgstr "Bestätigungslink senden"
|
||||
|
||||
msgid "Confirmation link sent"
|
||||
msgstr "Bestätigungslink gesendet"
|
||||
|
||||
msgid "We sent a link to your new e-mail address. It is valid for one hour."
|
||||
msgstr "Wir haben einen Link an Ihre neue E-Mail-Adresse gesendet. Er ist eine Stunde gültig."
|
||||
|
||||
msgid "Your address has not changed yet. It changes after you open the link and confirm."
|
||||
msgstr "Ihre Adresse hat sich noch nicht geändert. Sie ändert sich, sobald Sie den Link öffnen und bestätigen."
|
||||
|
||||
msgid "Confirm new e-mail address"
|
||||
msgstr "Neue E-Mail-Adresse bestätigen"
|
||||
|
||||
msgid "The e-mail address of your account will change. Nothing changes until you press the button."
|
||||
msgstr "Die E-Mail-Adresse Ihres Kontos wird geändert. Erst wenn Sie die Schaltfläche drücken, ändert sich etwas."
|
||||
|
||||
msgid "A notice with a way to undo the change is sent to the current address."
|
||||
msgstr "An die aktuelle Adresse wird ein Hinweis mit der Möglichkeit gesendet, die Änderung rückgängig zu machen."
|
||||
|
||||
msgid "Confirm change"
|
||||
msgstr "Änderung bestätigen"
|
||||
|
||||
msgid "E-mail address changed"
|
||||
msgstr "E-Mail-Adresse geändert"
|
||||
|
||||
msgid "Your e-mail address was changed."
|
||||
msgstr "Ihre E-Mail-Adresse wurde geändert."
|
||||
|
||||
msgid "A notice with a way to undo the change was sent to your previous address."
|
||||
msgstr "Ein Hinweis mit der Möglichkeit, die Änderung rückgängig zu machen, wurde an Ihre bisherige Adresse gesendet."
|
||||
|
||||
msgid "Undo e-mail change"
|
||||
msgstr "E-Mail-Änderung rückgängig machen"
|
||||
|
||||
msgid "The e-mail address of your account was changed. If you did not do this, undo the change now."
|
||||
msgstr "Die E-Mail-Adresse Ihres Kontos wurde geändert. Wenn Sie das nicht waren, machen Sie die Änderung jetzt rückgängig."
|
||||
|
||||
msgid "Previous e-mail address"
|
||||
msgstr "Bisherige E-Mail-Adresse"
|
||||
|
||||
msgid "Undoing restores the previous address, signs you out on all devices and informs the administrator."
|
||||
msgstr "Beim Rückgängigmachen wird die bisherige Adresse wiederhergestellt, Sie werden auf allen Geräten abgemeldet und die Administration wird informiert."
|
||||
|
||||
msgid "Undo change"
|
||||
msgstr "Änderung rückgängig machen"
|
||||
|
||||
msgid "E-mail change undone"
|
||||
msgstr "E-Mail-Änderung rückgängig gemacht"
|
||||
|
||||
msgid "Your previous e-mail address is back. You were signed out on all devices."
|
||||
msgstr "Ihre bisherige E-Mail-Adresse ist wieder eingetragen. Sie wurden auf allen Geräten abgemeldet."
|
||||
|
||||
msgid "Someone else may have had access to your account. Please change your password now."
|
||||
msgstr "Möglicherweise hatte jemand anderes Zugriff auf Ihr Konto. Bitte ändern Sie jetzt Ihr Passwort."
|
||||
|
||||
msgid "Address changed again"
|
||||
msgstr "Adresse erneut geändert"
|
||||
|
||||
msgid "The e-mail address was changed again since then. Nothing was changed now. If this was not you, contact the administrator."
|
||||
msgstr "Die E-Mail-Adresse wurde seitdem erneut geändert. Jetzt wurde nichts geändert. Wenn Sie das nicht waren, wenden Sie sich an die Administration."
|
||||
|
||||
msgid "Link not valid"
|
||||
msgstr "Link nicht gültig"
|
||||
|
||||
msgid "This link is not valid any more."
|
||||
msgstr "Dieser Link ist nicht mehr gültig."
|
||||
|
||||
msgid "Your e-mail address may have been changed, but the portal could not finish. Please check your profile or contact the administrator."
|
||||
msgstr "Ihre E-Mail-Adresse wurde möglicherweise geändert, aber das Portal konnte den Vorgang nicht abschließen. Bitte prüfen Sie Ihr Profil oder wenden Sie sich an die Administration."
|
||||
|
||||
msgid "Please confirm your new e-mail address"
|
||||
msgstr "Bitte bestätigen Sie Ihre neue E-Mail-Adresse"
|
||||
|
||||
msgid "this e-mail address was entered as the new e-mail address of your portal account. To confirm it, open this link and press the button. The link is valid for one hour:"
|
||||
msgstr "diese E-Mail-Adresse wurde als neue E-Mail-Adresse Ihres Portal-Kontos angegeben. Um sie zu bestätigen, öffnen Sie diesen Link und drücken Sie die Schaltfläche. Der Link ist eine Stunde gültig:"
|
||||
|
||||
msgid "Your address changes only after you confirm. If you did not ask for this, ignore this e-mail. Nothing will change."
|
||||
msgstr "Ihre Adresse ändert sich erst, wenn Sie bestätigen. Wenn Sie das nicht veranlasst haben, ignorieren Sie diese E-Mail. Es ändert sich nichts."
|
||||
|
||||
msgid "Your e-mail address was changed"
|
||||
msgstr "Ihre E-Mail-Adresse wurde geändert"
|
||||
|
||||
#, python-format
|
||||
msgid "the e-mail address of your portal account was changed on %(when)s (%(zone)s) to %(address)s."
|
||||
msgstr "die E-Mail-Adresse Ihres Portal-Kontos wurde am %(when)s (%(zone)s) auf %(address)s geändert."
|
||||
|
||||
msgid "If you did this, you do not need to do anything. If you did not, open this link to restore this address. The link is valid for 24 hours:"
|
||||
msgstr "Wenn Sie das waren, müssen Sie nichts tun. Wenn nicht, öffnen Sie diesen Link, um diese Adresse wiederherzustellen. Der Link ist 24 Stunden gültig:"
|
||||
|
||||
msgid "Undoing signs you out on all devices and informs the administrator."
|
||||
msgstr "Beim Rückgängigmachen werden Sie auf allen Geräten abgemeldet und die Administration wird informiert."
|
||||
|
||||
msgid "An e-mail change was undone"
|
||||
msgstr "Eine E-Mail-Änderung wurde rückgängig gemacht"
|
||||
|
||||
msgid "Hello,"
|
||||
msgstr "Guten Tag,"
|
||||
|
||||
#, python-format
|
||||
msgid "the user %(username)s undid an e-mail change on %(when)s (%(zone)s). All sessions of this user were ended."
|
||||
msgstr "der Benutzer %(username)s hat am %(when)s (%(zone)s) eine E-Mail-Änderung rückgängig gemacht. Alle Sitzungen dieses Benutzers wurden beendet."
|
||||
|
||||
msgid "The user may have lost control of the account. Please check it."
|
||||
msgstr "Der Benutzer hat möglicherweise die Kontrolle über das Konto verloren. Bitte prüfen Sie es."
|
||||
|
||||
@@ -83,3 +83,45 @@ msgstr "Ein Wert wurde nicht angenommen. Bitte prüfe deine Eingaben."
|
||||
|
||||
msgid "Your details may have been changed, but the portal could not finish. Please open your profile and check them. If they are wrong, contact the administrator."
|
||||
msgstr "Deine Angaben wurden möglicherweise geändert, aber das Portal konnte nicht abschließen. Bitte öffne dein Profil und prüfe sie. Wenn sie falsch sind, wende dich an die Administration."
|
||||
|
||||
msgid "This is already your e-mail address."
|
||||
msgstr "Das ist bereits deine E-Mail-Adresse."
|
||||
|
||||
msgid "Please enter a valid e-mail address."
|
||||
msgstr "Bitte gib eine gültige E-Mail-Adresse ein."
|
||||
|
||||
msgid "Announcements and security notices are sent to this address. We send a link to the new address. Your address changes only after you open the link and confirm."
|
||||
msgstr "Ankündigungen und Sicherheitshinweise werden an diese Adresse gesendet. Wir senden einen Link an die neue Adresse. Deine Adresse ändert sich erst, wenn du den Link öffnest und bestätigst."
|
||||
|
||||
msgid "We sent a link to your new e-mail address. It is valid for one hour."
|
||||
msgstr "Wir haben einen Link an deine neue E-Mail-Adresse gesendet. Er ist eine Stunde gültig."
|
||||
|
||||
msgid "Your address has not changed yet. It changes after you open the link and confirm."
|
||||
msgstr "Deine Adresse hat sich noch nicht geändert. Das passiert erst, wenn du den Link öffnest und bestätigst."
|
||||
|
||||
msgid "The e-mail address of your account will change. Nothing changes until you press the button."
|
||||
msgstr "Die E-Mail-Adresse deines Kontos wird geändert. Erst wenn du die Schaltfläche drückst, ändert sich etwas."
|
||||
|
||||
msgid "Your e-mail address was changed."
|
||||
msgstr "Deine E-Mail-Adresse wurde geändert."
|
||||
|
||||
msgid "A notice with a way to undo the change was sent to your previous address."
|
||||
msgstr "Ein Hinweis mit der Möglichkeit, die Änderung rückgängig zu machen, wurde an deine bisherige Adresse gesendet."
|
||||
|
||||
msgid "The e-mail address of your account was changed. If you did not do this, undo the change now."
|
||||
msgstr "Die E-Mail-Adresse deines Kontos wurde geändert. Wenn du das nicht warst, mach die Änderung jetzt rückgängig."
|
||||
|
||||
msgid "Undoing restores the previous address, signs you out on all devices and informs the administrator."
|
||||
msgstr "Beim Rückgängigmachen wird die bisherige Adresse wiederhergestellt, du wirst auf allen Geräten abgemeldet und die Administration wird informiert."
|
||||
|
||||
msgid "Your previous e-mail address is back. You were signed out on all devices."
|
||||
msgstr "Deine bisherige E-Mail-Adresse ist wieder eingetragen. Du wurdest auf allen Geräten abgemeldet."
|
||||
|
||||
msgid "Someone else may have had access to your account. Please change your password now."
|
||||
msgstr "Möglicherweise hatte jemand anderes Zugriff auf dein Konto. Bitte ändere jetzt dein Passwort."
|
||||
|
||||
msgid "The e-mail address was changed again since then. Nothing was changed now. If this was not you, contact the administrator."
|
||||
msgstr "Die E-Mail-Adresse wurde seitdem erneut geändert. Jetzt wurde nichts geändert. Wenn du das nicht warst, wende dich an die Administration."
|
||||
|
||||
msgid "Your e-mail address may have been changed, but the portal could not finish. Please check your profile or contact the administrator."
|
||||
msgstr "Deine E-Mail-Adresse wurde möglicherweise geändert, aber das Portal konnte den Vorgang nicht abschließen. Bitte prüfe dein Profil oder wende dich an die Administration."
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""The one rule for a usable e-mail address. No Django imports: the config reads it at startup."""
|
||||
|
||||
import re
|
||||
|
||||
_ADDRESS = re.compile(r"^[^@\s<>,;\"()\[\]\\]+@[^@\s<>,;\"()\[\]\\]+\.[^@\s<>,;\"()\[\]\\]+$")
|
||||
|
||||
|
||||
def is_valid_address(value: str) -> bool:
|
||||
return len(value) <= 254 and bool(_ADDRESS.match(value))
|
||||
@@ -9,7 +9,6 @@ duplicate one for a password or sign-in notice.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@@ -21,6 +20,7 @@ from django.utils import timezone, translation
|
||||
from portal.adapters.mail import MailMessage, MailRejected, MailUnavailable
|
||||
from portal.adapters.registry import get_mailer
|
||||
|
||||
from .address import is_valid_address
|
||||
from .models import OutboxMail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,8 +31,6 @@ RATE_PER_MINUTE = 20
|
||||
LEASE_SECONDS = 10 * 60 # a row that has been `sending` this long is given up on
|
||||
STOP_AFTER_CONSECUTIVE_OUTAGES = 3
|
||||
|
||||
_ADDRESS = re.compile(r"^[^@\s<>,;\"()\[\]\\]+@[^@\s<>,;\"()\[\]\\]+\.[^@\s<>,;\"()\[\]\\]+$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlushResult:
|
||||
@@ -44,10 +42,6 @@ class FlushResult:
|
||||
return f"sent={self.sent} retried={self.retried} failed={self.failed}"
|
||||
|
||||
|
||||
def is_valid_address(value: str) -> bool:
|
||||
return len(value) <= 254 and bool(_ADDRESS.match(value))
|
||||
|
||||
|
||||
def enqueue(template: str, recipient: str, context: dict | None = None) -> OutboxMail:
|
||||
"""Render the e-mail in the portal-wide language and queue it.
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ from django import forms
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from portal.mailer.address import is_valid_address
|
||||
|
||||
MAX_PASSWORD_LENGTH = 256 # Active Directory's limit
|
||||
|
||||
|
||||
@@ -163,3 +165,27 @@ class DetailsForm(forms.Form):
|
||||
def apply_errors(self, errors: dict[str, str]) -> None:
|
||||
for name, message in errors.items():
|
||||
self.add_error(name, message)
|
||||
|
||||
|
||||
# -- e-mail change ----------------------------------------------------------------------------
|
||||
|
||||
MAX_ADDRESS_LENGTH = 254
|
||||
|
||||
|
||||
class EmailChangeForm(forms.Form):
|
||||
new_email = forms.CharField(
|
||||
strip=True,
|
||||
required=False, # the messages below say it better than the default
|
||||
widget=forms.EmailInput(),
|
||||
)
|
||||
current_password = _password_field()
|
||||
|
||||
def clean_new_email(self):
|
||||
value = self.cleaned_data["new_email"]
|
||||
if not value:
|
||||
raise forms.ValidationError(_("Please fill in this field."))
|
||||
if len(value) > MAX_ADDRESS_LENGTH:
|
||||
raise forms.ValidationError(_("This e-mail address is too long."))
|
||||
if not is_valid_address(value):
|
||||
raise forms.ValidationError(_("Please enter a valid e-mail address."))
|
||||
return value
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.18 on 2026-10-19 07:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PendingEmailChange',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user_key', models.CharField(db_index=True, max_length=256)),
|
||||
('sam', models.CharField(max_length=256)),
|
||||
('kind', models.CharField(choices=[('confirm', 'Confirm'), ('undo', 'Undo')], max_length=10)),
|
||||
('token_hash', models.CharField(max_length=64, unique=True)),
|
||||
('old_address', models.CharField(blank=True, max_length=254)),
|
||||
('new_address', models.CharField(max_length=254)),
|
||||
('created_at', models.DateTimeField()),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('used_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class PendingEmailChange(models.Model):
|
||||
"""A one-time link for an e-mail change. Owned by `profile` (AD-19).
|
||||
|
||||
The token itself is never stored, only its SHA-256 hash (AD-9). A `confirm` row holds the
|
||||
address the user asked for (`new_address`) and the one they had then (`old_address`). An
|
||||
`undo` row holds the address to restore (`old_address`) and the one that was confirmed
|
||||
(`new_address`).
|
||||
"""
|
||||
|
||||
class Kind(models.TextChoices):
|
||||
CONFIRM = "confirm"
|
||||
UNDO = "undo"
|
||||
|
||||
user_key = models.CharField(max_length=256, db_index=True) # lower case
|
||||
sam = models.CharField(max_length=256) # exact sAMAccountName, for directory calls
|
||||
kind = models.CharField(max_length=10, choices=Kind.choices)
|
||||
token_hash = models.CharField(max_length=64, unique=True)
|
||||
old_address = models.CharField(max_length=254, blank=True)
|
||||
new_address = models.CharField(max_length=254)
|
||||
created_at = models.DateTimeField()
|
||||
expires_at = models.DateTimeField()
|
||||
used_at = models.DateTimeField(null=True, blank=True)
|
||||
+358
-1
@@ -1,4 +1,4 @@
|
||||
"""Password change and profile details of the signed-in user (FR-5..FR-9; AD-3, AD-4, AD-8).
|
||||
"""Password change, profile details and e-mail change (FR-5..FR-11; AD-3, AD-4, AD-8, AD-9).
|
||||
|
||||
The order is fixed: throttle check, policy read, verification of the current password, local
|
||||
hints, then the directory change, the ended sessions, the audit entry and the queued mail in
|
||||
@@ -8,8 +8,10 @@ stored or put into an exception.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import secrets
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -35,6 +37,7 @@ from portal.audit import services as audit
|
||||
from portal.mailer import services as mailer
|
||||
|
||||
from .forms import DETAIL_FIELDS, check_details
|
||||
from .models import PendingEmailChange
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -455,3 +458,357 @@ def update_details(user: SessionUser, values: dict[str, str], *, ip: str) -> Det
|
||||
_record_details_failure(user, ip, "error")
|
||||
raise
|
||||
return DetailsUpdated(changed, updated)
|
||||
|
||||
|
||||
# -- e-mail change (FR-10, FR-11) -------------------------------------------------------------
|
||||
#
|
||||
# Request: validate locally (the form), throttle.check, verify_password, then the pending row,
|
||||
# the queued mail and the audit entry in one transaction. Confirm and undo: the token claim,
|
||||
# the directory write, the other rows, the queued mail and the audit entry in one transaction.
|
||||
# Addresses, tokens and passwords never go to a log line or an audit entry.
|
||||
|
||||
REQUEST_ENDPOINT = "email-change-request" # five requests per hour per user
|
||||
REQUEST_LIMIT = 5
|
||||
REQUEST_WINDOW_SECONDS = 60 * 60
|
||||
CONFIRM_VALID_FOR = timedelta(hours=1)
|
||||
UNDO_VALID_FOR = timedelta(hours=24)
|
||||
MAX_TOKEN_LENGTH = 128
|
||||
|
||||
|
||||
class EmailInvalid(Exception):
|
||||
"""The new address was refused after the directory was read; `reason` is `same`."""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
self.reason = reason
|
||||
super().__init__("The new e-mail address is not usable")
|
||||
|
||||
|
||||
class LinkDead(Exception):
|
||||
"""The token is unknown, used, expired or replaced. Never says which."""
|
||||
|
||||
|
||||
class ChangedAgain(Exception):
|
||||
"""The address in the directory is no longer the one that was confirmed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Confirmed:
|
||||
notice_queued: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Undone:
|
||||
admin_notified: bool
|
||||
|
||||
|
||||
def new_token() -> tuple[str, str]:
|
||||
"""(token for the link, hash for the database)."""
|
||||
raw = secrets.token_urlsafe(32)
|
||||
return raw, hash_token(raw)
|
||||
|
||||
|
||||
def hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def find_link(token: str, kind: str) -> PendingEmailChange | None:
|
||||
"""The unused, unexpired row of this kind for the token, or None. Changes nothing."""
|
||||
if not token or len(token) > MAX_TOKEN_LENGTH:
|
||||
return None
|
||||
return PendingEmailChange.objects.filter(
|
||||
token_hash=hash_token(token),
|
||||
kind=kind,
|
||||
used_at__isnull=True,
|
||||
expires_at__gt=timezone.now(),
|
||||
).first()
|
||||
|
||||
|
||||
def _link(path: str, token: str) -> str:
|
||||
return f"{settings.OIDC.public_base_url}/email/{path}/{token}"
|
||||
|
||||
|
||||
def _display_name(account: DirectoryUser) -> str:
|
||||
name = account.display_name or " ".join(p for p in (account.given_name, account.surname) if p)
|
||||
return name or account.sam
|
||||
|
||||
|
||||
def _local_now():
|
||||
return timezone.now().astimezone(ZoneInfo(settings.DISPLAY_TIME_ZONE))
|
||||
|
||||
|
||||
def _email_failure(user: SessionUser, ip: str, action: str, reason: str, **detail) -> None:
|
||||
audit.record_failure(user.key, action, ip=ip, detail={"reason": reason, **detail})
|
||||
|
||||
|
||||
def request_email_change(user: SessionUser, new_address: str, current: str, *, ip: str) -> None:
|
||||
"""Ask for a confirmation link at `new_address`. The directory stays unchanged.
|
||||
|
||||
`new_address` has passed the form's checks. Raises Throttled, WrongCurrentPassword,
|
||||
EmailInvalid or DirectoryUnavailable. Must be called outside an open transaction.
|
||||
"""
|
||||
action = "email.change_failed"
|
||||
try:
|
||||
throttle.check(ENDPOINT, user=user.key, ip=ip or None)
|
||||
throttle.check(REQUEST_ENDPOINT, user=user.key)
|
||||
except throttle.Throttled:
|
||||
audit.record_failure(user.key, "email.throttled", ip=ip)
|
||||
raise
|
||||
|
||||
directory = get_directory()
|
||||
try:
|
||||
policy = directory.password_policy(user.sam)
|
||||
if not directory.verify_password(user.sam, current):
|
||||
throttle.hit(
|
||||
ENDPOINT,
|
||||
user=user.key,
|
||||
ip=ip or None,
|
||||
max_failures=failure_limit(policy),
|
||||
pause_seconds=pause_seconds(policy),
|
||||
)
|
||||
_email_failure(user, ip, action, "wrong_current", **_threshold_note(policy))
|
||||
raise WrongCurrentPassword(policy)
|
||||
account = directory.find_user(user.sam)
|
||||
if account is None:
|
||||
raise DirectoryUnavailable("The account could not be read")
|
||||
except DirectoryUnavailable:
|
||||
_email_failure(user, ip, action, "unavailable")
|
||||
raise
|
||||
throttle.reset(ENDPOINT, user=user.key) # the directory forgets failures after a good bind
|
||||
|
||||
if new_address.casefold() == (account.mail or "").strip().casefold():
|
||||
raise EmailInvalid("same")
|
||||
|
||||
raw, digest = new_token()
|
||||
now = timezone.now()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
PendingEmailChange.objects.filter(
|
||||
user_key=user.key, kind=PendingEmailChange.Kind.CONFIRM, used_at__isnull=True
|
||||
).delete() # the newest request replaces the earlier one
|
||||
PendingEmailChange.objects.create(
|
||||
user_key=user.key,
|
||||
sam=user.sam,
|
||||
kind=PendingEmailChange.Kind.CONFIRM,
|
||||
token_hash=digest,
|
||||
old_address=(account.mail or "").strip(),
|
||||
new_address=new_address,
|
||||
created_at=now,
|
||||
expires_at=now + CONFIRM_VALID_FOR,
|
||||
)
|
||||
mailer.enqueue(
|
||||
"email_change_confirm",
|
||||
new_address,
|
||||
{"name": _display_name(account), "link": _link("confirm", raw)},
|
||||
)
|
||||
audit.record(user.key, "email.change_requested", ip=ip)
|
||||
except Exception:
|
||||
_email_failure(user, ip, action, "error")
|
||||
raise
|
||||
throttle.hit(
|
||||
REQUEST_ENDPOINT,
|
||||
user=user.key,
|
||||
max_failures=REQUEST_LIMIT,
|
||||
pause_seconds=REQUEST_WINDOW_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _unclear(user: SessionUser, ip: str, action: str, what: str, step: str, exc: Exception):
|
||||
"""The directory changed but the portal's part rolled back (AD-4): log, never throw."""
|
||||
logger.error(
|
||||
"E-mail %s in the directory but the portal did not record it: "
|
||||
"user=%s ip=%s time=%s step=%s error=%s",
|
||||
what,
|
||||
user.key,
|
||||
ip,
|
||||
timezone.now().isoformat(),
|
||||
step,
|
||||
type(exc).__name__,
|
||||
)
|
||||
try:
|
||||
_email_failure(user, ip, action, "unclear", step=step)
|
||||
except Exception:
|
||||
logger.error("The failure entry for %s could not be written", user.key)
|
||||
|
||||
|
||||
def confirm_email_change(token: str, *, ip: str) -> Confirmed:
|
||||
"""Write the new address to the directory and mail the old one an undo link.
|
||||
|
||||
Raises LinkDead, DirectoryUnavailable or OutcomeUnknown. Must be called outside an open
|
||||
transaction.
|
||||
"""
|
||||
action = "email.change_failed"
|
||||
row = find_link(token, PendingEmailChange.Kind.CONFIRM)
|
||||
if row is None:
|
||||
raise LinkDead
|
||||
user = SessionUser(key=row.user_key, sam=row.sam)
|
||||
directory = get_directory()
|
||||
try:
|
||||
account = directory.find_user(row.sam)
|
||||
except DirectoryUnavailable:
|
||||
_email_failure(user, ip, action, "unavailable", stage="confirm")
|
||||
raise
|
||||
if account is None:
|
||||
raise LinkDead
|
||||
previous = (account.mail or "").strip()
|
||||
notice = mailer.is_valid_address(previous)
|
||||
|
||||
changed_in_directory = False
|
||||
step = "claim"
|
||||
try:
|
||||
with transaction.atomic():
|
||||
claimed = PendingEmailChange.objects.filter(pk=row.pk, used_at__isnull=True).update(
|
||||
used_at=timezone.now()
|
||||
)
|
||||
if not claimed: # a parallel request used it first
|
||||
raise LinkDead
|
||||
step = "directory"
|
||||
directory.update_attributes(row.sam, {"mail": row.new_address})
|
||||
changed_in_directory = True
|
||||
if notice:
|
||||
step = "queue_notice"
|
||||
raw, digest = new_token()
|
||||
now = timezone.now()
|
||||
PendingEmailChange.objects.create(
|
||||
user_key=row.user_key,
|
||||
sam=row.sam,
|
||||
kind=PendingEmailChange.Kind.UNDO,
|
||||
token_hash=digest,
|
||||
old_address=previous,
|
||||
new_address=row.new_address,
|
||||
created_at=now,
|
||||
expires_at=now + UNDO_VALID_FOR,
|
||||
)
|
||||
mailer.enqueue(
|
||||
"email_changed_notice",
|
||||
previous,
|
||||
{
|
||||
"name": _display_name(account),
|
||||
"new_address": row.new_address,
|
||||
"link": _link("undo", raw),
|
||||
"changed_at": _local_now(),
|
||||
"zone_name": settings.DISPLAY_TIME_ZONE,
|
||||
},
|
||||
)
|
||||
step = "audit"
|
||||
audit.record(
|
||||
user.key,
|
||||
"email.changed",
|
||||
ip=ip,
|
||||
detail={"notice": "queued" if notice else "skipped"},
|
||||
)
|
||||
except LinkDead:
|
||||
raise
|
||||
except DirectoryUnavailable:
|
||||
_email_failure(user, ip, action, "unavailable", stage="confirm")
|
||||
raise
|
||||
except (ValidationFailed, Forbidden) as exc:
|
||||
logger.error(
|
||||
"The directory refused an e-mail write: user=%s ip=%s error=%s",
|
||||
user.key,
|
||||
ip,
|
||||
type(exc).__name__,
|
||||
)
|
||||
_email_failure(user, ip, action, "refused", stage="confirm")
|
||||
raise DirectoryUnavailable("The directory refused the write") from exc
|
||||
except Exception as exc:
|
||||
if changed_in_directory:
|
||||
_unclear(user, ip, action, "address changed", step, exc)
|
||||
raise OutcomeUnknown("The address was changed but not recorded") from exc
|
||||
_email_failure(user, ip, action, "error", stage="confirm")
|
||||
raise
|
||||
return Confirmed(notice_queued=notice)
|
||||
|
||||
|
||||
def undo_email_change(token: str, *, ip: str) -> Undone:
|
||||
"""Restore the old address, end every session of the user and tell the Admin.
|
||||
|
||||
Raises LinkDead, ChangedAgain, DirectoryUnavailable or OutcomeUnknown. Must be called
|
||||
outside an open transaction.
|
||||
"""
|
||||
action = "email.undo_failed"
|
||||
row = find_link(token, PendingEmailChange.Kind.UNDO)
|
||||
if row is None:
|
||||
raise LinkDead
|
||||
user = SessionUser(key=row.user_key, sam=row.sam)
|
||||
directory = get_directory()
|
||||
try:
|
||||
account = directory.find_user(row.sam)
|
||||
except DirectoryUnavailable:
|
||||
_email_failure(user, ip, action, "unavailable")
|
||||
raise
|
||||
if account is None:
|
||||
raise LinkDead
|
||||
if (account.mail or "").strip().casefold() != row.new_address.casefold():
|
||||
raise ChangedAgain
|
||||
|
||||
admin = settings.ADMIN_NOTIFY_EMAIL
|
||||
changed_in_directory = False
|
||||
step = "claim"
|
||||
try:
|
||||
with transaction.atomic():
|
||||
claimed = PendingEmailChange.objects.filter(pk=row.pk, used_at__isnull=True).update(
|
||||
used_at=timezone.now()
|
||||
)
|
||||
if not claimed:
|
||||
raise LinkDead
|
||||
step = "directory"
|
||||
directory.update_attributes(row.sam, {"mail": row.old_address})
|
||||
changed_in_directory = True
|
||||
step = "end_sessions" # after the write, so a rolled-back write ends nothing
|
||||
ended = end_sessions(user)
|
||||
# A request made meanwhile (by whoever had the session) must not outlive the undo.
|
||||
PendingEmailChange.objects.filter(
|
||||
user_key=row.user_key, kind=PendingEmailChange.Kind.CONFIRM, used_at__isnull=True
|
||||
).delete()
|
||||
if admin:
|
||||
step = "queue_admin_notice"
|
||||
mailer.enqueue(
|
||||
"email_undone_admin",
|
||||
admin,
|
||||
{
|
||||
"username": row.sam,
|
||||
"undone_at": _local_now(),
|
||||
"zone_name": settings.DISPLAY_TIME_ZONE,
|
||||
},
|
||||
)
|
||||
step = "audit"
|
||||
audit.record(
|
||||
user.key,
|
||||
"email.undone",
|
||||
ip=ip,
|
||||
detail={
|
||||
"sessions_ended": ended,
|
||||
"admin_notice": "queued" if admin else "skipped",
|
||||
},
|
||||
)
|
||||
except LinkDead:
|
||||
raise
|
||||
except DirectoryUnavailable:
|
||||
_email_failure(user, ip, action, "unavailable")
|
||||
raise
|
||||
except (ValidationFailed, Forbidden) as exc:
|
||||
logger.error(
|
||||
"The directory refused an e-mail write: user=%s ip=%s error=%s",
|
||||
user.key,
|
||||
ip,
|
||||
type(exc).__name__,
|
||||
)
|
||||
_email_failure(user, ip, action, "refused")
|
||||
raise DirectoryUnavailable("The directory refused the write") from exc
|
||||
except Exception as exc:
|
||||
if changed_in_directory:
|
||||
try:
|
||||
end_sessions(user)
|
||||
except Exception:
|
||||
logger.error("The sessions of %s could not be ended", user.key)
|
||||
_unclear(user, ip, action, "address restored", step, exc)
|
||||
raise OutcomeUnknown("The address was restored but not recorded") from exc
|
||||
_email_failure(user, ip, action, "error")
|
||||
raise
|
||||
if not admin:
|
||||
logger.error(
|
||||
"ADMIN_NOTIFY_EMAIL is not set: the Admin was not told that user=%s undid an "
|
||||
"e-mail change",
|
||||
user.key,
|
||||
)
|
||||
return Undone(admin_notified=bool(admin))
|
||||
|
||||
@@ -6,4 +6,7 @@ urlpatterns = [
|
||||
path("profile", views.profile, name="profile"),
|
||||
path("password", views.password, name="password"),
|
||||
path("password/done", views.password_done, name="password-done"),
|
||||
path("email", views.email, name="email"),
|
||||
path("email/confirm/<str:token>", views.email_confirm, name="email-confirm"),
|
||||
path("email/undo/<str:token>", views.email_undo, name="email-undo"),
|
||||
]
|
||||
|
||||
+111
-2
@@ -1,4 +1,4 @@
|
||||
"""The password page (FR-5..FR-7). No business logic here: see services.py (AD-17)."""
|
||||
"""The password, profile and e-mail pages (FR-5..FR-11). No business logic here (AD-17)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
@@ -16,7 +17,8 @@ from portal.adapters.directory import DirectoryUnavailable
|
||||
from portal.adapters.registry import get_directory
|
||||
|
||||
from . import services
|
||||
from .forms import DETAIL_FIELDS, DetailsForm, PasswordChangeForm
|
||||
from .forms import DETAIL_FIELDS, DetailsForm, EmailChangeForm, PasswordChangeForm
|
||||
from .models import PendingEmailChange
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,3 +161,110 @@ def profile(request):
|
||||
remember(request, result.account) # this session shows the new name at once
|
||||
request.session[NOTICE_FLAG] = "saved" if result.changed else "unchanged"
|
||||
return redirect("profile")
|
||||
|
||||
|
||||
# -- e-mail change (FR-10, FR-11) -------------------------------------------------------------
|
||||
|
||||
EMAIL_SENT_FLAG = "email_change_sent"
|
||||
|
||||
|
||||
def _email_page(request, form, current, *, status=200, throttled=False):
|
||||
context = {"form": form, "current": current, "throttled": throttled}
|
||||
return render(request, "profile/email.html", context, status=status)
|
||||
|
||||
|
||||
@login_required
|
||||
@sensitive_post_parameters("current_password")
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def email(request):
|
||||
user = request.portal_user.user
|
||||
if request.method == "GET":
|
||||
try:
|
||||
details = services.load_details(user)
|
||||
except DirectoryUnavailable:
|
||||
return _unavailable(request)
|
||||
if request.session.pop(EMAIL_SENT_FLAG, None):
|
||||
return render(request, "profile/email_sent.html", {"current": details.mail})
|
||||
return _email_page(request, EmailChangeForm(), details.mail)
|
||||
|
||||
form = EmailChangeForm(request.POST)
|
||||
if not form.is_valid(): # no directory call before the throttle check (AD-7)
|
||||
return _email_page(request, form, None, status=400)
|
||||
|
||||
try:
|
||||
services.request_email_change(
|
||||
user,
|
||||
form.cleaned_data["new_email"],
|
||||
form.cleaned_data["current_password"],
|
||||
ip=request.client_ip,
|
||||
)
|
||||
except Throttled:
|
||||
return _email_page(request, EmailChangeForm(), None, status=429, throttled=True)
|
||||
except services.WrongCurrentPassword:
|
||||
form.add_error("current_password", _("The current password is not correct."))
|
||||
return _email_page(request, form, _current_address(user), status=400)
|
||||
except services.EmailInvalid:
|
||||
form.add_error("new_email", _("This is already your e-mail address."))
|
||||
return _email_page(request, form, _current_address(user), status=400)
|
||||
except DirectoryUnavailable:
|
||||
return _unavailable(request)
|
||||
|
||||
request.session[EMAIL_SENT_FLAG] = True
|
||||
return redirect("email")
|
||||
|
||||
|
||||
def _current_address(user) -> str | None:
|
||||
try:
|
||||
return services.load_details(user).mail
|
||||
except DirectoryUnavailable:
|
||||
return None
|
||||
|
||||
|
||||
def _dead_link(request):
|
||||
return render(request, "profile/email_link_dead.html", status=410)
|
||||
|
||||
|
||||
@never_cache
|
||||
@sensitive_post_parameters()
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def email_confirm(request, token):
|
||||
"""No sign-in needed: the link proves the mailbox. GET only shows (AD-9)."""
|
||||
if request.method == "GET":
|
||||
row = services.find_link(token, PendingEmailChange.Kind.CONFIRM)
|
||||
if row is None:
|
||||
return _dead_link(request)
|
||||
return render(request, "profile/email_confirm.html", {"change": row})
|
||||
|
||||
try:
|
||||
result = services.confirm_email_change(token, ip=request.client_ip)
|
||||
except services.LinkDead:
|
||||
return _dead_link(request)
|
||||
except DirectoryUnavailable:
|
||||
return _unavailable(request)
|
||||
except services.OutcomeUnknown:
|
||||
return render(request, "profile/email_unknown.html", status=500)
|
||||
return render(request, "profile/email_confirmed.html", {"notice": result.notice_queued})
|
||||
|
||||
|
||||
@never_cache
|
||||
@sensitive_post_parameters()
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def email_undo(request, token):
|
||||
"""No sign-in needed: the link proves the mailbox. GET only shows (AD-9)."""
|
||||
if request.method == "GET":
|
||||
row = services.find_link(token, PendingEmailChange.Kind.UNDO)
|
||||
if row is None:
|
||||
return _dead_link(request)
|
||||
return render(request, "profile/email_undo.html", {"change": row})
|
||||
|
||||
try:
|
||||
services.undo_email_change(token, ip=request.client_ip)
|
||||
except services.LinkDead:
|
||||
return _dead_link(request)
|
||||
except services.ChangedAgain:
|
||||
return render(request, "profile/email_changed_again.html", status=409)
|
||||
except DirectoryUnavailable:
|
||||
return _unavailable(request)
|
||||
except services.OutcomeUnknown:
|
||||
return render(request, "profile/email_unknown.html", status=500)
|
||||
return render(request, "profile/email_undone.html")
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{% load i18n %}{% autoescape off %}{% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %}
|
||||
|
||||
{% trans "this e-mail address was entered as the new e-mail address of your portal account. To confirm it, open this link and press the button. The link is valid for one hour:" %}
|
||||
|
||||
{{ link }}
|
||||
|
||||
{% trans "Your address changes only after you confirm. If you did not ask for this, ignore this e-mail. Nothing will change." %}
|
||||
|
||||
{% trans "This e-mail never contains your password." %}{% endautoescape %}
|
||||
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% autoescape off %}{% trans "Please confirm your new e-mail address" %}{% endautoescape %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% load i18n tz %}{% autoescape off %}{% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %}
|
||||
|
||||
{% timezone zone_name %}{% blocktrans with when=changed_at|date:"j. F Y, H:i" zone=changed_at|date:"T" address=new_address %}the e-mail address of your portal account was changed on {{ when }} ({{ zone }}) to {{ address }}.{% endblocktrans %}{% endtimezone %}
|
||||
|
||||
{% trans "If you did this, you do not need to do anything. If you did not, open this link to restore this address. The link is valid for 24 hours:" %}
|
||||
|
||||
{{ link }}
|
||||
|
||||
{% trans "Undoing signs you out on all devices and informs the administrator." %}
|
||||
|
||||
{% trans "This e-mail never contains your password." %}{% endautoescape %}
|
||||
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% autoescape off %}{% trans "Your e-mail address was changed" %}{% endautoescape %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% load i18n tz %}{% autoescape off %}{% trans "Hello," %}
|
||||
|
||||
{% timezone zone_name %}{% blocktrans with when=undone_at|date:"j. F Y, H:i" zone=undone_at|date:"T" username=username %}the user {{ username }} undid an e-mail change on {{ when }} ({{ zone }}). All sessions of this user were ended.{% endblocktrans %}{% endtimezone %}
|
||||
|
||||
{% trans "The user may have lost control of the account. Please check it." %}{% endautoescape %}
|
||||
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% autoescape off %}{% trans "An e-mail change was undone" %}{% endautoescape %}
|
||||
@@ -29,7 +29,7 @@
|
||||
<dt>{% trans "User name" %}</dt>
|
||||
<dd>{{ details.username }}</dd>
|
||||
<dt>{% trans "E-mail address" %}</dt>
|
||||
<dd>{% if details.mail %}{{ details.mail }}{% else %}–{% endif %}</dd>
|
||||
<dd>{% if details.mail %}{{ details.mail }}{% else %}–{% endif %} <a href="{% url 'email' %}">{% trans "Change e-mail address" %}</a></dd>
|
||||
</dl>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'profile' %}" class="card" data-busy-on-submit novalidate>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Change e-mail address" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Change e-mail address" %}</h1>
|
||||
<p class="lead">{% trans "Announcements and security notices are sent to this address. We send a link to the new address. Your address changes only after you open the link and confirm." %}</p>
|
||||
{% if throttled %}
|
||||
<div class="notice" role="alert" tabindex="-1" data-focus-on-load>
|
||||
<p>{% trans "Too many attempts. Please wait a few minutes." %}</p>
|
||||
</div>
|
||||
<p><a class="button" href="{% url 'home' %}">{% trans "Back to the start page" %}</a></p>
|
||||
{% else %}
|
||||
{% if form.errors %}
|
||||
<div class="error-summary" role="alert" tabindex="-1" data-focus-on-load>
|
||||
<p><strong>{% trans "No link was sent." %}</strong></p>
|
||||
<ul>
|
||||
{% for field in form %}{% for error in field.errors %}
|
||||
<li><a href="#{{ field.id_for_label }}">{{ error }}</a></li>
|
||||
{% endfor %}{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if current is not None %}
|
||||
<dl class="card details">
|
||||
<dt>{% trans "Current e-mail address" %}</dt>
|
||||
<dd>{% if current %}{{ current }}{% else %}–{% endif %}</dd>
|
||||
</dl>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'email' %}" class="card" data-busy-on-submit novalidate>
|
||||
{% csrf_token %}
|
||||
{% trans "New e-mail address" as label %}
|
||||
{% include "profile/_text_field.html" with field=form.new_email label=label max_length=254 autocomplete="email" input_type="email" required=True %}
|
||||
{% trans "Current password" as label %}{% trans "We check it before anything is sent." as hint %}
|
||||
{% include "profile/_field.html" with field=form.current_password label=label hint=hint autocomplete="current-password" %}
|
||||
<p class="actions">
|
||||
<button type="submit" class="primary"><span class="spinner" aria-hidden="true"></span>{% trans "Send confirmation link" %}</button>
|
||||
<a class="button" href="{% url 'profile' %}">{% trans "Cancel" %}</a>
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Address changed again" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Address changed again" %}</h1>
|
||||
<div class="notice" role="alert">
|
||||
<p>{% trans "The e-mail address was changed again since then. Nothing was changed now. If this was not you, contact the administrator." %}</p>
|
||||
</div>
|
||||
<p><a class="button" href="{% url 'home' %}">{% trans "Back to the start page" %}</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Confirm new e-mail address" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Confirm new e-mail address" %}</h1>
|
||||
<p class="lead">{% trans "The e-mail address of your account will change. Nothing changes until you press the button." %}</p>
|
||||
<dl class="card details">
|
||||
<dt>{% trans "Current e-mail address" %}</dt>
|
||||
<dd>{% if change.old_address %}{{ change.old_address }}{% else %}–{% endif %}</dd>
|
||||
<dt>{% trans "New e-mail address" %}</dt>
|
||||
<dd>{{ change.new_address }}</dd>
|
||||
</dl>
|
||||
{% if change.old_address %}<p>{% trans "A notice with a way to undo the change is sent to the current address." %}</p>{% endif %}
|
||||
<form method="post" action="{{ request.path }}" class="card" data-busy-on-submit>
|
||||
{% csrf_token %}
|
||||
<p class="actions">
|
||||
<button type="submit" class="primary"><span class="spinner" aria-hidden="true"></span>{% trans "Confirm change" %}</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "E-mail address changed" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "E-mail address changed" %}</h1>
|
||||
<div class="notice success" role="status">
|
||||
<p>{% trans "Your e-mail address was changed." %}</p>
|
||||
</div>
|
||||
{% if notice %}<p>{% trans "A notice with a way to undo the change was sent to your previous address." %}</p>{% endif %}
|
||||
<p><a class="button" href="{% url 'home' %}">{% trans "Back to the start page" %}</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Link not valid" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Link not valid" %}</h1>
|
||||
<div class="notice" role="alert">
|
||||
<p>{% trans "This link is not valid any more." %}</p>
|
||||
</div>
|
||||
<p><a class="button" href="{% url 'home' %}">{% trans "Back to the start page" %}</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Confirmation link sent" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Confirmation link sent" %}</h1>
|
||||
<div class="notice success" role="status">
|
||||
<p>{% trans "We sent a link to your new e-mail address. It is valid for one hour." %}</p>
|
||||
</div>
|
||||
<p>{% trans "Your address has not changed yet. It changes after you open the link and confirm." %}</p>
|
||||
<p><a class="button" href="{% url 'profile' %}">{% trans "Back to the profile" %}</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Undo e-mail change" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Undo e-mail change" %}</h1>
|
||||
<p class="lead">{% trans "The e-mail address of your account was changed. If you did not do this, undo the change now." %}</p>
|
||||
<dl class="card details">
|
||||
<dt>{% trans "Previous e-mail address" %}</dt>
|
||||
<dd>{{ change.old_address }}</dd>
|
||||
<dt>{% trans "Current e-mail address" %}</dt>
|
||||
<dd>{{ change.new_address }}</dd>
|
||||
</dl>
|
||||
<p>{% trans "Undoing restores the previous address, signs you out on all devices and informs the administrator." %}</p>
|
||||
<form method="post" action="{{ request.path }}" class="card" data-busy-on-submit>
|
||||
{% csrf_token %}
|
||||
<p class="actions">
|
||||
<button type="submit" class="primary"><span class="spinner" aria-hidden="true"></span>{% trans "Undo change" %}</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "E-mail change undone" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "E-mail change undone" %}</h1>
|
||||
<div class="notice success" role="status">
|
||||
<p>{% trans "Your previous e-mail address is back. You were signed out on all devices." %}</p>
|
||||
</div>
|
||||
<p><strong>{% trans "Someone else may have had access to your account. Please change your password now." %}</strong></p>
|
||||
<p><a class="button primary" href="{% url 'password' %}">{% trans "Change password" %}</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Result unclear" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Result unclear" %}</h1>
|
||||
<div class="notice" role="alert">
|
||||
<p>{% trans "Your e-mail address may have been changed, but the portal could not finish. Please check your profile or contact the administrator." %}</p>
|
||||
</div>
|
||||
<p><a class="button" href="{% url 'home' %}">{% trans "Back to the start page" %}</a></p>
|
||||
{% endblock %}
|
||||
@@ -178,3 +178,13 @@ def test_the_mail_settings_are_required(valid_env, name):
|
||||
del valid_env[name]
|
||||
with pytest.raises(ConfigError, match=name):
|
||||
load_config(valid_env)
|
||||
|
||||
|
||||
def test_the_admin_notice_address_is_optional_and_checked(valid_env):
|
||||
assert load_config(valid_env).admin_notify_email == ""
|
||||
assert load_config({**valid_env, "ADMIN_NOTIFY_EMAIL": " "}).admin_notify_email == ""
|
||||
good = {**valid_env, "ADMIN_NOTIFY_EMAIL": "admin@example.test"}
|
||||
assert load_config(good).admin_notify_email == "admin@example.test"
|
||||
with pytest.raises(ConfigError, match="ADMIN_NOTIFY_EMAIL") as info:
|
||||
load_config({**valid_env, "ADMIN_NOTIFY_EMAIL": "not an address"})
|
||||
assert "not an address" not in str(info.value)
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
"""E-mail change: request, confirmation link, undo link, limits, sessions, audit and logs."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.test import Client
|
||||
from django.utils import timezone
|
||||
|
||||
from portal.accounts.models import ThrottleCounter, UserSession
|
||||
from portal.adapters.directory import DirectoryUnavailable, ValidationFailed
|
||||
from portal.audit.models import AuditEntry
|
||||
from portal.mailer.models import OutboxMail
|
||||
from portal.profile import services
|
||||
from portal.profile.models import PendingEmailChange
|
||||
from portal.tests.helpers import sign_in
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
CURRENT = "Correct-Horse-9"
|
||||
WRONG = "Not-The-Password-1"
|
||||
OLD = "alice@example.test"
|
||||
NEW = "alice.new@example.test"
|
||||
ADMIN = "admin@example.test"
|
||||
LINK = re.compile(r"https://portal\.example\.test(/email/(?:confirm|undo)/[\w-]+)")
|
||||
DEAD = "This link is not valid any more."
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def admin_address(settings):
|
||||
settings.ADMIN_NOTIFY_EMAIL = ADMIN
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def people(adapters):
|
||||
d = adapters.directory
|
||||
d.add_user("alice", password=CURRENT, display_name="Alice Anders", mail=OLD)
|
||||
d.add_user("bob", display_name="Bob Brown", mail="bob@example.test")
|
||||
d.add_user("nomail", password=CURRENT, display_name="No Mail")
|
||||
return d
|
||||
|
||||
|
||||
class Spy:
|
||||
def __init__(self, directory):
|
||||
self.calls = []
|
||||
for name in ("password_policy", "verify_password", "find_user", "update_attributes"):
|
||||
self._wrap(directory, name)
|
||||
|
||||
def _wrap(self, directory, name):
|
||||
original = getattr(directory, name)
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self.calls.append(name)
|
||||
return original(*args, **kwargs)
|
||||
|
||||
setattr(directory, name, wrapper)
|
||||
|
||||
|
||||
def signed_in(adapters, name="alice"):
|
||||
client, _ = sign_in(adapters, name)
|
||||
return client
|
||||
|
||||
|
||||
def ask(client, new=NEW, password=CURRENT):
|
||||
return client.post("/email", {"new_email": new, "current_password": password})
|
||||
|
||||
|
||||
def mails(to=None):
|
||||
rows = OutboxMail.objects.all()
|
||||
return list(rows.filter(recipient=to) if to else rows)
|
||||
|
||||
|
||||
def link_in(mail):
|
||||
return LINK.search(mail.body_text).group(1)
|
||||
|
||||
|
||||
def request_link(adapters, name="alice", new=NEW):
|
||||
"""Sign in, ask for a change and return the confirmation path."""
|
||||
client = signed_in(adapters, name)
|
||||
assert ask(client, new).status_code == 302
|
||||
return link_in(mails(new)[-1])
|
||||
|
||||
|
||||
def confirmed_undo_path(adapters):
|
||||
path = request_link(adapters)
|
||||
Client().post(path)
|
||||
return link_in(mails(OLD)[-1])
|
||||
|
||||
|
||||
def actions():
|
||||
names = AuditEntry.objects.values_list("action", flat=True)
|
||||
return [name for name in names if name.startswith("email.")]
|
||||
|
||||
|
||||
def mail_of(adapters, name="alice"):
|
||||
return adapters.directory.find_user(name).mail
|
||||
|
||||
|
||||
# -- the page -----------------------------------------
|
||||
|
||||
|
||||
def test_the_page_shows_the_current_address_and_is_reached_from_the_profile(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
profile = client.get("/profile").content.decode()
|
||||
assert 'href="/email"' in profile and "Change e-mail address" in profile
|
||||
html = client.get("/email").content.decode()
|
||||
assert OLD in html and 'name="new_email"' in html and 'name="current_password"' in html
|
||||
assert 'autocomplete="current-password"' in html and "<form" in html
|
||||
|
||||
|
||||
def test_the_page_is_unavailable_when_the_directory_is_down(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
adapters.directory.unavailable = True
|
||||
assert client.get("/email").status_code == 503
|
||||
|
||||
|
||||
def test_the_page_and_the_mail_speak_german(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
client.cookies["pm_lang"] = "de"
|
||||
assert "E-Mail-Adresse ändern" in client.get("/email").content.decode()
|
||||
assert ask(client).status_code == 302
|
||||
mail = mails(NEW)[0]
|
||||
assert mail.subject == "Bitte bestätigen Sie Ihre neue E-Mail-Adresse"
|
||||
assert "Guten Tag Alice Anders," in mail.body_text and "eine Stunde" in mail.body_text
|
||||
|
||||
|
||||
# -- the request -----------------------------------------
|
||||
|
||||
|
||||
def test_a_request_mails_a_link_to_the_new_address_and_leaves_the_directory_alone(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
response = ask(client)
|
||||
assert response.status_code == 302 and response["Location"] == "/email"
|
||||
assert mail_of(adapters) == OLD
|
||||
[mail] = mails()
|
||||
assert mail.recipient == NEW and mail.template == "email_change_confirm"
|
||||
assert LINK.search(mail.body_text) and "/email/confirm/" in mail.body_text
|
||||
assert "password" not in mail.body_text.lower().replace("this e-mail never", "")
|
||||
sent = client.get("/email").content.decode()
|
||||
assert "We sent a link" in sent and 'name="new_email"' not in sent
|
||||
assert 'name="new_email"' in client.get("/email").content.decode() # shown once
|
||||
assert actions() == ["email.change_requested"]
|
||||
|
||||
|
||||
def test_only_the_hash_of_a_token_is_stored(people, adapters):
|
||||
path = request_link(adapters)
|
||||
token = path.rsplit("/", 1)[1]
|
||||
assert len(token) == 43 # secrets.token_urlsafe(32)
|
||||
row = PendingEmailChange.objects.get()
|
||||
assert row.token_hash == services.hash_token(token) and len(row.token_hash) == 64
|
||||
assert token not in " ".join(str(v) for v in row.__dict__.values())
|
||||
assert row.kind == "confirm" and row.user_key == "alice" and row.sam == "alice"
|
||||
assert row.expires_at - row.created_at == timedelta(hours=1)
|
||||
assert row.old_address == OLD and row.new_address == NEW and row.used_at is None
|
||||
|
||||
|
||||
def test_the_order_is_form_then_throttle_then_password_then_the_write(people, adapters):
|
||||
spy = Spy(adapters.directory)
|
||||
client = signed_in(adapters)
|
||||
spy.calls.clear()
|
||||
assert ask(client, new="nonsense").status_code == 400
|
||||
assert spy.calls == [] # the form is checked before any directory call
|
||||
ThrottleCounter.objects.create(
|
||||
endpoint="password-change",
|
||||
subject="user:alice",
|
||||
count=4,
|
||||
blocked_until=timezone.now() + timedelta(minutes=5),
|
||||
)
|
||||
assert ask(client).status_code == 429
|
||||
assert spy.calls == [] and mails() == [] and PendingEmailChange.objects.count() == 0
|
||||
assert "email.throttled" in actions()
|
||||
|
||||
|
||||
def test_a_wrong_password_is_refused_counted_and_audited(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
response = ask(client, password=WRONG)
|
||||
assert response.status_code == 400
|
||||
assert "The current password is not correct." in response.content.decode()
|
||||
assert mails() == [] and PendingEmailChange.objects.count() == 0
|
||||
assert ThrottleCounter.objects.get(endpoint="password-change", subject="user:alice").count == 1
|
||||
assert AuditEntry.objects.get(action="email.change_failed").detail == {
|
||||
"reason": "wrong_current"
|
||||
}
|
||||
assert mail_of(adapters) == OLD
|
||||
|
||||
|
||||
def test_both_pages_share_one_counter_below_the_directory_lockout(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
# The fake directory locks at 5, so the portal allows 4 wrong passwords in all.
|
||||
for _ in range(2):
|
||||
client.post(
|
||||
"/password",
|
||||
{
|
||||
"current_password": WRONG,
|
||||
"new_password": "Xx1-new-pass",
|
||||
"confirm_password": "Xx1-new-pass",
|
||||
},
|
||||
)
|
||||
assert ask(client, password=WRONG).status_code == 400
|
||||
assert ask(client, password=WRONG).status_code == 400
|
||||
spy = Spy(adapters.directory)
|
||||
response = ask(client) # even the right password: the limit is reached
|
||||
assert response.status_code == 429
|
||||
assert "Too many attempts. Please wait a few minutes." in response.content.decode()
|
||||
assert spy.calls == [] and mails() == []
|
||||
assert ThrottleCounter.objects.get(endpoint="password-change", subject="user:alice").count == 4
|
||||
# and the password page is paused as well
|
||||
blocked = client.post(
|
||||
"/password",
|
||||
{
|
||||
"current_password": CURRENT,
|
||||
"new_password": "Xx1-new-pass",
|
||||
"confirm_password": "Xx1-new-pass",
|
||||
},
|
||||
)
|
||||
assert blocked.status_code == 429
|
||||
|
||||
|
||||
def test_five_requests_per_hour_are_allowed_and_the_sixth_is_not(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
for number in range(5):
|
||||
assert ask(client, new=f"new{number}@example.test").status_code == 302
|
||||
spy = Spy(adapters.directory)
|
||||
assert ask(client, new="sixth@example.test").status_code == 429
|
||||
assert spy.calls == [] and len(mails()) == 5
|
||||
assert actions().count("email.throttled") == 1
|
||||
ThrottleCounter.objects.filter(endpoint="email-change-request").update(
|
||||
blocked_until=timezone.now() - timedelta(seconds=1)
|
||||
)
|
||||
assert ask(client, new="after@example.test").status_code == 302 # recovers after the pause
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["", " ", "not-an-address", "a b@example.test", "x" * 250 + "@example.test", OLD.upper()],
|
||||
)
|
||||
def test_a_bad_new_address_is_refused_and_not_counted(people, adapters, value):
|
||||
client = signed_in(adapters)
|
||||
response = ask(client, new=value)
|
||||
assert response.status_code == 400 and "No link was sent." in response.content.decode()
|
||||
assert mails() == [] and PendingEmailChange.objects.count() == 0
|
||||
assert not ThrottleCounter.objects.exists()
|
||||
|
||||
|
||||
def test_the_same_address_in_another_letter_case_is_named(people, adapters):
|
||||
response = ask(signed_in(adapters), new="Alice@Example.Test")
|
||||
assert "This is already your e-mail address." in response.content.decode()
|
||||
|
||||
|
||||
def test_a_newer_request_replaces_the_pending_one(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
ask(client, new="first@example.test")
|
||||
ask(client, new="second@example.test")
|
||||
first = link_in(mails("first@example.test")[0])
|
||||
second = link_in(mails("second@example.test")[0])
|
||||
other = Client()
|
||||
assert other.get(first).status_code == 410 and other.post(first).status_code == 410
|
||||
assert other.get(second).status_code == 200
|
||||
assert other.post(second).status_code == 200
|
||||
assert mail_of(adapters) == "second@example.test"
|
||||
|
||||
|
||||
def test_the_request_works_when_the_user_has_no_address_yet(people, adapters):
|
||||
client = signed_in(adapters, "nomail")
|
||||
assert (
|
||||
"–" in client.get("/email").content.decode()
|
||||
or "–" in client.get("/email").content.decode()
|
||||
)
|
||||
assert ask(client).status_code == 302
|
||||
assert len(mails(NEW)) == 1
|
||||
|
||||
|
||||
def test_a_request_is_unavailable_when_the_directory_is_down(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
adapters.directory.unavailable = True
|
||||
assert ask(client).status_code == 503
|
||||
assert mails() == [] and PendingEmailChange.objects.count() == 0
|
||||
assert AuditEntry.objects.get(action="email.change_failed").detail["reason"] == "unavailable"
|
||||
|
||||
|
||||
# -- the confirmation link -----------------------------------------
|
||||
|
||||
|
||||
def test_opening_the_confirmation_link_changes_and_consumes_nothing(people, adapters):
|
||||
path = request_link(adapters)
|
||||
before = list(AuditEntry.objects.values_list("id", flat=True))
|
||||
visitor = Client() # not signed in
|
||||
for _ in range(3):
|
||||
response = visitor.get(path)
|
||||
assert response.status_code == 200
|
||||
html = response.content.decode()
|
||||
assert OLD in html and NEW in html and "<form" in html and 'method="post"' in html
|
||||
assert "csrfmiddlewaretoken" in html
|
||||
assert (
|
||||
response["Cache-Control"].startswith("max-age=0")
|
||||
or "no-cache" in response["Cache-Control"]
|
||||
)
|
||||
assert visitor.head(path).status_code in (200, 405)
|
||||
assert mail_of(adapters) == OLD
|
||||
assert PendingEmailChange.objects.get().used_at is None
|
||||
assert len(mails()) == 1 and list(AuditEntry.objects.values_list("id", flat=True)) == before
|
||||
|
||||
|
||||
def test_confirming_writes_the_address_and_mails_the_old_one_an_undo_link(people, adapters):
|
||||
path = request_link(adapters)
|
||||
visitor = Client()
|
||||
response = visitor.post(path)
|
||||
assert (
|
||||
response.status_code == 200
|
||||
and "Your e-mail address was changed." in response.content.decode()
|
||||
)
|
||||
assert "A notice with a way to undo" in response.content.decode()
|
||||
assert mail_of(adapters) == NEW
|
||||
confirm = PendingEmailChange.objects.get(kind="confirm")
|
||||
assert confirm.used_at is not None
|
||||
undo = PendingEmailChange.objects.get(kind="undo")
|
||||
assert undo.old_address == OLD and undo.new_address == NEW
|
||||
assert undo.expires_at - undo.created_at == timedelta(hours=24)
|
||||
[notice] = mails(OLD)
|
||||
assert notice.template == "email_changed_notice"
|
||||
token = link_in(notice).rsplit("/", 1)[1]
|
||||
assert undo.token_hash == services.hash_token(token) and token != undo.token_hash
|
||||
assert NEW in notice.body_text and "24" in notice.body_text
|
||||
entry = AuditEntry.objects.get(action="email.changed")
|
||||
assert entry.actor == "alice" and entry.detail == {"notice": "queued"}
|
||||
|
||||
|
||||
def test_a_confirmation_works_once(people, adapters):
|
||||
path = request_link(adapters)
|
||||
visitor = Client()
|
||||
assert visitor.post(path).status_code == 200
|
||||
assert visitor.post(path).status_code == 410
|
||||
assert visitor.get(path).status_code == 410
|
||||
assert len(mails(OLD)) == 1
|
||||
|
||||
|
||||
def test_dead_links_all_look_alike(people, adapters):
|
||||
unknown = Client().get("/email/confirm/" + "a" * 43)
|
||||
assert unknown.status_code == 410 and DEAD in unknown.content.decode()
|
||||
|
||||
expired = request_link(adapters)
|
||||
PendingEmailChange.objects.update(expires_at=timezone.now() - timedelta(seconds=1))
|
||||
used = confirmed_undo_path(adapters) # a fresh pair of rows
|
||||
PendingEmailChange.objects.filter(kind="undo").update(used_at=timezone.now())
|
||||
|
||||
pages = {
|
||||
Client().get(expired).content,
|
||||
Client().get(used).content,
|
||||
Client().post(expired).content,
|
||||
Client().post(used).content,
|
||||
unknown.content,
|
||||
}
|
||||
assert len(pages) == 1 # the page does not say which case it is
|
||||
assert Client().post(expired).status_code == 410
|
||||
|
||||
|
||||
def test_a_confirmation_fails_cleanly_when_the_directory_is_down(people, adapters):
|
||||
path = request_link(adapters)
|
||||
adapters.directory.unavailable = True
|
||||
response = Client().post(path)
|
||||
assert response.status_code == 503
|
||||
adapters.directory.unavailable = False
|
||||
assert PendingEmailChange.objects.get().used_at is None
|
||||
assert mails(OLD) == [] and mail_of(adapters) == OLD
|
||||
assert AuditEntry.objects.get(action="email.change_failed").detail == {
|
||||
"reason": "unavailable",
|
||||
"stage": "confirm",
|
||||
}
|
||||
assert Client().post(path).status_code == 200 # and it works once the directory is back
|
||||
|
||||
|
||||
def test_a_refused_write_rolls_everything_back(people, adapters, monkeypatch):
|
||||
path = request_link(adapters)
|
||||
|
||||
def refuse(*args, **kwargs):
|
||||
raise ValidationFailed("no")
|
||||
|
||||
monkeypatch.setattr(adapters.directory, "update_attributes", refuse)
|
||||
assert Client().post(path).status_code == 503
|
||||
assert PendingEmailChange.objects.get().used_at is None and mails(OLD) == []
|
||||
assert "email.changed" not in actions()
|
||||
|
||||
|
||||
def test_a_user_without_an_address_can_change_it_without_a_notice(people, adapters):
|
||||
client = signed_in(adapters, "nomail")
|
||||
ask(client)
|
||||
path = link_in(mails(NEW)[0])
|
||||
response = Client().post(path)
|
||||
assert response.status_code == 200 and "A notice with a way" not in response.content.decode()
|
||||
assert Client().get(path).status_code == 410
|
||||
assert mail_of(adapters, "nomail") == NEW
|
||||
assert PendingEmailChange.objects.filter(kind="undo").count() == 0
|
||||
assert [m.recipient for m in mails()] == [NEW]
|
||||
assert AuditEntry.objects.get(action="email.changed").detail == {"notice": "skipped"}
|
||||
page = Client().get(link_in(mails(NEW)[0])) # nothing to show any more
|
||||
assert page.status_code == 410
|
||||
|
||||
|
||||
def test_the_confirmation_page_does_not_mention_a_notice_without_an_old_address(people, adapters):
|
||||
client = signed_in(adapters, "nomail")
|
||||
ask(client)
|
||||
html = Client().get(link_in(mails(NEW)[0])).content.decode()
|
||||
assert "A notice with a way" not in html and NEW in html
|
||||
|
||||
|
||||
# -- the undo link -----------------------------------------
|
||||
|
||||
|
||||
def test_opening_the_undo_link_changes_and_consumes_nothing(people, adapters):
|
||||
path = confirmed_undo_path(adapters)
|
||||
count = AuditEntry.objects.count()
|
||||
for _ in range(2):
|
||||
response = Client().get(path)
|
||||
assert response.status_code == 200
|
||||
html = response.content.decode()
|
||||
assert OLD in html and NEW in html and 'method="post"' in html
|
||||
assert mail_of(adapters) == NEW and AuditEntry.objects.count() == count
|
||||
assert PendingEmailChange.objects.get(kind="undo").used_at is None
|
||||
|
||||
|
||||
def test_undoing_restores_the_address_ends_the_sessions_and_tells_the_admin(people, adapters):
|
||||
path = confirmed_undo_path(adapters)
|
||||
alice_one, alice_two = signed_in(adapters), signed_in(adapters)
|
||||
bob = signed_in(adapters, "bob")
|
||||
response = Client().post(path)
|
||||
html = response.content.decode()
|
||||
assert response.status_code == 200 and "Please change your password now." in html
|
||||
assert mail_of(adapters) == OLD
|
||||
assert alice_one.get("/").status_code == 302 and alice_two.get("/").status_code == 302
|
||||
assert bob.get("/").status_code == 200 # only this user's sessions end
|
||||
assert UserSession.objects.filter(username="alice").count() == 0
|
||||
assert PendingEmailChange.objects.get(kind="undo").used_at is not None
|
||||
[admin] = mails(ADMIN)
|
||||
assert admin.template == "email_undone_admin" and "alice" in admin.body_text
|
||||
assert path.rsplit("/", 1)[1] not in admin.body_text and OLD not in admin.body_text
|
||||
entry = AuditEntry.objects.get(action="email.undone")
|
||||
assert entry.detail == {"sessions_ended": 3, "admin_notice": "queued"}
|
||||
assert Client().post(path).status_code == 410 and Client().get(path).status_code == 410
|
||||
|
||||
|
||||
def test_undoing_also_drops_a_request_made_in_the_meantime(people, adapters):
|
||||
path = confirmed_undo_path(adapters)
|
||||
signed_in(adapters)
|
||||
pending_client = signed_in(adapters)
|
||||
ask(pending_client, new="thief@example.test")
|
||||
stolen = link_in(mails("thief@example.test")[0])
|
||||
assert Client().post(path).status_code == 200
|
||||
assert Client().post(stolen).status_code == 410
|
||||
assert mail_of(adapters) == OLD
|
||||
|
||||
|
||||
def test_the_undo_is_unavailable_when_the_directory_is_down(people, adapters):
|
||||
path = confirmed_undo_path(adapters)
|
||||
other = signed_in(adapters)
|
||||
adapters.directory.unavailable = True
|
||||
assert Client().post(path).status_code == 503
|
||||
adapters.directory.unavailable = False
|
||||
assert PendingEmailChange.objects.get(kind="undo").used_at is None
|
||||
assert other.get("/").status_code == 200 and mails(ADMIN) == []
|
||||
assert "email.undone" not in actions()
|
||||
|
||||
|
||||
def test_an_undo_after_another_change_changes_nothing(people, adapters):
|
||||
path = confirmed_undo_path(adapters)
|
||||
adapters.directory.update_attributes("alice", {"mail": "third@example.test"})
|
||||
other = signed_in(adapters)
|
||||
response = Client().post(path)
|
||||
assert response.status_code == 409 and "changed again" in response.content.decode()
|
||||
assert mail_of(adapters) == "third@example.test"
|
||||
assert other.get("/").status_code == 200 and mails(ADMIN) == []
|
||||
assert PendingEmailChange.objects.get(kind="undo").used_at is None
|
||||
|
||||
|
||||
def test_an_undo_without_an_admin_address_still_succeeds_and_logs_an_error(
|
||||
people, adapters, settings, caplog
|
||||
):
|
||||
settings.ADMIN_NOTIFY_EMAIL = ""
|
||||
path = confirmed_undo_path(adapters)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
response = Client().post(path)
|
||||
assert response.status_code == 200 and mail_of(adapters) == OLD
|
||||
assert mails(ADMIN) == [] and len(mails()) == 2 # the two earlier mails only
|
||||
[record] = [r for r in caplog.records if "ADMIN_NOTIFY_EMAIL" in r.getMessage()]
|
||||
assert record.levelno == logging.ERROR and "user=alice" in record.getMessage()
|
||||
assert OLD not in caplog.text and NEW not in caplog.text
|
||||
assert AuditEntry.objects.get(action="email.undone").detail["admin_notice"] == "skipped"
|
||||
|
||||
|
||||
def test_an_expired_undo_link_is_dead(people, adapters):
|
||||
path = confirmed_undo_path(adapters)
|
||||
PendingEmailChange.objects.filter(kind="undo").update(
|
||||
expires_at=timezone.now() - timedelta(seconds=1)
|
||||
)
|
||||
assert Client().get(path).status_code == 410
|
||||
assert Client().post(path).status_code == 410
|
||||
assert mail_of(adapters) == NEW
|
||||
|
||||
|
||||
# -- guards -----------------------------------------
|
||||
|
||||
|
||||
def test_the_page_needs_a_sign_in(people):
|
||||
client = Client()
|
||||
assert client.get("/email").status_code == 302
|
||||
assert "/auth/login" in client.get("/email")["Location"]
|
||||
assert client.post("/email", {"new_email": NEW, "current_password": CURRENT}).status_code == 302
|
||||
assert mails() == []
|
||||
|
||||
|
||||
def test_wrong_methods_are_refused(people, adapters):
|
||||
path = request_link(adapters)
|
||||
client = signed_in(adapters)
|
||||
for target in ("/email", path, "/email/undo/abc"):
|
||||
assert client.put(target).status_code == 405
|
||||
assert client.delete(target).status_code == 405
|
||||
assert mail_of(adapters) == OLD
|
||||
|
||||
|
||||
def test_a_post_without_a_csrf_token_changes_nothing(people, adapters):
|
||||
path = request_link(adapters)
|
||||
guarded = Client(enforce_csrf_checks=True)
|
||||
assert guarded.post(path).status_code == 403
|
||||
assert mail_of(adapters) == OLD and PendingEmailChange.objects.get().used_at is None
|
||||
undo = confirmed_undo_path(adapters)
|
||||
assert guarded.post(undo).status_code == 403
|
||||
assert mail_of(adapters) == NEW
|
||||
signed = Client(enforce_csrf_checks=True)
|
||||
signed.cookies.update(signed_in(adapters).cookies)
|
||||
before = len(mails())
|
||||
assert (
|
||||
signed.post(
|
||||
"/email", {"new_email": "x@example.test", "current_password": CURRENT}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert len(mails()) == before
|
||||
|
||||
|
||||
def test_the_form_has_no_username_and_ignores_one(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
html = client.get("/email").content.decode()
|
||||
assert "username" not in html.lower().replace("user name", "")
|
||||
client.post(
|
||||
"/email", {"new_email": NEW, "current_password": CURRENT, "username": "bob", "sam": "bob"}
|
||||
)
|
||||
assert PendingEmailChange.objects.get().user_key == "alice"
|
||||
assert mail_of(adapters, "bob") == "bob@example.test"
|
||||
|
||||
|
||||
# -- failures after the directory changed -----------------------------------------
|
||||
|
||||
|
||||
def test_a_failure_after_the_confirming_write_is_reported_as_unclear(
|
||||
people, adapters, monkeypatch, caplog
|
||||
):
|
||||
path = request_link(adapters)
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise RuntimeError("database trouble " + OLD)
|
||||
|
||||
monkeypatch.setattr(services.mailer, "enqueue", broken)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
response = Client().post(path)
|
||||
assert response.status_code == 500 and "Result unclear" in response.content.decode()
|
||||
assert mail_of(adapters) == NEW # the directory did change
|
||||
assert "email.changed" not in actions()
|
||||
entry = AuditEntry.objects.get(action="email.change_failed")
|
||||
assert entry.detail == {"reason": "unclear", "step": "queue_notice"}
|
||||
record = next(r for r in caplog.records if "did not record it" in r.getMessage())
|
||||
assert record.levelno == logging.ERROR and "user=alice" in record.getMessage()
|
||||
assert "RuntimeError" in record.getMessage()
|
||||
for secret in (OLD, NEW, path.rsplit("/", 1)[1], "database trouble"):
|
||||
assert secret not in caplog.text
|
||||
|
||||
|
||||
def test_a_failure_after_the_undoing_write_still_ends_the_sessions(
|
||||
people, adapters, monkeypatch, caplog
|
||||
):
|
||||
path = confirmed_undo_path(adapters)
|
||||
other = signed_in(adapters)
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise RuntimeError("database trouble")
|
||||
|
||||
monkeypatch.setattr(services.mailer, "enqueue", broken)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
response = Client().post(path)
|
||||
assert response.status_code == 500
|
||||
assert mail_of(adapters) == OLD and other.get("/").status_code == 302
|
||||
assert AuditEntry.objects.get(action="email.undo_failed").detail["reason"] == "unclear"
|
||||
assert "user=alice" in caplog.text and OLD not in caplog.text
|
||||
|
||||
|
||||
def test_a_failure_before_any_write_leaves_the_request_clean(people, adapters, monkeypatch):
|
||||
client = signed_in(adapters)
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(services.mailer, "enqueue", broken)
|
||||
with pytest.raises(RuntimeError):
|
||||
ask(client)
|
||||
assert PendingEmailChange.objects.count() == 0
|
||||
assert AuditEntry.objects.get(action="email.change_failed").detail == {"reason": "error"}
|
||||
|
||||
|
||||
# -- no secrets anywhere -----------------------------------------
|
||||
|
||||
|
||||
def test_no_password_token_or_address_reaches_logs_or_the_audit(people, adapters, caplog):
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
client = signed_in(adapters)
|
||||
ask(client, password=WRONG)
|
||||
ask(client)
|
||||
confirm = link_in(mails(NEW)[0])
|
||||
Client().get(confirm)
|
||||
Client().post(confirm)
|
||||
undo = link_in(mails(OLD)[0])
|
||||
Client().post(undo)
|
||||
Client().post(undo)
|
||||
tokens = [confirm.rsplit("/", 1)[1], undo.rsplit("/", 1)[1]]
|
||||
stored = " ".join(str(e.detail) + e.action + e.target + e.ip for e in AuditEntry.objects.all())
|
||||
stored += " ".join(str(dict(s.get_decoded())) for s in UserSession.objects.all())
|
||||
for secret in (CURRENT, WRONG, OLD, NEW, ADMIN, *tokens):
|
||||
assert secret not in caplog.text, secret
|
||||
assert secret not in stored, secret
|
||||
hashes = " ".join(PendingEmailChange.objects.values_list("token_hash", flat=True))
|
||||
assert all(token not in hashes for token in tokens)
|
||||
|
||||
|
||||
def test_the_password_field_is_masked_in_error_reports(people, adapters, monkeypatch):
|
||||
from django.views.debug import SafeExceptionReporterFilter
|
||||
|
||||
def crash(*args, **kwargs):
|
||||
raise RuntimeError("crash")
|
||||
|
||||
monkeypatch.setattr(services, "request_email_change", crash)
|
||||
client = signed_in(adapters)
|
||||
client.raise_request_exception = False
|
||||
response = ask(client)
|
||||
assert response.status_code == 500
|
||||
request = response.wsgi_request
|
||||
shown = SafeExceptionReporterFilter().get_post_parameters(request)
|
||||
assert shown["current_password"] == SafeExceptionReporterFilter.cleansed_substitute
|
||||
|
||||
|
||||
def test_the_migrations_are_complete(db):
|
||||
call_command("makemigrations", check=True, dry_run=True, verbosity=0)
|
||||
|
||||
|
||||
def test_an_unavailable_directory_error_type_is_what_the_service_raises(people, adapters):
|
||||
adapters.directory.unavailable = True
|
||||
from portal.accounts.sessions import SessionUser
|
||||
|
||||
with pytest.raises(DirectoryUnavailable):
|
||||
services.request_email_change(SessionUser.from_sam("alice"), NEW, CURRENT, ip="127.0.0.1")
|
||||
|
||||
|
||||
def test_a_link_token_is_blanked_in_the_request_log_path():
|
||||
from portal.config.logging import redact
|
||||
|
||||
assert redact("/email/confirm/abc-DEF_123") == "/email/confirm/<redacted>"
|
||||
assert redact("Internal Server Error: /email/undo/abc") == (
|
||||
"Internal Server Error: /email/undo/<redacted>"
|
||||
)
|
||||
assert redact("/password") == "/password"
|
||||
|
||||
|
||||
def test_the_configured_handler_blanks_tokens_from_every_logger():
|
||||
import io
|
||||
import logging.config
|
||||
|
||||
from portal.config.logging import build_logging
|
||||
|
||||
root = logging.getLogger()
|
||||
saved = (root.handlers[:], root.level)
|
||||
request_logger = logging.getLogger("django.request")
|
||||
saved_request = (request_logger.handlers[:], request_logger.level)
|
||||
try:
|
||||
logging.config.dictConfig(build_logging("INFO"))
|
||||
handler = root.handlers[0]
|
||||
stream = io.StringIO()
|
||||
handler.setStream(stream)
|
||||
logging.getLogger("django.security.csrf").warning(
|
||||
"Forbidden (CSRF token missing): /email/confirm/SECRET-token_123"
|
||||
)
|
||||
output = stream.getvalue()
|
||||
finally:
|
||||
root.handlers[:], root.level = saved
|
||||
request_logger.handlers[:], request_logger.level = saved_request
|
||||
assert "SECRET-token_123" not in output and "/email/confirm/<redacted>" in output
|
||||
Reference in New Issue
Block a user