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
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
12 changes: 10 additions & 2 deletions api/user.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 Down Expand Up @@ -392,8 +392,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
21 changes: 20 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,26 @@ 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.
#
# A session issued before this field existed at all has no ":" -- treat that as
# token_version "0" (matching a fresh/unchanged account) instead of invalidating
# it outright. It still correctly fails below once the account's real
# token_version has moved past 0 from an actual password change.
if ":" in user_id:
raw_id, token_version = user_id.split(":", 1)
else:
raw_id, token_version = user_id, "0"
try:
raw_id = int(raw_id)
except ValueError:
return None
user = User.query.get(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