Profile page at /profile with first name, last name, display name, telephone and mobile read from AD and shown with the username and e-mail. Only changed fields are written through the directory adapter in one transaction with the audit entry (field names only); numbers are accepted in any common notation and stored as +country code and digits; names are required. Plain-language errors per field, German text and 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
162 lines
5.9 KiB
Python
162 lines
5.9 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.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, 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"]})
|
|
|
|
|
|
# -- 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")
|