End a user's sessions when the account is deactivated

There is no token denylist in this product, so the JWT user loader is
the only place a deactivated account can be stopped, and it did a bare
lookup by id. Somebody deactivated at 09:00 kept a working access token
until it expired, and a refresh token that minted fresh ones for thirty
days.

/api/auth/refresh and the API-token path already checked isactive. This
makes the loader consistent with them rather than inventing policy.

Scope: product
(cherry picked from commit 9c1848fc61)
This commit is contained in:
cproudlock
2026-08-31 07:02:00 -04:00
parent 85e8a7b877
commit d1a7907a2c
3 changed files with 83 additions and 2 deletions

View File

@@ -98,7 +98,13 @@ from .plugins import plugin_manager
# model internals. The endpoints plugin resolves a delivered script by name and
# serves its body by digest to the agent. Additive name on the surface, minor
# bump.
__contract_version__ = '0.27.0'
# 0.28.0: added client_ip and strip_port to shopdb.api so a plugin does not
# hand-roll caller-IP extraction. Two plugins had, and both read the first
# X-Forwarded-For hop, which the caller sends - so a rate limit keyed on it was
# defeated by rotating the header. The helper resolves from remote_addr, which
# ProxyFix has already filled from the header when TRUSTED_PROXY_COUNT says a
# proxy is really in front. Additive names on the surface, minor bump.
__contract_version__ = '0.28.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
@@ -211,7 +217,14 @@ def create_app(config_name: str = None) -> Flask:
def user_lookup_callback(_jwt_header, jwt_data):
from .core.models import User
identity = jwt_data["sub"]
return db.session.get(User, int(identity))
user = db.session.get(User, int(identity))
# Deactivating an account has to end its sessions. There is no token
# denylist, so without this check the access token stays good until it
# expires and the refresh token keeps minting new ones for 30 days.
# /auth/refresh and the API-token path already check isactive.
if user is None or not user.isactive:
return None
return user
@app.after_request
def apply_security_headers(response):