From 661b9d2691040e81415cc1a7cd47e41728d237b4 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Thu, 20 Aug 2026 23:07:35 -0700 Subject: [PATCH 1/2] Invalidate sessions and JWTs when a user's password changes *** REQUIRED BEFORE THIS DEPLOYS: production runs MySQL (see __init__.py -- SQLALCHEMY_DATABASE_URI switches to MySQL whenever DB_ENDPOINT/DB_USERNAME/ DB_PASSWORD are set), a completely separate database this session had no access to. Only the local dev SQLite DB has been migrated. Someone MUST run this against production before/with this deploy, or every login there will error on the missing column: ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0; *** Previously nothing tied an issued JWT or Flask-Login session to a specific password: the JWT carried no exp claim at all (never expired by JWT semantics) and no password-derived data, and Flask-Login sessions just carried a bare user id, re-validated against fresh DB data on every request but with no check that the underlying credential hadn't changed. A stolen JWT or session cookie kept working indefinitely, surviving a password reset that was meant to lock an attacker out. Adds User.token_version, bumped in set_password() (the single funnel every password-change path already goes through) only on an actual hash change. JWTs now carry token_version + exp and are checked against the account's current value in auth_required. Sessions now carry it via a composite get_id() ("id:token_version"), checked in load_user (main.py), so a stale session is rejected before ever reaching a @login_required route instead of running with outdated auth state. Verified live: fresh JWT/session -> 200, password reset -> old JWT gets 401 with an explicit "password has changed" message, old session gets redirected to login, fresh login after the reset works again. --- api/authorize.py | 14 ++++++++++++-- api/user.py | 12 ++++++++++-- main.py | 12 +++++++++++- model/user.py | 22 ++++++++++++++++++---- 4 files changed, 51 insertions(+), 9 deletions(-) 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..8cf6928 100644 --- a/main.py +++ b/main.py @@ -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(): 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): From c8ed790dcc062ebd0c4203c9ce218755e9e823ff Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Mon, 31 Aug 2026 11:59:42 -0700 Subject: [PATCH 2/2] Treat a legacy session id (no token_version) as version 0 Every session predating this feature has a plain "id" cookie with no ":token_version" suffix, which load_user() previously invalidated unconditionally (the split on ":" raised, caught, returned None). Treat a missing ":" as token_version "0" instead, matching a fresh/unchanged account -- it still correctly fails once that account's real token_version has moved past 0 from an actual password change. Co-Authored-By: Claude Sonnet 5 --- main.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 8cf6928..16615d9 100644 --- a/main.py +++ b/main.py @@ -113,11 +113,20 @@ def load_user(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: + # + # 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(int(raw_id)) + user = User.query.get(raw_id) if user is None or str(user.token_version) != token_version: return None return user