Skip to content
Closed
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
6 changes: 6 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
# Configure Flask Port, default to 8587 which is same as Docker setup
app.config['FLASK_PORT'] = int(os.environ.get('FLASK_PORT') or 8587)

# Configure Code Runner port, default 8591
app.config['RUNNER_PORT'] = int(os.environ.get('RUNNER_PORT') or 8591)

# Configure Flask to handle JSON with UTF-8 encoding versus default ASCII
app.config['JSON_AS_ASCII'] = False # Allow emojis, non-ASCII characters in JSON responses

Expand Down Expand Up @@ -68,6 +71,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
14 changes: 12 additions & 2 deletions api/authorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 63 additions & 29 deletions api/python_exec_api.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,75 @@
# /api/python_exec_api.py
from flask import Blueprint, request, jsonify
from flask import Blueprint, Flask, request
from __init__ import app
from flask_restful import Api, Resource
import subprocess, tempfile, os
import subprocess, tempfile, os, requests

python_exec_api = Blueprint('python_exec_api', __name__, url_prefix='/run')

api = Api(python_exec_api)

runner_port = app.config['RUNNER_PORT']
# based on docker configured name
RUNNER_URL = f'http://code_runner:{runner_port}/python'

class PythonExec(Resource):
def post(self):
"""Executes submitted Python code safely in a short-lived subprocess."""
data = request.get_json()
data = request.get_json(silent=True) or {}
code = data.get("code", "")

if not code.strip():
return {"output": "⚠️ No code provided."}, 400

with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as tmp:
tmp.write(code.encode())
tmp.flush()

try:
result = subprocess.run(
["python3", tmp.name],
capture_output=True,
text=True,
timeout=5,
cwd="/tmp", # Force working directory to /tmp
env={"HOME": "/tmp", "PATH": "/usr/bin:/usr/local/bin"} # Restricted environment
)
output = result.stdout + result.stderr
except subprocess.TimeoutExpired:
output = "⏱️ Execution timed out (5 s limit)."
except Exception as e:
output = f"Error running code: {str(e)}"
finally:
os.unlink(tmp.name)

return {"output": output}

api.add_resource(PythonExec, "/python")
is_production = os.environ.get("IS_PRODUCTION", "false").lower() == "true"

if is_production:
return _execute_remote(data)
# might have to update this in future; could be vuln
# skipping verbose check
else:
return _execute_local(code)


def _execute_local(code):
with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as tmp:
tmp.write(code.encode())
tmp.flush()

try:
result = subprocess.run(
["python3", tmp.name],
capture_output=True,
text=True,
timeout=5,
cwd="/tmp", # Force working directory to /tmp
env={"HOME": "/tmp", "PATH": "/usr/bin:/usr/local/bin"} # Restricted environment
)
output = result.stdout + result.stderr
except subprocess.TimeoutExpired:
output = "Execution timed out (5 s limit)."
except Exception as e:
output = f"Error running code: {str(e)}"
finally:
os.unlink(tmp.name)

return {"output": output}

def _execute_remote(data):
try:
response = requests.post(
RUNNER_URL,
json=data,
timeout=10
)

return response.json(), response.status_code

except requests.Timeout:
return {"output": "Runner timed out."}, 504

except requests.RequestException as e:
return {
"output": f"Could not connect to code runner: {str(e)}"
}, 502


api.add_resource(PythonExec, "/python")
75 changes: 61 additions & 14 deletions api/user.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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
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
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 @@ -392,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"
)
Expand Down Expand Up @@ -716,16 +733,45 @@ 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

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')
Expand All @@ -736,6 +782,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
40 changes: 23 additions & 17 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
version: '3'
services:
web:
image: flask_open
build: .
env_file:
- .env # This file is optional; defaults will be used if it does not exist
ports:
- "8587:8587"
volumes:
- ./instance:/app/instance
restart: unless-stopped
web:
image: flask_open
build: .
env_file:
- .env # This file is optional; defaults will be used if it does not exist
ports:
- "8587:8587"
volumes:
- ./instance:/app/instance
restart: unless-stopped

socketio:
image: socket_open
build: ./socket
ports:
- "8500:8500"

restart: unless-stopped
socketio:
image: socket_open
build: ./socket
ports:
- "8500:8500"
restart: unless-stopped

code_runner:
image: flask_runner
build: ./runner
cap_drop:
- ALL
restart: unless-stopped
12 changes: 11 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading