# 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.