Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
67 changes: 55 additions & 12 deletions api/user.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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).
Expand Down