diff --git a/docs/superpowers/specs/2026-05-11-profile-password-change-design.md b/docs/superpowers/specs/2026-05-11-profile-password-change-design.md new file mode 100644 index 0000000..40d0801 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-profile-password-change-design.md @@ -0,0 +1,96 @@ +# 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 ``. + +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=` → 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 `` → ``. +- `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.