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
271 lines
9.9 KiB
Python
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")
|