From 8586b8a839f2ad72aff73bddba255f3cbcfbba62 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Wed, 19 Aug 2026 20:20:02 -0700 Subject: [PATCH 1/4] 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/4] 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/4] 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 From b57ee694681efb33444eb4e8128a508fea131e4d Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Tue, 8 Sep 2026 10:43:40 -0700 Subject: [PATCH 4/4] Replace internal Spring sync endpoint with frontend-facing reset-password jm1021 closed spring #169 (which called this endpoint) as violating the project's architecture: Spring must never push to Flask directly. This endpoint was the Flask side of that banned call, gated by a static shared secret and reachable only from Spring's server. Replaced _InternalPasswordSync with _ResetPasswordVerified: called directly by the frontend (pages' support.md), not by Spring. It verifies the caller's resetToken itself -- the same HMAC-SHA256-signed token Spring's ResetCode.java issues -- using a RESET_TOKEN_SECRET both backends are independently configured with, entirely locally (no network call back to Spring). Flask becomes the source of truth for this password write; the frontend syncs Spring's copy afterward by calling Spring's /reset/oauth/complete with the same token. Also added local single-use enforcement for the token (a small in-process consumed-token set), since Flask has no visibility into Spring's own single-use tracking and the old endpoint's stateless shared-secret auth didn't need this at all. Co-Authored-By: Claude Sonnet 5 --- __init__.py | 8 ++-- api/user.py | 107 +++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/__init__.py b/__init__.py index a8b1c18..7e0bda8 100644 --- a/__init__.py +++ b/__init__.py @@ -68,9 +68,11 @@ # 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') +# Signing secret for password-reset tokens issued by Spring's ResetCode.java. +# Must match RESET_TOKEN_SECRET in the spring repo's .env -- Flask verifies these +# tokens locally (no call back to Spring). No default -- unset means the +# frontend-driven reset-password endpoint is closed. +app.config['RESET_TOKEN_SECRET'] = os.environ.get('RESET_TOKEN_SECRET') # 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 3f391c2..f39f338 100644 --- a/api/user.py +++ b/api/user.py @@ -1,5 +1,8 @@ +import base64 +import hashlib import hmac import jwt +import threading 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 @@ -15,6 +18,73 @@ # API docs https://flask-restful.readthedocs.io/en/latest/api.html api = Api(user_api) +# Tokens this process has already spent, so a captured token can't be replayed against +# Flask again within its own TTL -- Spring enforces single-use on its side too (consumed +# when the frontend calls /reset/oauth/complete), but that's a separate record Flask has +# no visibility into, so this endpoint needs its own. Keyed by the full token string; +# swept lazily since each entry's own expiry means it's safe to forget once that time +# passes -- nobody can replay an expired token anyway. +_consumed_reset_tokens = {} +_consumed_reset_tokens_lock = threading.Lock() + + +def _verify_reset_token(uid, token): + """Verifies a password-reset token issued by Spring's ResetCode.java, entirely + locally -- no network call back to Spring. Same HMAC-SHA256 scheme on both + sides, keyed by the RESET_TOKEN_SECRET both backends are independently + configured with: token = base64url(uid) + "." + expiresAt + "." + nonce + "." + + signature, where signature = HMAC-SHA256(secret, f"{uid}.{expiresAt}.{nonce}"). + Signature + expiry only -- callers that need single-use enforcement (this file's + reset-password endpoint) must check/record consumption themselves. + """ + if not uid or not token: + return False, None + + parts = token.split('.') + if len(parts) != 4: + return False, None + _uid_b64, exp_str, nonce, signature = parts + + try: + exp = int(exp_str) + except ValueError: + return False, None + if datetime.utcnow().timestamp() > exp: + return False, None + + secret = current_app.config.get('RESET_TOKEN_SECRET') + if not secret: + return False, None + + payload = f"{uid}.{exp}.{nonce}".encode('utf-8') + expected_sig = base64.urlsafe_b64encode( + hmac.new(secret.encode('utf-8'), payload, hashlib.sha256).digest() + ).decode('utf-8').rstrip('=') + + return hmac.compare_digest(expected_sig, signature), exp + + +def _consume_reset_token(uid, token): + """Verifies the token (signature + expiry) and, only if it hasn't been spent + against this endpoint before, marks it spent and returns True. Single-use + enforcement local to Flask -- see _verify_reset_token's docstring.""" + valid, exp = _verify_reset_token(uid, token) + if not valid: + return False + + now = datetime.utcnow().timestamp() + with _consumed_reset_tokens_lock: + for stored_token, stored_exp in list(_consumed_reset_tokens.items()): + if stored_exp <= now: + del _consumed_reset_tokens[stored_token] + + if token in _consumed_reset_tokens: + return False + + _consumed_reset_tokens[token] = exp + return True + + 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 @@ -735,27 +805,29 @@ def post(self): except Exception as e: return {'message': f'Error creating guest user: {str(e)}'}, 500 - class _InternalPasswordSync(Resource): + class _ResetPasswordVerified(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. + Sets this uid's Flask password using a short-lived, single-use token + issued by Spring's POST /mvc/person/reset/oauth/verify. Called directly + by the frontend, not by Spring -- Flask verifies the token's HMAC + signature and expiry itself (RESET_TOKEN_SECRET, shared with Spring but + never sent over the wire between the two backends), so there is no + Spring -> Flask network call. Flask is the source of truth for this + password write; the frontend syncs Spring's copy separately afterward by + calling Spring's /reset/oauth/complete with the same token. """ 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') + reset_token = body.get('resetToken') + password = body.get('newPassword') - if not uid or not password: - return {'message': 'uid and password are required'}, 400 + if not uid or not reset_token or not password: + return {'message': 'uid, resetToken, and newPassword are required'}, 400 if len(password) < 8: return {'message': 'Password must be at least 8 characters'}, 400 + if not _consume_reset_token(uid, reset_token): + return {'message': 'Invalid, expired, or already-used reset token'}, 403 user = User.query.filter_by(_uid=uid).first() if user is None: @@ -763,10 +835,9 @@ def post(self): 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 + # update() returns None on IntegrityError (already rolled back internally). + return {'message': f'Failed to reset password for {uid}'}, 500 + return {'message': f'Password reset for {uid}'}, 200 # building RESTapi endpoint api.add_resource(_ID, '/id') @@ -778,7 +849,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') + api.add_resource(_ResetPasswordVerified, '/reset-password') class _Class(Resource): """Manage the user's `class` list (e.g. CSSE, CSP, CSA).