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