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>
5.5 KiB
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-userfor 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 /profilefor the page,POST /profile/passwordfor 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-userso 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:
- On login.
login_userstashessession["pw_changed_at"] = user.password_changed_at.isoformat(). - On every request.
load_current_userrejects the session — same shape as the existinguser.activecheck — when the marker is missing, malformed, or strictly older thanuser.password_changed_at.
On successful password change:
- Rotate
user.password_digestand bumpuser.password_changed_atto "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_loginroute.
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:
- All three fields present → otherwise
error=fields_required. new_password == confirm_new_password→ otherwiseerror=mismatch.validate_new_password(new_password)passes → otherwiseerror=emptyorerror=too_short.verify_password(current_password, user.password_digest)succeeds → otherwiseerror=wrong_current.- 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_atso 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.pyl4d2web/services/rate_limit.pyl4d2web/routes/profile_routes.pyl4d2web/templates/profile.htmll4d2web/tests/test_profile.py
Modified files
l4d2web/models.py— column.l4d2web/auth.py—MIN_PASSWORD_LENGTH,validate_new_password,login_usersignature, freshness check inload_current_user.l4d2web/routes/auth_routes.py— pass marker tologin_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— applyvalidate_new_passwordfor parity with the web flow.
Reused utilities
hash_password,verify_password—l4d2web/auth.pyrequire_login—l4d2web/auth.pysession_scope—l4d2web/db.pynow_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_ator use asession_versioncounter? 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.