From 8586b8a839f2ad72aff73bddba255f3cbcfbba62 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Wed, 19 Aug 2026 20:20:02 -0700 Subject: [PATCH 1/3] 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 c429fd1..a8b1c18 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 ff021f77065e168ade336b4ce2a97f6ddffba431 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Thu, 20 Aug 2026 10:32:09 -0700 Subject: [PATCH 2/3] 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 c8115faf6f5b4b203d46fd20a841196404ef1807 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Mon, 31 Aug 2026 12:01:06 -0700 Subject: [PATCH 3/3] Return 500 from internal password sync when the update didn't happen _InternalPasswordSync always returned 200 after calling user.update(), even though update() returns None on IntegrityError (rolled back internally). That meant Spring could be told a sync succeeded when the write never actually happened. Co-Authored-By: Claude Sonnet 5 --- api/user.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/user.py b/api/user.py index 47e9c1c..3f391c2 100644 --- a/api/user.py +++ b/api/user.py @@ -761,7 +761,11 @@ def post(self): if user is None: return {'message': f'User {uid} not found'}, 404 - user.update({'password': password}) + updated = user.update({'password': password}) + if updated is None: + # update() returns None on IntegrityError (already rolled back internally) -- + # don't report success to Spring when the write didn't actually happen. + return {'message': f'Failed to sync password for {uid}'}, 500 return {'message': f'Password synced for {uid}'}, 200 # building RESTapi endpoint