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
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
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
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
Comment on lines +113 to +119
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
22 changes: 18 additions & 4 deletions model/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ class User(db.Model, UserMixin):
_class = db.Column(db.JSON, unique=False, nullable=True)
_school = db.Column(db.String(255), default="Unknown", nullable=True)
_game_profile = db.Column(db.JSON, unique=False, nullable=True)
# Bumped every time the password actually changes (set_password). Embedded in issued
# JWTs and in the Flask-Login session id (see get_id) and checked on every request, so
# a stolen JWT or session cookie stops working the moment this account's password is
# reset, instead of staying valid for the rest of its lifetime.
token_version = db.Column(db.Integer, default=0, nullable=False)

# Define many-to-many relationship with Section model through UserSection table
# Overlaps setting silences SQLAlchemy warnings about multiple relationship paths
Expand Down Expand Up @@ -188,9 +193,12 @@ def __init__(self, name, uid, password=app.config["DEFAULT_PASSWORD"], kasm_serv
self._school = school
self._game_profile = game_profile if game_profile else None

# UserMixin/Flask-Login requires a get_id method to return the id as a string
# UserMixin/Flask-Login requires a get_id method to return the id as a string.
# Composite id (id:token_version) so load_user (main.py) can reject a session cookie
# whose token_version doesn't match the account's current one -- see token_version
# column comment above.
def get_id(self):
return str(self.id)
return f"{self.id}:{self.token_version}"

# UserMixin/Flask-Login requires is_authenticated to be defined
@property
Expand Down Expand Up @@ -271,10 +279,16 @@ def set_password(self, password):
"""Set password: hash if not already hashed, else set directly."""
if password and password.startswith("pbkdf2:sha256:"):
# Already hashed, set directly
self._password = password
new_hash = password
else:
# Not hashed, hash it
self._password = generate_password_hash(password, "pbkdf2:sha256", salt_length=10)
new_hash = generate_password_hash(password, "pbkdf2:sha256", salt_length=10)

# Only bump on an actual change, so idempotent operations (e.g. re-importing the
# same hash during a data restore) don't needlessly invalidate live sessions/JWTs.
if new_hash != self._password:
self.token_version = (self.token_version or 0) + 1
self._password = new_hash

# check password parameter versus stored/encrypted password
def is_password(self, password):
Expand Down
2 changes: 1 addition & 1 deletion scripts/db_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def backup_mysql_database():
f"--host={app.config['DB_ENDPOINT']}",
'--port=3306',
f"--user={app.config['DB_USERNAME']}",
'--single-transaction', '--routines', '--triggers',
'--single-transaction', '--set-gtid-purged=OFF', '--no-tablespaces','--routines', '--triggers',
db_name,
]
env = dict(os.environ, MYSQL_PWD=app.config['DB_PASSWORD'])
Expand Down
Loading