flask: Invalidate sessions and JWTs when a user's password changes (1/3 split of #73) - #74
Conversation
*** REQUIRED BEFORE THIS DEPLOYS: production runs MySQL (see __init__.py --
SQLALCHEMY_DATABASE_URI switches to MySQL whenever DB_ENDPOINT/DB_USERNAME/
DB_PASSWORD are set), a completely separate database this session had no
access to. Only the local dev SQLite DB has been migrated. Someone MUST run
this against production before/with this deploy, or every login there will
error on the missing column:
ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0;
***
Previously nothing tied an issued JWT or Flask-Login session to a specific
password: the JWT carried no exp claim at all (never expired by JWT
semantics) and no password-derived data, and Flask-Login sessions just
carried a bare user id, re-validated against fresh DB data on every request
but with no check that the underlying credential hadn't changed. A stolen
JWT or session cookie kept working indefinitely, surviving a password reset
that was meant to lock an attacker out.
Adds User.token_version, bumped in set_password() (the single funnel every
password-change path already goes through) only on an actual hash change.
JWTs now carry token_version + exp and are checked against the account's
current value in auth_required. Sessions now carry it via a composite
get_id() ("id:token_version"), checked in load_user (main.py), so a stale
session is rejected before ever reaching a @login_required route instead of
running with outdated auth state.
Verified live: fresh JWT/session -> 200, password reset -> old JWT gets 401
with an explicit "password has changed" message, old session gets redirected
to login, fresh login after the reset works again.
|
@jmort1021 accept the pull request pls so Dhyan can add the schema |
|
This should be tied to Profile password reset. |
|
Addressed on the spring side (split/spring/s2-oauth-verified-reset, spring #170 -- see that PR/spring #169 for the full response). Short version: the profile page's direct password field was removed in a later PR (pages #1371) in favor of routing everyone through the OAuth-verified reset wizard, so 'tied to Profile password reset' now means tied to that wizard's completion. |
Every session predating this feature has a plain "id" cookie with no ":token_version" suffix, which load_user() previously invalidated unconditionally (the split on ":" raised, caught, returned None). Treat a missing ":" as token_version "0" instead, matching a fresh/unchanged account -- it still correctly fails once that account's real token_version has moved past 0 from an actual password change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Splitting #73 into smaller, independently-reviewable PRs across spring/flask/pages. This one covers the password reset button side of flask — session/JWT invalidation on password change.
Independent of the other PRs in this stack.
Previously nothing tied an issued JWT or Flask-Login session to a specific password: the JWT had no
expclaim at all and carried nothing password-derived, and sessions carried a bare user id. A stolen JWT or session cookie kept working indefinitely, surviving a password reset meant to lock an attacker out.Adds
User.token_version, bumped inset_password()only on an actual hash change. JWTs now carrytoken_version+expand are checked against the account's current value; sessions carry it via a compositeget_id(), checked inload_user.Requires a schema change before deploy:
ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0;Original PR: #73