remove_user_sessions() got the user id as except_id, so the exception never matched. On an own password change the result was correct only by accident. When an admin changed another user's password, the admin session got a new id, but the old entry stayed valid in the store. - New Session.regenerate(): keeps the data under a new id and removes the old store entry. - change_password() handles the sessions: on an own password change it regenerates the current session and ends all other sessions of the user (except_id is now the session id). When an admin changes another user's password, the admin session stays untouched and all sessions of that user end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
6.3 KiB
Python
189 lines
6.3 KiB
Python
import time
|
|
|
|
from flask import request
|
|
from flask.sessions import SessionInterface as FlaskSessionInterface, SessionMixin
|
|
from itsdangerous import BadSignature, Signer, want_bytes
|
|
from werkzeug.datastructures import CallbackDict
|
|
|
|
from max.crypto import get_random_string
|
|
from max.exceptions import SessionWithoutUser
|
|
|
|
# in seconds
|
|
SESSION_IDLE_TIMEOUT = 30 * 60
|
|
SESSION_ABSOLUTE_TIMEOUT = 12 * 60 * 60
|
|
|
|
# Keys for the session metadata in the session storage
|
|
CREATED_AT_KEY = "_created_at"
|
|
LAST_SEEN_KEY = "_last_seen"
|
|
|
|
|
|
SESSION_MAX_PER_USER = 10
|
|
|
|
|
|
class SessionStorage:
|
|
def __init__(self):
|
|
self.data = {}
|
|
|
|
def __getitem__(self, id):
|
|
return self.data.get(id)
|
|
|
|
def __delitem__(self, id):
|
|
self.data.pop(id, None)
|
|
|
|
def __setitem__(self, id, data):
|
|
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
|
|
|
|
def _limit_user_sessions(self, user_id):
|
|
"""Makes room for one new session by removing the least recently used ones"""
|
|
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])
|
|
for id in user_ids[: max(0, len(user_ids) - SESSION_MAX_PER_USER + 1)]:
|
|
del self.data[id]
|
|
|
|
def remove_user_sessions(self, user_id, except_id=None):
|
|
ids_to_remove = []
|
|
for id, data in self.data.items():
|
|
if data["user_id"] == user_id and id != except_id:
|
|
ids_to_remove.append(id)
|
|
|
|
for id in ids_to_remove:
|
|
del self.data[id]
|
|
|
|
|
|
session_storage = SessionStorage()
|
|
|
|
|
|
class Session(CallbackDict, SessionMixin):
|
|
def __init__(self, id=None, data=None, created_at=None, last_seen=None):
|
|
def on_update(self):
|
|
self.modified = True
|
|
if not self.id:
|
|
self.set_new_id()
|
|
|
|
self.id = id
|
|
self.created_at = created_at
|
|
self.last_seen = last_seen
|
|
self.modified = False
|
|
CallbackDict.__init__(self, data, on_update)
|
|
|
|
def is_empty(self):
|
|
return len(self) == 0
|
|
|
|
def is_expired(self, now):
|
|
return (
|
|
now - self.created_at > SESSION_ABSOLUTE_TIMEOUT
|
|
or now - self.last_seen > SESSION_IDLE_TIMEOUT
|
|
)
|
|
|
|
def clear(self):
|
|
"""Removes all data, including the entry in the session storage"""
|
|
del session_storage[self.id]
|
|
self.id = None
|
|
self.created_at = None
|
|
self.last_seen = None
|
|
super().clear()
|
|
|
|
def set_new_id(self):
|
|
self.id = get_random_string(32)
|
|
|
|
def regenerate(self):
|
|
"""Keeps the data under a new id. The old id becomes invalid."""
|
|
del session_storage[self.id]
|
|
self.set_new_id()
|
|
self.modified = True
|
|
|
|
|
|
class SessionInterface(FlaskSessionInterface):
|
|
def _get_signer(self, app):
|
|
if not app.secret_key:
|
|
return None
|
|
return Signer(app.secret_key, salt="flask-session", key_derivation="hmac")
|
|
|
|
def get_cookie_name(self, app):
|
|
return app.config["SESSION_COOKIE_NAME"]
|
|
|
|
def make_session_object(self, session_id=None, data=None):
|
|
created_at = last_seen = None
|
|
if data is not None:
|
|
data = dict(data) # do not modify the entry in the session storage
|
|
created_at = data.pop(CREATED_AT_KEY)
|
|
last_seen = data.pop(LAST_SEEN_KEY)
|
|
return Session(
|
|
session_id, data=data, created_at=created_at, last_seen=last_seen
|
|
)
|
|
|
|
def open_session(self, app, request):
|
|
session_id = request.cookies.get(self.get_cookie_name(app))
|
|
if not session_id:
|
|
return self.make_session_object()
|
|
|
|
signer = self._get_signer(app)
|
|
if signer is None:
|
|
return None
|
|
try:
|
|
sid_as_bytes = signer.unsign(session_id)
|
|
session_id = sid_as_bytes.decode()
|
|
except BadSignature:
|
|
return self.make_session_object()
|
|
|
|
if not isinstance(session_id, str):
|
|
session_id = session_id.decode("utf-8", "strict")
|
|
|
|
data = session_storage[session_id]
|
|
if data is None:
|
|
# Never adopt an id chosen by the client (session fixation)
|
|
return self.make_session_object()
|
|
|
|
session = self.make_session_object(session_id, data=data)
|
|
if session.is_expired(time.monotonic()):
|
|
del session_storage[session_id]
|
|
return self.make_session_object()
|
|
return session
|
|
|
|
def get_cookie_attributes(self, app):
|
|
# Attributes for setting and deleting the cookie must match, because
|
|
# browsers ignore a deletion of a __Host- cookie without Secure and Path.
|
|
return dict(
|
|
domain=self.get_cookie_domain(app),
|
|
path=self.get_cookie_path(app),
|
|
secure=self.get_cookie_secure(app),
|
|
httponly=self.get_cookie_httponly(app),
|
|
samesite=self.get_cookie_samesite(app),
|
|
)
|
|
|
|
def save_session(self, app, session, response):
|
|
cookie_name = self.get_cookie_name(app)
|
|
cookie_attributes = self.get_cookie_attributes(app)
|
|
if session.is_empty():
|
|
if session.modified:
|
|
del session_storage[session.id]
|
|
# Only send the deletion header if the client sent a cookie
|
|
if cookie_name in request.cookies:
|
|
response.delete_cookie(cookie_name, **cookie_attributes)
|
|
else:
|
|
expires = self.get_expiration_time(app, session)
|
|
session.last_seen = time.monotonic()
|
|
if session.created_at is None:
|
|
session.created_at = session.last_seen
|
|
try:
|
|
session_storage[session.id] = {
|
|
**session,
|
|
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 = (
|
|
self._get_signer(app).sign(want_bytes(session.id)).decode("utf-8")
|
|
)
|
|
response.set_cookie(
|
|
cookie_name, session_id, expires=expires, **cookie_attributes
|
|
)
|