Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
11 KiB
title, type, ticket, epic, created, status, baseline_revision, route, route_source, risk, review, review_source, context
| title | type | ticket | epic | created | status | baseline_revision | route | route_source | risk | review | review_source | context | |||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Password change | feature | 1 | epic-account-self-service | 2026-10-18 | approved | ead5670 |
full | auto | high | full | pinned |
|
Intent
Problem: Every password change still goes through the admin. A signed-in user has no way to change their own AD password, see the rules first, or be told what happened.
Approach: A new profile app (spine: password change and Password Policy live in profile) with a service change_password and one page at /password. The page lists the user's password policy read from AD, asks for the current password and the new one twice, and applies the change over LDAPS through the directory adapter. After a success the user's other sessions end and a confirmation e-mail is queued, all in one transaction with the audit entry.
Boundaries & Constraints
Always: AD-2, AD-3, AD-4, AD-8, AD-17 apply. The acting user is read from the session only; the view and service accept no username. The order is fixed: validate the form locally, throttle.check (before any AD call), verify_password, local policy hints, change_password inside the transaction with audit, queued mail and ended sessions. A failed AD call rolls back and writes a failure entry after the rollback. Passwords exist only in local variables: never logged, audited, stored, put in an exception message, echoed back into the form, or kept in the session. The view is marked sensitive_post_parameters. Messages are plain words with no technical terms and the pause is never shown in numbers. Every new string has German formal text, informal where it differs, in portal/locale. The mail names the time of the change with its time zone and never contains the password.
Never: No change to another user, no username in the URL or form. No password reset for forgotten or expired passwords (the page says to contact the Admin). No new AD write path: the adapter's change_password is the only one. No storage of the current password for later steps. No JavaScript requirement: the form works as a plain POST.
I/O & Edge-Case Matrix
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|---|---|---|---|
| Open the page | Signed-in user | Effect text, the policy rules in words, three empty fields; menu entry "Change password" active | Directory down: the page says it is not possible now, no form |
| Policy shown | Domain policy, or a fine-grained policy that applies | Minimum length, character kinds when complexity is on, how many old passwords are remembered, minimum age, expiry | Fine-grained policy not readable: rules shown with a note that a stricter rule may apply |
| Success | Correct current, new password accepted by AD | AD changed; other sessions of the user ended, this one kept; password.changed audited; e-mail queued; redirect to a done page |
n/a |
| Wrong current | Current password does not match | "The current password is not correct." Nothing changed; counts toward the limit; failure audited | No AD change is attempted |
| Limit reached | The effective number of failed current-password checks (5, or the AD lockout threshold minus 1 when that is lower) | "Too many attempts. Please wait a few minutes." (HTTP 429), no AD call, no mail; throttled attempt audited | Recovers after the pause or after a success |
| Mismatch or empty | New and repeat differ, a field empty, over 256 characters | Error under the field and in a summary at the top; nothing sent to AD; not counted | n/a |
| Same as current | New equals current | "The new password must be different." Not sent to AD | n/a |
| Locally detectable rule | Shorter than the minimum, or too few character kinds | Specific reason ("The password is too short.") after the current password was verified; not sent to AD; not counted | n/a |
| AD rejects the new password | History, age or another rule | "The new password does not meet the rules above, or it was used or changed too recently." Nothing changed; audited as policy; not counted |
n/a |
| No usable e-mail address | AD mail empty or invalid |
Password changes anyway; no mail; the done page omits the e-mail sentence; audit detail records mail: skipped |
n/a |
| Directory down during the change | AD unavailable | "This is not possible right now. Please try again later." Nothing changes; failure audited | Transaction rolled back |
| Not signed in | Any request | Redirect to sign-in | n/a |
| Wrong method or CSRF | GET to the change, POST without token | 405 or 403; nothing changes | n/a |
Code Map
portal/profile/-- new app:services.py(change_password,describe_policy,local_hints; typed errors),forms.py(PasswordChangeForm, three password inputs withrender_value=False),views.py(password,password_done),urls.py,apps.py. No models.portal/templates/profile/password.html,password_done.html,portal/templates/mail/password_changed/{subject,body}.txt.portal/shell/menu.py-- add the entry "Change password" for everyone.portal/static/portal/shell.jsandshell.css-- on submit the primary button gets a spinner and is disabled (without blocking the submit); the error summary takes focus on load. Both optional enhancements.portal/config/settings.py,urls.py-- register the app and the routes.- Reuse, unchanged:
accounts.throttle(check,hit,reset),accounts.sessions.end_sessions,audit.services,mailer.services.enqueue,adapters.directory,accounts.decorators.login_required. portal/tests/--test_password_change.py(service and view with the fake directory and fake mailer), additions totest_translations.pyas needed.
Tasks & Acceptance
Execution:
profile/services.py-- the ordered flow, throttle counting only for a wrong current password with the limit derived from the policy's lockout threshold, one transaction for AD call, audit, mail and session ending -- AD-4, AD-8profile/services.py-- policy in words and the local hints (minimum length, character kinds) -- FR-5profile/forms.py,views.py, templates, menu entry -- the page, the done page, the error mapping to messages and status codes -- AD-17- mail template
password_changedin German formal with the time and zone, no password shell.jsandshell.css-- spinner and focus on the summary- German and informal German text for every new string
- Tests for every matrix row, plus: no password in logs, audit, session or response; another user's sessions untouched; a rolled-back change queues no mail and ends no session
Acceptance Criteria:
- Given a signed-in user, when they open
/password, then the policy rules from the directory are listed before any field and the page states the effect. - Given the correct current password and an acceptable new one, when they submit, then the directory password changed, their other sessions are gone and this one stays, an audit entry exists and one mail is queued.
- Given a wrong current password as often as the effective limit (5, or lower when AD locks sooner), when the next request arrives, then it is refused with 429 before any directory call and the attempt is audited.
- Given AD refuses the new password, when they submit, then nothing changed, no session ended, no mail queued, and the message is in plain words.
- Given any request, then no password appears in a log line, an audit entry, the session, the database or the rendered response.
- Given a failure after the AD call inside the transaction, then the error is logged at error level with the change details (AD-4) and the user is told the outcome is unknown.
Design Notes
Counting. Only a wrong current password counts toward the limit (FR-6). Policy rejections and local mistakes do not: they happen after the current password was proven, and punishing them would lock out people who are only choosing a password. The limit uses the existing password-change endpoint counters per user and per IP with the default of 5 failures and a 15-minute pause.
Account lockout. verify_password binds as the user, so every wrong attempt also raises AD's own bad-password count, and attempts made elsewhere (for example at authentik) count too. The directory adapter therefore reads lockoutThreshold and lockoutObservationWindow (or the fine-grained msDS-Lockout* values) with the password policy (built first, on its own branch, and merged before this ticket). The portal's limit is min(5, threshold - 1) and at least 1; with a threshold of 0 (the domain never locks) it stays 5. The pause is at least the observation window. A threshold of 1 cannot be protected from the portal; the page still works and the audit notes it.
One verification per change. The portal verifies the current password once, then calls the adapter's change_password, which authenticates as the user again. That is a second correct bind, not a second chance for an attacker.
Local hints. The hints (too short, too few character kinds) only improve the message; AD stays the authority. Rules the portal cannot know (history, minimum age, parts of the name) collapse into the generic policy message.
Status codes. Validation and policy errors re-render the form with 400, the limit gives 429, the directory being down gives 503, success redirects (POST, redirect, GET) to the done page, which reads a one-time session flag for the e-mail sentence.
Expired passwords. A user whose password has expired cannot bind, so their current-password check fails and counts. The sign-in already sends such users to the Admin; the page text says the same.
Verification
Commands:
uv run pytest-- expected: all tests pass, including the new onesuv run ruff check portal manage.py docker-- expected: no findingsuv run python manage.py makemigrations --check --dry-run-- expected: no changes detected
Done checkpoint (you, on the Linux host against the test OU): sign in as ptest-user1; the rules match the domain policy; a wrong current password shows the message; five wrong ones block the sixth without touching AD (check that ptest-user1 is not locked out in AD); a too-short and a history-rejected password show the right messages; a good change works, a second session of the same user in another browser is signed out, and the confirmation mail arrives with the time and no password; ptest-user1's new password works at authentik. Reset the test password afterwards with the test-environment script.