Files
ping-mee-self-service-platform/portal/profile/views.py
T
Ebbe BaßandClaude Sonnet 5.5 94c1d39c9d 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
2026-10-19 10:05:41 +02:00

271 lines
9.9 KiB
Python

"""The password, profile and e-mail pages (FR-5..FR-11). No business logic here (AD-17)."""
from __future__ import annotations
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
from portal.accounts.decorators import login_required
from portal.accounts.identity import remember
from portal.accounts.throttle import Throttled
from portal.adapters.directory import DirectoryUnavailable
from portal.adapters.registry import get_directory
from . import services
from .forms import DETAIL_FIELDS, DetailsForm, EmailChangeForm, PasswordChangeForm
from .models import PendingEmailChange
logger = logging.getLogger(__name__)
DONE_FLAG = "password_changed"
# Rule keys from services.local_hints, in plain words.
HINT_MESSAGES = {
"too_short": lambda: _("The password is too short."),
"too_few_groups": lambda: _(
"The password needs characters from more groups: upper case, lower case, digits, special."
),
}
def _page(request, form, *, policy=None, status=200, throttled=False):
rules = services.describe_policy(policy) if policy is not None else []
partly_known = policy is not None and policy.fine_grained and not policy.fine_grained_readable
context = {
"form": form,
"rules": rules,
"partly_known": partly_known,
"throttled": throttled,
}
return render(request, "profile/password.html", context, status=status)
def _unavailable(request):
return render(request, "accounts/unavailable.html", status=503)
@login_required
@sensitive_post_parameters("current_password", "new_password", "confirm_password")
@require_http_methods(["GET", "POST"])
def password(request):
user = request.portal_user.user
if request.method == "GET":
try:
policy = get_directory().password_policy(user.sam)
except DirectoryUnavailable:
return _unavailable(request)
return _page(request, PasswordChangeForm(), policy=policy)
form = PasswordChangeForm(request.POST)
if not form.is_valid():
return _page(request, form, policy=_policy_or_none(user), status=400)
try:
result = services.change_password(
user,
form.cleaned_data["current_password"],
form.cleaned_data["new_password"],
ip=request.client_ip,
session_key=request.session.session_key,
)
except Throttled:
return _page(request, PasswordChangeForm(), status=429, throttled=True)
except services.WrongCurrentPassword as exc:
form.add_error("current_password", _("The current password is not correct."))
return _page(request, form, policy=exc.policy, status=400)
except services.PasswordNotAccepted as exc:
messages = [HINT_MESSAGES[key]() for key in exc.reasons] or [
_(
"The new password does not meet the rules above, "
"or it was used or changed too recently."
)
]
for message in messages:
form.add_error("new_password", message)
return _page(request, form, policy=exc.policy, status=400)
except DirectoryUnavailable:
return _unavailable(request)
except services.OutcomeUnknown:
return render(request, "profile/password_unknown.html", status=500)
request.session[DONE_FLAG] = {"mail": result.mail_queued}
return redirect("password-done")
def _policy_or_none(user):
try:
return get_directory().password_policy(user.sam)
except DirectoryUnavailable:
return None
@login_required
def password_done(request):
flag = request.session.pop(DONE_FLAG, None)
if flag is None: # opened directly: nothing to report
return redirect("password")
return render(request, "profile/password_done.html", {"mail": flag["mail"]})
# -- profile details (FR-9) -------------------------------------------------------------------
NOTICE_FLAG = "profile_notice" # "saved" or "unchanged", shown once after the redirect
def _details_page(request, form, details, *, status=200, notice=None):
context = {
"form": form,
"details": details,
"notice": notice,
"fields": [
{"field": form[name], "label": label, "max_length": max_length}
for name, (label, max_length, _required) in DETAIL_FIELDS.items()
],
}
return render(request, "profile/details.html", context, status=status)
@login_required
@require_http_methods(["GET", "POST"])
def profile(request):
user = request.portal_user.user # the session user only: nothing is read from the request
if request.method == "GET":
try:
details = services.load_details(user)
except DirectoryUnavailable:
return _unavailable(request)
notice = request.session.pop(NOTICE_FLAG, None)
return _details_page(request, DetailsForm(initial=details.values), details, notice=notice)
form = DetailsForm(request.POST)
form.is_valid() # trims; the checks need the directory value and run in the service
try:
result = services.update_details(user, form.cleaned_data, ip=request.client_ip)
except services.DetailsInvalid as exc:
form.apply_errors(exc.errors)
return _details_page(request, form, exc.details, status=400)
except services.DetailsNotAccepted as exc:
form.add_error(None, _("A value was not accepted. Please check your entries."))
return _details_page(request, form, exc.details, status=400)
except DirectoryUnavailable:
return _unavailable(request)
except services.OutcomeUnknown:
return render(request, "profile/details_unknown.html", status=500)
if result.changed:
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")