Limit sessions per user instead of a global LRU cap

The session store held at most 100 sessions and evicted the least
recently used one. Anyone with an account could log in 101 times and
thereby log out every other user, including the admins.

- Each user has at most 10 sessions. A new login above the limit
  removes the user's own least recently used session (by _last_seen).
- There is no global limit anymore: the store only accepts sessions
  with a user_id (raises SessionWithoutUser otherwise), so it is
  bounded by 10 * number of users. Anonymous users still create no
  store entries.
- save_session clears an invalid session before re-raising, since
  Flask saves the session again for the error response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
finnandClaude Opus 5.5 committed 2026-10-21 17:27:44 +00:00
1 parent 80757fa1d8
commit 8e32727fcd
2 files changed
+33 -29

No files matched your search

+4
View File
@@ -10,6 +10,10 @@ class NotFound(MaxException):
pass pass
class SessionWithoutUser(MaxException):
pass
class PermissionDenied(MaxException): class PermissionDenied(MaxException):
def __init__(self, message): def __init__(self, message):
super().__init__() super().__init__()
+29 -29
View File
@@ -6,6 +6,7 @@ from itsdangerous import BadSignature, Signer, want_bytes
from werkzeug.datastructures import CallbackDict from werkzeug.datastructures import CallbackDict
from max.crypto import get_random_string from max.crypto import get_random_string
from max.exceptions import SessionWithoutUser
# in seconds # in seconds
SESSION_IDLE_TIMEOUT = 30 * 60 SESSION_IDLE_TIMEOUT = 30 * 60
@@ -16,48 +17,42 @@ CREATED_AT_KEY = "_created_at"
LAST_SEEN_KEY = "_last_seen" LAST_SEEN_KEY = "_last_seen"
SESSION_MAX_PER_USER = 10
class SessionStorage: class SessionStorage:
def __init__(self, maxcap=100): def __init__(self):
self.maxcap = maxcap
self.lru_ids = [] # newest ids are on the end (so do append to add a fresh id)
self.data = {} self.data = {}
def __getitem__(self, id): def __getitem__(self, id):
if id not in self.data: return self.data.get(id)
return None
self._refresh_id(id)
return self.data[id]
def __delitem__(self, id): def __delitem__(self, id):
if id in self.data: self.data.pop(id, None)
del self.data[id]
self._remove_id(id)
def __setitem__(self, id, data): def __setitem__(self, id, data):
self._refresh_id(id) if data.get("user_id") is None:
raise SessionWithoutUser("Refusing to store a session without user_id")
if id not in self.data:
self._limit_user_sessions(data["user_id"])
self.data[id] = data self.data[id] = data
while len(self.lru_ids) > self.maxcap: def _limit_user_sessions(self, user_id):
removed_id = self.lru_ids.pop(0) # remove first element """Makes room for one new session by removing the least recently used ones"""
del self.data[removed_id] user_ids = [id for id, data in self.data.items() if data["user_id"] == user_id]
user_ids.sort(key=lambda id: self.data[id][LAST_SEEN_KEY])
def _remove_id(self, id): for id in user_ids[: max(0, len(user_ids) - SESSION_MAX_PER_USER + 1)]:
self.lru_ids = [_id for _id in self.lru_ids if _id != id] del self.data[id]
def _refresh_id(self, id):
self._remove_id(id)
self.lru_ids.append(id)
def remove_user_sessions(self, user_id, except_id=None): def remove_user_sessions(self, user_id, except_id=None):
ids_to_remove = [] ids_to_remove = []
for id, data in self.data.items(): for id, data in self.data.items():
if data.get("user_id", None) == user_id and id != except_id: if data["user_id"] == user_id and id != except_id:
ids_to_remove.append(id) ids_to_remove.append(id)
for id in ids_to_remove: for id in ids_to_remove:
del self.data[id] del self.data[id]
self._remove_id(id)
session_storage = SessionStorage() session_storage = SessionStorage()
@@ -161,11 +156,16 @@ class SessionInterface(FlaskSessionInterface):
session.last_seen = time.monotonic() session.last_seen = time.monotonic()
if session.created_at is None: if session.created_at is None:
session.created_at = session.last_seen session.created_at = session.last_seen
session_storage[session.id] = { try:
**session, session_storage[session.id] = {
CREATED_AT_KEY: session.created_at, **session,
LAST_SEEN_KEY: session.last_seen, CREATED_AT_KEY: session.created_at,
} LAST_SEEN_KEY: session.last_seen,
}
except SessionWithoutUser:
# Flask saves the session again for the error response
session.clear()
raise
session_id = ( session_id = (
self._get_signer(app).sign(want_bytes(session.id)).decode("utf-8") self._get_signer(app).sign(want_bytes(session.id)).decode("utf-8")
) )