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

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-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.pyMIN_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_passwordl4d2web/auth.py
  • require_loginl4d2web/auth.py
  • session_scopel4d2web/db.py
  • now_utcl4d2web/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.