From 3311db513a030e5a0bf5eca6c6419616de33b446 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Wed, 19 Aug 2026 20:20:02 -0700 Subject: [PATCH 1/5] Add internal password sync endpoint for Spring-verified resets 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 --- __init__.py | 3 +++ api/user.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/__init__.py b/__init__.py index f3db9d3..a5af9b5 100644 --- a/__init__.py +++ b/__init__.py @@ -68,6 +68,9 @@ # Defaults app.config['DEFAULT_PASSWORD'] = os.environ.get('DEFAULT_PASSWORD') or 'password' app.config['DEFAULT_PFP'] = os.environ.get('DEFAULT_PFP') or 'default.png' +# Shared secret for server-to-server calls from Spring (e.g. password sync after +# an OAuth-verified reset). No default -- unset means the sync endpoint is closed. +app.config['INTERNAL_SYNC_KEY'] = os.environ.get('INTERNAL_SYNC_KEY') # Convenience user app.config['MY_NAME'] = os.environ.get('MY_NAME') or 'convenience' app.config['MY_UID'] = os.environ.get('MY_UID') or 'convenience' diff --git a/api/user.py b/api/user.py index 31f87ad..810c00b 100644 --- a/api/user.py +++ b/api/user.py @@ -1,3 +1,4 @@ +import hmac import jwt from flask import Blueprint, app, request, jsonify, current_app, Response, g from flask_restful import Api, Resource # used for REST API building @@ -726,6 +727,35 @@ def post(self): except Exception as e: return {'message': f'Error creating guest user: {str(e)}'}, 500 + class _InternalPasswordSync(Resource): + """ + Server-to-server password sync, called by the Spring backend after a + password reset completes there, so the same account's Flask password + stays in sync. Not reachable via a browser session -- gated by a shared + secret (INTERNAL_SYNC_KEY) instead of user auth. + """ + def post(self): + sync_key = current_app.config.get('INTERNAL_SYNC_KEY') + provided_key = request.headers.get('X-Internal-Sync-Key') + if not sync_key or not provided_key or not hmac.compare_digest(provided_key, sync_key): + return {'message': 'Unauthorized'}, 401 + + body = request.get_json(silent=True) or {} + uid = body.get('uid') + password = body.get('password') + + if not uid or not password: + return {'message': 'uid and password are required'}, 400 + if len(password) < 8: + return {'message': 'Password must be at least 8 characters'}, 400 + + user = User.query.filter_by(_uid=uid).first() + if user is None: + return {'message': f'User {uid} not found'}, 404 + + user.update({'password': password}) + return {'message': f'Password synced for {uid}'}, 200 + # building RESTapi endpoint api.add_resource(_ID, '/id') api.add_resource(_BULK, '/users') @@ -736,6 +766,7 @@ def post(self): api.add_resource(_GradeData, '/grade_data') api.add_resource(_APExam, '/apexam') api.add_resource(_School, '/school') + api.add_resource(_InternalPasswordSync, '/internal/sync-password') class _Class(Resource): """Manage the user's `class` list (e.g. CSSE, CSP, CSA). From 276fe0479df509917414fd3dc98beccd0a1c06db Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Thu, 20 Aug 2026 10:32:09 -0700 Subject: [PATCH 2/5] Strip password hash from general-purpose user API responses 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. --- api/user.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/api/user.py b/api/user.py index 810c00b..47e9c1c 100644 --- a/api/user.py +++ b/api/user.py @@ -15,14 +15,22 @@ # API docs https://flask-restful.readthedocs.io/en/latest/api.html api = Api(user_api) -class UserAPI: +def _without_password(user_data): + """Strip the password hash before a user dict goes out over a general-purpose + API response. The admin-only backup/export endpoints in data_export_import_api.py + call user.read() directly instead of this, since restoring from a backup needs + the hash to round-trip.""" + user_data.pop('password', None) + return user_data + +class UserAPI: class _ID(Resource): # Individual identification API operation @token_required() def get(self): ''' Retrieve the current user from the token_required authentication check ''' current_user = g.current_user ''' Return the current user as a json object with role information ''' - user_data = current_user.read() + user_data = _without_password(current_user.read()) # Add role information to response user_data['role'] = current_user.role user_data['is_admin'] = current_user.is_admin() @@ -151,13 +159,13 @@ def post(self): # Create method db_user = User.query.filter_by(_uid=uid).first() if db_user: #print(f"User exists in DB but create returned None: {db_user.uid}") - return jsonify(db_user.read()) # Return the user anyway + return jsonify(_without_password(db_user.read())) # Return the user anyway else: return {'message': f'Processed {name}, either a format error or User ID {uid} is duplicate'}, 400 - + #print(f"Successfully created user: {user.uid}") # return response, the created user details as a JSON object - return jsonify(user.read()) + return jsonify(_without_password(user.read())) except Exception as e: #print(f"Error creating user: {e}") @@ -199,9 +207,9 @@ def get(self): total = len(users) # prepare a json list of user dictionaries - json_ready = [] + json_ready = [] for user in users: - user_data = user.read() + user_data = _without_password(user.read()) # Add access control if current_user.role == 'Admin' or current_user.id == user.id: user_data['access'] = ['rw'] # read-write access control @@ -263,9 +271,9 @@ def put(self): # Update the User object to the database using custom update method user.update(body) - + # return response, the updated user details as a JSON object - return jsonify(user.read()) + return jsonify(_without_password(user.read())) @token_required("Admin") def delete(self): @@ -288,7 +296,7 @@ def delete(self): return {'message': f'User {uid} not found'}, 404 # Read and then Delete the User object using custom methods - user_json = user.read() + user_json = _without_password(user.read()) user.delete() # 204 is the status code for delete with no json response @@ -717,12 +725,12 @@ def post(self): # Check if user was actually created in database db_user = User.query.filter_by(_uid=uid).first() if db_user: - return jsonify(db_user.read()) + return jsonify(_without_password(db_user.read())) else: return {'message': f'Failed to create guest account for {uid}, username may already exist'}, 400 # Return the created user details - return jsonify(user.read()) + return jsonify(_without_password(user.read())) except Exception as e: return {'message': f'Error creating guest user: {str(e)}'}, 500 From a9bf7b888da1bf4cb36459b4bac4680180b76825 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Thu, 20 Aug 2026 23:07:35 -0700 Subject: [PATCH 3/5] Invalidate sessions and JWTs when a user's password changes *** 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. --- api/authorize.py | 14 ++++++++++++-- api/user.py | 12 ++++++++++-- main.py | 12 +++++++++++- model/user.py | 22 ++++++++++++++++++---- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/api/authorize.py b/api/authorize.py index df7ae49..1feafda 100644 --- a/api/authorize.py +++ b/api/authorize.py @@ -63,14 +63,24 @@ def decorated(*args, **kwargs): # Decode the token and retrieve the user data data = jwt.decode(token, current_app.config["SECRET_KEY"], algorithms=["HS256"]) user = User.query.filter_by(_uid=data["_uid"]).first() - + if user is None: return { "message": "Invalid Authentication token!", "data": None, "error": "Unauthorized" }, 401 - + + # Tokens issued before this field existed have no token_version claim + # (treated as 0); reject if it doesn't match the account's current + # value -- i.e. the password has changed since this token was issued. + if data.get("token_version", 0) != (user.token_version or 0): + return { + "message": "Token is no longer valid -- password has changed.", + "data": None, + "error": "Unauthorized" + }, 401 + auth_method = "jwt" # Set the current_user in the global context g.current_user = user diff --git a/api/user.py b/api/user.py index 47e9c1c..59e4670 100644 --- a/api/user.py +++ b/api/user.py @@ -2,7 +2,7 @@ import jwt from flask import Blueprint, app, request, jsonify, current_app, Response, g from flask_restful import Api, Resource # used for REST API building -from datetime import datetime +from datetime import datetime, timedelta from __init__ import app, db from api.authorize import token_required from model.user import User @@ -401,8 +401,16 @@ def post(self): # Check if user is found if user: try: + # exp ties the token's server-enforced lifetime to the cookie's + # client-side max_age (previously the token never expired by JWT + # semantics at all). token_version is checked on every request in + # auth_required -- see model/user.py's token_version column comment. token = jwt.encode( - {"_uid": user._uid}, + { + "_uid": user._uid, + "token_version": user.token_version, + "exp": datetime.utcnow() + timedelta(seconds=current_app.config["JWT_TOKEN_MAX_AGE"]), + }, current_app.config["SECRET_KEY"], algorithm="HS256" ) diff --git a/main.py b/main.py index d39177f..8cf6928 100644 --- a/main.py +++ b/main.py @@ -110,7 +110,17 @@ def unauthorized_callback(): # register URIs for server pages @login_manager.user_loader def load_user(user_id): - return User.query.get(int(user_id)) + # 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 = User.query.get(int(raw_id)) + if user is None or str(user.token_version) != token_version: + return None + return user @app.context_processor def inject_user(): diff --git a/model/user.py b/model/user.py index 2f0ec24..796191d 100644 --- a/model/user.py +++ b/model/user.py @@ -158,6 +158,11 @@ class User(db.Model, UserMixin): _class = db.Column(db.JSON, unique=False, nullable=True) _school = db.Column(db.String(255), default="Unknown", nullable=True) _game_profile = db.Column(db.JSON, unique=False, nullable=True) + # Bumped every time the password actually changes (set_password). Embedded in issued + # JWTs and in the Flask-Login session id (see get_id) and checked on every request, so + # a stolen JWT or session cookie stops working the moment this account's password is + # reset, instead of staying valid for the rest of its lifetime. + token_version = db.Column(db.Integer, default=0, nullable=False) # Define many-to-many relationship with Section model through UserSection table # Overlaps setting silences SQLAlchemy warnings about multiple relationship paths @@ -188,9 +193,12 @@ def __init__(self, name, uid, password=app.config["DEFAULT_PASSWORD"], kasm_serv self._school = school self._game_profile = game_profile if game_profile else None - # UserMixin/Flask-Login requires a get_id method to return the id as a string + # UserMixin/Flask-Login requires a get_id method to return the id as a string. + # Composite id (id:token_version) so load_user (main.py) can reject a session cookie + # whose token_version doesn't match the account's current one -- see token_version + # column comment above. def get_id(self): - return str(self.id) + return f"{self.id}:{self.token_version}" # UserMixin/Flask-Login requires is_authenticated to be defined @property @@ -271,10 +279,16 @@ def set_password(self, password): """Set password: hash if not already hashed, else set directly.""" if password and password.startswith("pbkdf2:sha256:"): # Already hashed, set directly - self._password = password + new_hash = password else: # Not hashed, hash it - self._password = generate_password_hash(password, "pbkdf2:sha256", salt_length=10) + new_hash = generate_password_hash(password, "pbkdf2:sha256", salt_length=10) + + # Only bump on an actual change, so idempotent operations (e.g. re-importing the + # same hash during a data restore) don't needlessly invalidate live sessions/JWTs. + if new_hash != self._password: + self.token_version = (self.token_version or 0) + 1 + self._password = new_hash # check password parameter versus stored/encrypted password def is_password(self, password): From 1538f942d511e1f13d92cea3c1e70018d3863e7d Mon Sep 17 00:00:00 2001 From: Dhyan Soni Date: Fri, 21 Aug 2026 10:30:47 -0700 Subject: [PATCH 4/5] safety against db deletion --- scripts/db_migrate-prod2sqlite.py | 126 ++++++++++++++++++++++-------- 1 file changed, 94 insertions(+), 32 deletions(-) diff --git a/scripts/db_migrate-prod2sqlite.py b/scripts/db_migrate-prod2sqlite.py index b574885..cdca93d 100755 --- a/scripts/db_migrate-prod2sqlite.py +++ b/scripts/db_migrate-prod2sqlite.py @@ -23,7 +23,6 @@ import json import os import shutil -import subprocess import sys import time from datetime import datetime @@ -70,39 +69,88 @@ 'leaderboard', 'elementary_leaderboard', 'skill_snapshots', } +# ── Target safety guard ─────────────────────────────────────────────────────── + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + + +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}/")) + + +def assert_target_is_sqlite(): + """Refuse to run unless the app is bound to a local SQLite file. + + This script calls db.drop_all() on whatever database `db` is bound to, and + __init__.py selects MySQL whenever DB_ENDPOINT / DB_USERNAME / DB_PASSWORD + are set. A production .env sitting in the working directory therefore turns + "pull prod down to sqlite" into "wipe prod", so check the target first. + """ + db_string = app.config['SQLALCHEMY_DATABASE_STRING'] + if db_string.startswith('sqlite'): + return + + print("ERROR: refusing to run - the target database is not SQLite.") + print(f" engine: {db_string.split(':', 1)[0] or 'unknown'}") + print(f" host: {app.config['DB_ENDPOINT']}") + print(f" schema: {app.config['SQLALCHEMY_DATABASE_NAME']}") + print() + print("This script drops and recreates every table in the target, so running") + print("it against that database would destroy it rather than populate a local") + print("SQLite file.") + print() + print("DB_ENDPOINT / DB_USERNAME / DB_PASSWORD are being read from .env, and") + print("__init__.py picks MySQL whenever all three are non-empty. Blank them for") + print("this run so it falls back to sqlite:///volumes/ — note that `env -u` does") + print("NOT work here, because load_dotenv() refills unset vars straight from .env:") + print(" DB_ENDPOINT= DB_USERNAME= DB_PASSWORD= \\") + print(" python scripts/db_migrate-prod2sqlite.py") + sys.exit(1) + + # ── Database backup / creation helpers ──────────────────────────────────────── +BACKUP_DIR = os.path.join(ROOT, PERSISTENCE_PREFIX, 'backups') + + def backup_database(db_uri, backup_uri, db_string): - """Back up the current database before overwriting it.""" - db_name = db_uri.split('/')[-1] + """Copy the local SQLite file aside. - if 'mysql' in db_string: - backup_file = f"{db_name}_backup.sql" - os.environ['MYSQL_PWD'] = app.config["DB_PASSWORD"] - try: - subprocess.run( - ['mysqldump', '-h', app.config["DB_ENDPOINT"], - '-u', app.config["DB_USERNAME"], - f'-p{app.config["DB_PASSWORD"]}', db_name, '>', backup_file], - check=True, shell=True, - ) - print(f"MySQL database backed up to {backup_file}") - except subprocess.CalledProcessError as e: - print(f"mysqldump failed: {e}") - finally: - del os.environ['MYSQL_PWD'] + Returns True when a rollback point exists (or when there is nothing to lose + yet). The caller must not drop tables when this returns False. + """ + if not db_string.startswith('sqlite'): + # assert_target_is_sqlite() runs first, so this is unreachable via main(). + print("ERROR: backups are only supported for SQLite targets.") + return False + + db_path = sqlite_path(db_uri) + if not os.path.exists(db_path) or os.path.getsize(db_path) == 0: + print(f"No existing data at {db_path}; nothing to back up.") + return True + + os.makedirs(BACKUP_DIR, exist_ok=True) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + dump_path = os.path.join(BACKUP_DIR, f"{os.path.basename(db_path)}.{timestamp}.bak") - elif 'sqlite' in db_string: + try: + shutil.copyfile(db_path, dump_path) if backup_uri: - db_path = db_uri.replace('sqlite:///', f"{PERSISTENCE_PREFIX}/") - backup_path = backup_uri.replace('sqlite:///', f"{PERSISTENCE_PREFIX}/") - shutil.copyfile(db_path, backup_path) - print(f"SQLite database backed up to {backup_path}") - else: - print("Backup not supported for production database.") + # Keep the configured *_bak.db convenience copy up to date too. + shutil.copyfile(db_path, sqlite_path(backup_uri)) + except OSError as e: + print(f"ERROR: could not back up {db_path}: {e}") + return False - else: - print("Unsupported database type for backup.") + size = os.path.getsize(dump_path) + if size != os.path.getsize(db_path): + print(f"ERROR: backup {dump_path} is truncated ({size} bytes).") + return False + + print(f"SQLite database backed up to {dump_path} ({size} bytes)") + print(f"Roll back with: cp {dump_path} {db_path}") + return True def create_database_if_missing(engine, db_name): @@ -764,22 +812,34 @@ def load_all(all_data): # ── Entry point ──────────────────────────────────────────────────────────────── def main(): - # Step 0: Warn user and back up existing database + # Step 0: Confirm the target is local, warn, and back up existing database + assert_target_is_sqlite() + with app.app_context(): try: inspector = db.inspect(db.engine) if inspector.get_table_names(): - print("Warning: you are about to lose all data in your local SQLite database!") - print("Do you want to continue? (y/n)") - if input().lower() != 'y': + target = sqlite_path(app.config['SQLALCHEMY_DATABASE_URI']) + print(f"Warning: every table in {target} is about to be dropped") + print("and rebuilt from production data.") + if os.getenv('FORCE_YES') == 'true': + response = 'y' + else: + print("Do you want to continue? (y/n)") + response = input() + if response.lower() != 'y': print("Exiting without making changes.") sys.exit(0) - backup_database( + backed_up = backup_database( app.config['SQLALCHEMY_DATABASE_URI'], app.config['SQLALCHEMY_BACKUP_URI'], app.config['SQLALCHEMY_DATABASE_STRING'], ) + if not backed_up and os.getenv('ALLOW_NO_BACKUP') != 'true': + print("\nRefusing to drop the database without a rollback point.") + print("Fix the backup above, or set ALLOW_NO_BACKUP=true to override.") + sys.exit(1) except OperationalError as e: if "Unknown database" in str(e): @@ -851,6 +911,8 @@ def main(): print("\n=== Step 3: Building new schema and loading data ===") try: with app.app_context(): + # Re-check immediately before the destructive call. + assert_target_is_sqlite() db.drop_all() print("All tables dropped.") db.create_all() From 9ac31c19d5b4d09e0216ac41c7b4496dd7f624a2 Mon Sep 17 00:00:00 2001 From: Dhyan Soni Date: Mon, 24 Aug 2026 13:55:03 -0700 Subject: [PATCH 5/5] db init fix --- scripts/db_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/db_init.py b/scripts/db_init.py index b1cff48..90fd0be 100755 --- a/scripts/db_init.py +++ b/scripts/db_init.py @@ -57,7 +57,7 @@ def backup_mysql_database(): f"--host={app.config['DB_ENDPOINT']}", '--port=3306', f"--user={app.config['DB_USERNAME']}", - '--single-transaction', '--routines', '--triggers', + '--single-transaction', '--set-gtid-purged=OFF', '--no-tablespaces','--routines', '--triggers', db_name, ] env = dict(os.environ, MYSQL_PWD=app.config['DB_PASSWORD'])