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
110 lines
3.8 KiB
Python
110 lines
3.8 KiB
Python
"""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"]})
|