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:
1 parent
80757fa1d8
commit
8e32727fcd
2 files changed
+28
-24
No files matched your search
@@ -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__()
|
||||||
|
|||||||
+24
-24
@@ -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
|
||||||
|
try:
|
||||||
session_storage[session.id] = {
|
session_storage[session.id] = {
|
||||||
**session,
|
**session,
|
||||||
CREATED_AT_KEY: session.created_at,
|
CREATED_AT_KEY: session.created_at,
|
||||||
LAST_SEEN_KEY: session.last_seen,
|
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")
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user