--- title: 'Password change' type: 'feature' ticket: '1' epic: 'epic-account-self-service' created: '2026-10-18' status: 'draft' baseline_revision: 'ead5670' route: 'full' route_source: 'auto' risk: 'high' review: 'full' review_source: 'pinned' context: - '{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md' - '{project-root}/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md' - '{project-root}/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-password.html' --- ## 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 | 5 failed current-password checks | "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 with `render_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.js` and `shell.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 to `test_translations.py` as needed. ## Tasks & Acceptance **Execution:** - [ ] `profile/services.py` -- the ordered flow, throttle counting only for a wrong current password, one transaction for AD call, audit, mail and session ending -- AD-4, AD-8 - [ ] `profile/services.py` -- policy in words and the local hints (minimum length, character kinds) -- FR-5 - [ ] `profile/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_changed` in German formal with the time and zone, no password - [ ] `shell.js` and `shell.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 five times, when a sixth 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 counter. The portal limit must stay below the domain's lockout threshold or the portal can lock the account. The threshold is not read by the directory adapter today. Decision needed (below). **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 ones - `uv run ruff check portal manage.py docker` -- expected: no findings - `uv 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.