diff --git a/api/authorize.py b/api/authorize.py index df7ae49..1feafda 100644 --- a/api/authorize.py +++ b/api/authorize.py @@ -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 diff --git a/api/user.py b/api/user.py index 31f87ad..cf5ff6b 100644 --- a/api/user.py +++ b/api/user.py @@ -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 @@ -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" ) diff --git a/main.py b/main.py index d39177f..16615d9 100644 --- a/main.py +++ b/main.py @@ -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(): diff --git a/model/user.py b/model/user.py index 2f0ec24..796191d 100644 --- a/model/user.py +++ b/model/user.py @@ -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 @@ -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 @@ -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):