left4me/docs/superpowers/specs/2026-05-11-profile-password-change-design.md
mwiegand ccd3b36319
docs: design for profile page with self-service password change
The matching design doc for the implementation plan committed in
6eb9bd0. Captures the session-invalidation reasoning (Django-style
"keep current session, kill others") and the open questions resolved
during brainstorming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:21:40 +02:00

96 lines
5.5 KiB
Markdown

# Profile page with self-service password change — design
## Context
The web app has login/logout (`l4d2web/auth.py`, `l4d2web/routes/auth_routes.py`) and admin user management (activate/deactivate/delete), but no way for a logged-in user to change their own password. The header (`l4d2web/templates/base.html:27`) renders the username as a non-clickable `<span class="muted">`.
This design adds a `/profile` page reachable by clicking the username, with "Change password" as its first (and only) section. Future profile fields can slot into the same page as new sections without rework.
## Goals
- Logged-in users can change their own password from a self-service page.
- Following an industry-standard session model: a successful password change **invalidates every other active session for the user** and **keeps the current session signed in**. No re-login on the current browser.
- Single password policy enforced everywhere a password is set (web flow today, CLI `create-user` for consistency).
## Non-goals (out of scope for v1)
- Admin password reset for other users. A separate feature; no rework needed to add later.
- Password recovery / email reset flow.
- Other profile fields (display name, email, etc.). The page is structured to grow but ships with one section.
## Decisions
- **URLs.** `GET /profile` for the page, `POST /profile/password` for the form submission.
- **Form fields.** `current_password`, `new_password`, `confirm_new_password`. All three required.
- **Password policy.** Not empty, minimum 8 characters. Same rule applies to the CLI `create-user` so policy lives in one place.
- **Session policy.** Invalidate other sessions on success; keep the current session signed in.
- **Rate limit.** Per-IP, sliding window. Re-uses the same primitive as `/login`.
- **CSRF.** Standard hidden-token pattern shared with the rest of the app.
## Session-invalidation mechanism
A new `password_changed_at: DateTime NOT NULL` column on `users`. Two checkpoints:
1. **On login.** `login_user` stashes `session["pw_changed_at"] = user.password_changed_at.isoformat()`.
2. **On every request.** `load_current_user` rejects the session — same shape as the existing `user.active` check — when the marker is missing, malformed, or strictly older than `user.password_changed_at`.
On successful password change:
- Rotate `user.password_digest` and bump `user.password_changed_at` to "now".
- Re-stamp `session["pw_changed_at"]` to the new value so this browser keeps working.
- Other browsers carry the old marker and get logged out the next time they hit a `@require_login` route.
This mirrors the established `g.user = None if not user.active else user` pattern, so the surface area added to the auth path is small and the behavior is easy to reason about.
## Validation branches (POST /profile/password)
In order:
1. All three fields present → otherwise `error=fields_required`.
2. `new_password == confirm_new_password` → otherwise `error=mismatch`.
3. `validate_new_password(new_password)` passes → otherwise `error=empty` or `error=too_short`.
4. `verify_password(current_password, user.password_digest)` succeeds → otherwise `error=wrong_current`.
5. Rotate, re-stamp, redirect to `/profile?success=1`.
Errors are surfaced inline on the next render of `/profile` via a small `?error=<key>` → human-readable message map in the route. No flash storage required.
## Migration story
Adding `password_changed_at` to `users` requires a migration:
- Add the column nullable.
- Backfill existing rows with their `created_at` so historical data has a sane marker.
- Alter to `NOT NULL`.
Effect on existing live sessions: any cookie that predates the migration lacks `pw_changed_at` and is rejected on first request after deploy. Users log in once more. Acceptable for v1 deployment.
## Surface area
**New files**
- `l4d2web/alembic/versions/0009_user_password_changed_at.py`
- `l4d2web/services/rate_limit.py`
- `l4d2web/routes/profile_routes.py`
- `l4d2web/templates/profile.html`
- `l4d2web/tests/test_profile.py`
**Modified files**
- `l4d2web/models.py` — column.
- `l4d2web/auth.py``MIN_PASSWORD_LENGTH`, `validate_new_password`, `login_user` signature, freshness check in `load_current_user`.
- `l4d2web/routes/auth_routes.py` — pass marker to `login_user`; use the generic rate-limit helper.
- `l4d2web/templates/base.html` — username `<span>``<a href="/profile">`.
- `l4d2web/app.py` — register the new blueprint, reset its rate-limit bucket in TESTING.
- `l4d2web/cli.py` — apply `validate_new_password` for parity with the web flow.
## Reused utilities
- `hash_password`, `verify_password``l4d2web/auth.py`
- `require_login``l4d2web/auth.py`
- `session_scope``l4d2web/db.py`
- `now_utc``l4d2web/models.py`
- CSRF hidden-token pattern — see `templates/admin_users.html`, `routes/auth_routes.py`
## Open questions resolved during brainstorming
- *Should the current session also be invalidated?* No — industry consensus (Django `update_session_auth_hash`, Rails Devise, GitHub/Google behaviour, OWASP / NIST SP 800-63B implications) is to keep the current session and rotate other sessions. Forcing re-login on a session that just proved knowledge of the current password adds friction without security gain.
- *Should we add `password_changed_at` or use a `session_version` counter?* Timestamp is enough; the comparison is unambiguous and avoids an extra integer field with arbitrary meaning.
- *Admin reset?* Deferred. The current model has no rework debt for adding it later.