feat: add password change

Password page with the AD policy listed first, ordered service (throttle limit below the lockout threshold, verification, local hints, directory change, ended sessions, audit and confirmation mail in one transaction), plain-language errors, German text and tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
2026-10-18 10:08:17 +02:00
co-authored by Claude Sonnet 5.5
parent a1a766adef
commit 49c7fd4a0b
22 changed files with 1346 additions and 17 deletions
+109
View File
@@ -0,0 +1,109 @@
"""The password page (FR-5..FR-7). No business logic here: see services.py (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.debug import sensitive_post_parameters
from django.views.decorators.http import require_http_methods
from portal.accounts.decorators import login_required
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 PasswordChangeForm
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"]})