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:
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProfileConfig(AppConfig):
|
||||
name = "portal.profile"
|
||||
label = "profile"
|
||||
@@ -0,0 +1,35 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
MAX_PASSWORD_LENGTH = 256 # Active Directory's limit
|
||||
|
||||
|
||||
def _password_field():
|
||||
return forms.CharField(
|
||||
max_length=MAX_PASSWORD_LENGTH,
|
||||
strip=False, # spaces can be part of a password
|
||||
widget=forms.PasswordInput(render_value=False),
|
||||
error_messages={
|
||||
"required": _("Please fill in this field."),
|
||||
"max_length": _("This password is too long."),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PasswordChangeForm(forms.Form):
|
||||
current_password = _password_field()
|
||||
new_password = _password_field()
|
||||
confirm_password = _password_field()
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
current, new, confirm = (
|
||||
data.get("current_password"),
|
||||
data.get("new_password"),
|
||||
data.get("confirm_password"),
|
||||
)
|
||||
if new and confirm and new != confirm:
|
||||
self.add_error("confirm_password", _("The two new passwords are not the same."))
|
||||
elif new and current and new == current:
|
||||
self.add_error("new_password", _("The new password must be different."))
|
||||
return data
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Password change for the signed-in user (FR-5, FR-6, FR-7; AD-3, AD-4, AD-8).
|
||||
|
||||
The order is fixed: throttle check, policy read, verification of the current password, local
|
||||
hints, then the directory change, the ended sessions, the audit entry and the queued mail in
|
||||
one transaction. Passwords live only in local variables here: they are never logged, audited,
|
||||
stored or put into an exception.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import ngettext
|
||||
|
||||
from portal.accounts import throttle
|
||||
from portal.accounts.sessions import SessionUser, end_sessions
|
||||
from portal.adapters.directory import DirectoryUnavailable, PasswordPolicy, PasswordRejected
|
||||
from portal.adapters.registry import get_directory
|
||||
from portal.audit import services as audit
|
||||
from portal.mailer import services as mailer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENDPOINT = "password-change"
|
||||
CHARACTER_GROUPS_REQUIRED = 3 # AD complexity: three of upper, lower, digit, other
|
||||
|
||||
|
||||
class WrongCurrentPassword(Exception):
|
||||
def __init__(self, policy: PasswordPolicy | None = None) -> None:
|
||||
self.policy = policy
|
||||
super().__init__("The current password is not correct")
|
||||
|
||||
|
||||
class PasswordNotAccepted(Exception):
|
||||
"""The new password breaks a rule. `reasons` holds keys for rules the portal could name."""
|
||||
|
||||
def __init__(self, reasons: list[str], policy: PasswordPolicy | None = None) -> None:
|
||||
self.reasons = reasons
|
||||
self.policy = policy
|
||||
super().__init__("The new password is not accepted")
|
||||
|
||||
|
||||
class OutcomeUnknown(Exception):
|
||||
"""The directory accepted the new password but the portal could not finish its own part."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Changed:
|
||||
mail_queued: bool
|
||||
other_sessions_ended: int
|
||||
|
||||
|
||||
# -- the rules, in words ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def describe_policy(policy: PasswordPolicy) -> list[str]:
|
||||
"""The user's password rules as sentences in the active language."""
|
||||
rules = [
|
||||
ngettext(
|
||||
"At least %(n)s character",
|
||||
"At least %(n)s characters",
|
||||
policy.min_length,
|
||||
)
|
||||
% {"n": policy.min_length}
|
||||
]
|
||||
if policy.complexity:
|
||||
rules.append(
|
||||
_(
|
||||
"Characters from at least three of these groups: upper case letters, "
|
||||
"lower case letters, digits and special characters"
|
||||
)
|
||||
)
|
||||
rules.append(_("No part of your user name or display name"))
|
||||
if policy.history_length:
|
||||
rules.append(
|
||||
ngettext(
|
||||
"Not your last %(n)s password",
|
||||
"Not one of your last %(n)s passwords",
|
||||
policy.history_length,
|
||||
)
|
||||
% {"n": policy.history_length}
|
||||
)
|
||||
if policy.min_age:
|
||||
rules.append(_min_age_rule(policy.min_age))
|
||||
if policy.max_age:
|
||||
days = max(1, round(policy.max_age.total_seconds() / 86400))
|
||||
rules.append(
|
||||
ngettext(
|
||||
"A password is valid for %(n)s day",
|
||||
"A password is valid for %(n)s days",
|
||||
days,
|
||||
)
|
||||
% {"n": days}
|
||||
)
|
||||
return rules
|
||||
|
||||
|
||||
def _min_age_rule(age: timedelta) -> str:
|
||||
if age >= timedelta(days=1):
|
||||
days = round(age.total_seconds() / 86400)
|
||||
return ngettext(
|
||||
"A new password can be changed again after %(n)s day",
|
||||
"A new password can be changed again after %(n)s days",
|
||||
days,
|
||||
) % {"n": days}
|
||||
hours = max(1, round(age.total_seconds() / 3600))
|
||||
return ngettext(
|
||||
"A new password can be changed again after %(n)s hour",
|
||||
"A new password can be changed again after %(n)s hours",
|
||||
hours,
|
||||
) % {"n": hours}
|
||||
|
||||
|
||||
def _character_groups(password: str) -> int:
|
||||
groups = set()
|
||||
for char in password:
|
||||
if char.isupper():
|
||||
groups.add("upper")
|
||||
elif char.islower():
|
||||
groups.add("lower")
|
||||
elif char.isdigit():
|
||||
groups.add("digit")
|
||||
else:
|
||||
groups.add("other")
|
||||
return len(groups)
|
||||
|
||||
|
||||
def local_hints(new: str, policy: PasswordPolicy) -> list[str]:
|
||||
"""Rule keys the portal can check itself; the directory stays the authority."""
|
||||
reasons = []
|
||||
if len(new) < policy.min_length:
|
||||
reasons.append("too_short")
|
||||
if policy.complexity and _character_groups(new) < CHARACTER_GROUPS_REQUIRED:
|
||||
reasons.append("too_few_groups")
|
||||
return reasons
|
||||
|
||||
|
||||
# -- the limit -----------------------------------------
|
||||
|
||||
|
||||
def failure_limit(policy: PasswordPolicy) -> int:
|
||||
"""Wrong current passwords allowed: below the directory's lockout threshold (FR-6)."""
|
||||
threshold = policy.lockout_threshold
|
||||
if threshold <= 0:
|
||||
return throttle.THROTTLE_MAX_FAILURES
|
||||
return max(1, min(throttle.THROTTLE_MAX_FAILURES, threshold - 1))
|
||||
|
||||
|
||||
def pause_seconds(policy: PasswordPolicy) -> int:
|
||||
window = int(policy.lockout_window.total_seconds()) if policy.lockout_window else 0
|
||||
return max(throttle.THROTTLE_PAUSE_SECONDS, window)
|
||||
|
||||
|
||||
# -- the change -----------------------------------------
|
||||
|
||||
|
||||
def _record_failure(user: SessionUser, ip: str, reason: str, **detail) -> None:
|
||||
audit.record_failure(
|
||||
user.key, "password.change_failed", ip=ip, detail={"reason": reason, **detail}
|
||||
)
|
||||
|
||||
|
||||
def change_password(
|
||||
user: SessionUser, current: str, new: str, *, ip: str, session_key: str | None
|
||||
) -> Changed:
|
||||
"""Change the signed-in user's password.
|
||||
|
||||
Raises Throttled, WrongCurrentPassword, PasswordNotAccepted, DirectoryUnavailable or
|
||||
OutcomeUnknown. Must be called outside an open transaction.
|
||||
"""
|
||||
try:
|
||||
throttle.check(ENDPOINT, user=user.key, ip=ip or None)
|
||||
except throttle.Throttled:
|
||||
audit.record_failure(user.key, "password.throttled", ip=ip)
|
||||
raise
|
||||
|
||||
directory = get_directory()
|
||||
try:
|
||||
policy = directory.password_policy(user.sam)
|
||||
if not directory.verify_password(user.sam, current):
|
||||
throttle.hit(
|
||||
ENDPOINT,
|
||||
user=user.key,
|
||||
ip=ip or None,
|
||||
max_failures=failure_limit(policy),
|
||||
pause_seconds=pause_seconds(policy),
|
||||
)
|
||||
_record_failure(user, ip, "wrong_current")
|
||||
raise WrongCurrentPassword(policy)
|
||||
account = directory.find_user(user.sam)
|
||||
except DirectoryUnavailable:
|
||||
_record_failure(user, ip, "unavailable")
|
||||
raise
|
||||
|
||||
reasons = local_hints(new, policy)
|
||||
if reasons:
|
||||
_record_failure(user, ip, "policy", rules=reasons)
|
||||
raise PasswordNotAccepted(reasons, policy)
|
||||
|
||||
changed_in_directory = False
|
||||
try:
|
||||
with transaction.atomic():
|
||||
directory.change_password(user.sam, current, new)
|
||||
changed_in_directory = True
|
||||
ended = end_sessions(user, except_session_key=session_key)
|
||||
mail_queued = _queue_mail(account)
|
||||
audit.record(
|
||||
user.key,
|
||||
"password.changed",
|
||||
ip=ip,
|
||||
detail={
|
||||
"other_sessions_ended": ended,
|
||||
"mail": "queued" if mail_queued else "skipped",
|
||||
},
|
||||
)
|
||||
except PasswordRejected as exc:
|
||||
if exc.reason == "wrong_current": # the password changed between the check and the change
|
||||
raise WrongCurrentPassword(policy) from exc
|
||||
_record_failure(user, ip, "policy")
|
||||
raise PasswordNotAccepted([], policy) from exc
|
||||
except DirectoryUnavailable:
|
||||
_record_failure(user, ip, "unavailable")
|
||||
raise
|
||||
except Exception as exc:
|
||||
if changed_in_directory:
|
||||
# AD-4: the directory changed but the portal's part did not commit.
|
||||
logger.error("Password changed in the directory but the portal did not record it")
|
||||
raise OutcomeUnknown("The password was changed but not recorded") from exc
|
||||
raise
|
||||
|
||||
throttle.reset(ENDPOINT, user=user.key)
|
||||
return Changed(mail_queued=mail_queued, other_sessions_ended=ended)
|
||||
|
||||
|
||||
def _queue_mail(account) -> bool:
|
||||
"""Queue the confirmation e-mail inside the caller's transaction; False if there is none."""
|
||||
address = (account.mail if account else "") or ""
|
||||
if not mailer.is_valid_address(address):
|
||||
return False
|
||||
name = account.display_name or " ".join(p for p in (account.given_name, account.surname) if p)
|
||||
now = timezone.now().astimezone(ZoneInfo(settings.DISPLAY_TIME_ZONE))
|
||||
mailer.enqueue(
|
||||
"password_changed",
|
||||
address,
|
||||
{"name": name or account.sam, "changed_at": now, "zone_name": settings.DISPLAY_TIME_ZONE},
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("password", views.password, name="password"),
|
||||
path("password/done", views.password_done, name="password-done"),
|
||||
]
|
||||
@@ -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"]})
|
||||
Reference in New Issue
Block a user