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",
|
||||
|
||||
Reference in New Issue
Block a user