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..3f391c2 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 @@ -14,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() @@ -150,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}") @@ -198,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 @@ -262,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): @@ -287,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 @@ -716,16 +725,49 @@ 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 + 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 + + 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 api.add_resource(_ID, '/id') api.add_resource(_BULK, '/users') @@ -736,6 +778,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).