Feature Changes (detailed in description) - #73
Conversation
POST /api/internal/sync-password: updates a user's password by uid, called server-to-server by the Spring backend after it completes an OAuth + student ID verified password reset, so the same account's Flask password doesn't drift out of sync with Spring's. Gated by a shared secret (INTERNAL_SYNC_KEY, compared with hmac.compare_digest for timing-safety) instead of user auth, since this is never called from a browser -- there's no existing service-to-service auth mechanism in this app to reuse, and reusing the admin-only PUT /api/user route would have meant giving Spring real Flask admin credentials. This endpoint can only ever change one user's password, and is a closed no-op if INTERNAL_SYNC_KEY is unset. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GET /api/user (any logged-in user, not just admins) and the other UserAPI create/update/delete responses were including the PBKDF2 hash from User.read() in the JSON body. Adds _without_password() and applies it at every general-purpose response site; the admin-only backup/export endpoints in data_export_import_api.py are left alone since they need the hash for restore fidelity.
*** 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.
There was a problem hiding this comment.
Pull request overview
This PR hardens authentication and password handling in the Flask app by adding service-to-service password synchronization, removing password hashes from general API responses, and ensuring sessions/JWTs are invalidated on password changes. It also adds safety checks to the prod→sqlite migration script to reduce the risk of destructive runs against non-local databases.
Changes:
- Add
token_versionand enforce session/JWT invalidation when a password changes. - Add internal password sync endpoint (
POST /api/internal/sync-password) gated by a shared secret header. - Add target/backup safety guards to
scripts/db_migrate-prod2sqlite.pyand stop returning password hashes from general user API responses.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/db_migrate-prod2sqlite.py | Adds SQLite-only guardrails and local backup creation before destructive schema resets. |
| model/user.py | Adds token_version, bumps it on real password changes, and embeds it into Flask-Login session IDs. |
| main.py | Validates the session’s token_version during load_user to reject stale sessions. |
| api/user.py | Removes password hashes from common user API responses, adds JWT exp + token_version, and introduces the internal password sync endpoint. |
| api/authorize.py | Rejects JWTs whose token_version no longer matches the user’s current value. |
| init.py | Loads INTERNAL_SYNC_KEY from the environment (no default) to gate internal sync endpoints. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| def sqlite_path(uri): | ||
| """Absolute on-disk path for a sqlite:/// URI, anchored to the repo root.""" | ||
| return os.path.join(ROOT, uri.replace('sqlite:///', f"{PERSISTENCE_PREFIX}/")) |
|
|
||
| # ── Database backup / creation helpers ──────────────────────────────────────── | ||
|
|
||
| BACKUP_DIR = os.path.join(ROOT, PERSISTENCE_PREFIX, 'backups') |
| # user_id is the composite "id:token_version" from User.get_id(). A mismatched | ||
| # token_version means the session predates a password change on this account -- | ||
| # returning None here tells Flask-Login the session is invalid. | ||
| try: | ||
| raw_id, token_version = user_id.split(":", 1) | ||
| except ValueError: | ||
| return None |
| user.update({'password': password}) | ||
| return {'message': f'Password synced for {uid}'}, 200 |
|
I would like to understand without a password hash. If I recall we only returned a partial hash, just to know it was hashing and working. |
tl;dr Changes
Password reset button
Support page
Password databases syncing
Security for transfers of data between two dbs
Ticketing for password reset attempts (acceptable through the admin account)
flask: Password sync endpoint, hash leak fix, and session/JWT invalidation
Schema and config, already handled
The new
token_versioncolumn has been applied to production, so nothing needs to runalongside this merge. For reference, production runs MySQL (
SQLALCHEMY_DATABASE_URIin__init__.pyswitches to MySQL as soon asDB_ENDPOINT,DB_USERNAMEandDB_PASSWORDare set) and the migration was:
INTERNAL_SYNC_KEYis set in the environment to the same value Spring uses. If it wereunset, the sync endpoint would stay closed and return 401.
Changes
New endpoint:
POST /api/internal/sync-password(api/user.py,_InternalPasswordSync)OAuth verified reset, so the same person's Flask password does not drift out of sync.
X-Internal-Sync-Key, compared againstINTERNAL_SYNC_KEYwithhmac.compare_digestso the comparison is timing safe. It is notuser auth because this is never called from a browser, and there is no existing
service-to-service auth in this app to reuse. Reusing the admin-only
PUT /api/userroutewould have meant handing Spring real Flask admin credentials.
INTERNAL_SYNC_KEYis unset, every request gets 401. The endpoint can onlyever change one user's password, nothing else.
__init__.pyreadsINTERNAL_SYNC_KEYfrom the environment with no default.Password hash no longer returned by the general user API
GET /api/user, the bulk user list, and the create, update, delete and guest-createresponses were all including the PBKDF2 hash from
User.read()in the JSON body, to anylogged in user, not just admins.
_without_password()and applied it at every one of those response sites.data_export_import_api.pyare deliberatelyleft alone, since a restore needs the hash to round trip.
Sessions and JWTs are now invalidated when a password changes
Before this, nothing tied an issued JWT or a Flask-Login session to a specific password. The
JWT had no
expclaim at all, so by JWT semantics it never expired, and it carried nothingderived from the password. Flask-Login sessions carried a bare user id, revalidated against
fresh database data on every request but with no check that the credential behind it had
changed. A stolen token or session cookie kept working indefinitely and survived the exact
password reset that was supposed to lock the attacker out.
User.token_version, bumped inset_password(), which is the single funnel everypassword change path already goes through. It only bumps on an actual hash change, so
idempotent operations like re-importing the same hash during a data restore do not
needlessly log everyone out.
token_versionand anexptied toJWT_TOKEN_MAX_AGE(matching thecookie's max_age), and
auth_requiredcompares the claim against the account's currentvalue. Tokens issued before the field existed are treated as version 0.
get_id()of"id:token_version", checked inload_userinmain.py, so a stale session is rejected before it reaches a@login_requiredroute rather than running with outdated auth state.Verified live: fresh JWT and session both return 200, then after a password reset the old JWT
gets a 401 with an explicit "password has changed" message and the old session is redirected
to login, and a fresh login after the reset works again.
Migration script safety:
scripts/db_migrate-prod2sqlite.pyThis script calls
db.drop_all()on whatever database the app is bound to, and__init__.pypicks MySQL whenever the three DB env vars are set. A production
.envsitting in the workingdirectory therefore turned "pull prod down into sqlite" into "wipe prod".
assert_target_is_sqlite(), which refuses to run unless the target is a local SQLitefile, and prints exactly how to blank the DB env vars for the run. It is checked at startup
and again immediately before
drop_all().volumes/backups/with a timestamp, verify the copyis not truncated, and print the exact
cpcommand to roll back. The script refuses to dropanything when the backup fails, unless
ALLOW_NO_BACKUP=trueis set.subprocesswithshell=Trueanda redirect in the argument list, so it never actually wrote a dump.
FORCE_YES=trueskips the interactive confirmation for scripted runs.