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>
This commit is contained in:
parent
cb52a69faf
commit
ccd3b36319
1 changed files with 96 additions and 0 deletions
|
|
@ -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 `<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.
|
||||||
Loading…
Reference in a new issue