Skip to content

Feature Changes (detailed in description) - #73

Open
dhyantsoni wants to merge 5 commits into
Open-Coding-Society:mainfrom
dhyantsoni:main
Open

Feature Changes (detailed in description)#73
dhyantsoni wants to merge 5 commits into
Open-Coding-Society:mainfrom
dhyantsoni:main

Conversation

@dhyantsoni

Copy link
Copy Markdown

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_version column has been applied to production, so nothing needs to run
alongside this merge. For reference, production runs MySQL (SQLALCHEMY_DATABASE_URI in
__init__.py switches to MySQL as soon as DB_ENDPOINT, DB_USERNAME and DB_PASSWORD
are set) and the migration was:

ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0;

INTERNAL_SYNC_KEY is set in the environment to the same value Spring uses. If it were
unset, the sync endpoint would stay closed and return 401.

Changes

New endpoint: POST /api/internal/sync-password (api/user.py, _InternalPasswordSync)

  • Updates a user's password by uid. Called server to server by Spring after it completes an
    OAuth verified reset, so the same person's Flask password does not drift out of sync.
  • Authenticated with a shared secret header X-Internal-Sync-Key, compared against
    INTERNAL_SYNC_KEY with hmac.compare_digest so the comparison is timing safe. It is not
    user 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/user route
    would have meant handing Spring real Flask admin credentials.
  • Fails closed: if INTERNAL_SYNC_KEY is unset, every request gets 401. The endpoint can only
    ever change one user's password, nothing else.
  • Validates: uid and password required, 8 character minimum, 404 on unknown uid.
  • __init__.py reads INTERNAL_SYNC_KEY from 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-create
    responses were all including the PBKDF2 hash from User.read() in the JSON body, to any
    logged in user, not just admins.
  • Added _without_password() and applied it at every one of those response sites.
  • The admin-only backup and export endpoints in data_export_import_api.py are deliberately
    left 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 exp claim at all, so by JWT semantics it never expired, and it carried nothing
derived 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.

  • Added User.token_version, bumped in set_password(), which is the single funnel every
    password 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.
  • JWTs now carry token_version and an exp tied to JWT_TOKEN_MAX_AGE (matching the
    cookie's max_age), and auth_required compares the claim against the account's current
    value. Tokens issued before the field existed are treated as version 0.
  • Sessions carry it through a composite get_id() of "id:token_version", checked in
    load_user in main.py, so a stale session is rejected before it reaches a
    @login_required route 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.py

This script calls db.drop_all() on whatever database the app is bound to, and __init__.py
picks MySQL whenever the three DB env vars are set. A production .env sitting in the working
directory therefore turned "pull prod down into sqlite" into "wipe prod".

  • Added assert_target_is_sqlite(), which refuses to run unless the target is a local SQLite
    file, and prints exactly how to blank the DB env vars for the run. It is checked at startup
    and again immediately before drop_all().
  • Backups now copy the SQLite file into volumes/backups/ with a timestamp, verify the copy
    is not truncated, and print the exact cp command to roll back. The script refuses to drop
    anything when the backup fails, unless ALLOW_NO_BACKUP=true is set.
  • Removed the old mysqldump backup path, which ran through subprocess with shell=True and
    a redirect in the argument list, so it never actually wrote a dump.
  • FORCE_YES=true skips the interactive confirmation for scripted runs.

RudraBJoshi and others added 4 commits August 19, 2026 20:20
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.
Copilot AI lite review requested due to automatic review settings August 23, 2026 19:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_version and 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.py and 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.

Comment on lines +77 to +79
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')
Comment thread main.py
Comment on lines +113 to +119
# 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
Comment thread api/user.py
Comment on lines +772 to +773
user.update({'password': password})
return {'message': f'Password synced for {uid}'}, 200
@jm1021

jm1021 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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.

@RudraBJoshi

Copy link
Copy Markdown

Split into smaller PRs for review: #74 (JWT/session invalidation), #75 (password sync endpoint + hash-leak fix), #76 (migration script safety). Marking this draft — all three are independent of each other.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants