diff --git a/CHANGES.rst b/CHANGES.rst index 8a6b0a01..6a29726d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,15 +1,17 @@ Version 0.7.0 ------------------ +- Use msgpack for serialization, along with ``SESSION_SERIALIZATION_FORMAT``. - Prevent sid reuse on storage miss. - Add time-to-live expiration for MongoDB. - Abstraction to improve consistency between backends. - Enforce PERMANENT_SESSION_LIFETIME as expiration consistently for all backends. -- Add logo and additional Documentation. -- Add ``flask session_cleanup`` command and alternatively, SESSION_CLEANUP_N_REQUESTS for SQLAlchemy +- Add logo and additional documentation. +- Add ``flask session_cleanup`` command and alternatively, ``SESSION_CLEANUP_N_REQUESTS`` for SQLAlchemy - Use Vary cookie header - Type hints - Remove null session in favour of specific exception messages. +- Deprecate ``SESSION_USE_SIGNER``. Version 0.6.0 diff --git a/docs/config_flask_session.rst b/docs/config_flask_session.rst index 8ba6eb99..cf643827 100644 --- a/docs/config_flask_session.rst +++ b/docs/config_flask_session.rst @@ -41,5 +41,14 @@ These are specific to Flask-Session. Default: ``32`` +.. py:data:: SESSION_SERIALIZATION_FORMAT + + The serialization format to use. Can be 'msgpack' or 'json'. Set to 'msgpack' for a more efficient serialization format. Set to 'json' for a human-readable format. + + Default: ``'msgpack'`` + +.. versionadded:: 0.7.0 + ``SESSION_SERIALIZATION_FORMAT`` + .. versionadded:: 0.6 ``SESSION_ID_LENGTH`` diff --git a/docs/installation.rst b/docs/installation.rst index b9fcf91e..bac7af89 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -8,7 +8,11 @@ Install from PyPI using an installer such as pip: $ pip install Flask-Session -Flask-Session has no dependencies other than those included with Flask. However, unless you are using the FileSystemCache, you need to choose and a backend and install an appropriate client library. +Flask-Session's only required dependency is msgspec for serialization, which has no sub-dependencies. + +.. note:: + + You need to choose and a backend and install an appropriate client library, unless you are using the FileSystemCache. For example, if you want to use Redis as your backend, you will need to install the redis-py client library: diff --git a/pyproject.toml b/pyproject.toml index be1450ac..08305aa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ requires-python = ">=3.7" dependencies = [ "flask>=2.2", - "cachelib", + "msgspec>=0.18.6", ] dynamic = ["version"] diff --git a/requirements/dev.txt b/requirements/dev.txt index 25311c8e..465f83bb 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,6 +1,6 @@ # Core flask>=2.2 -cachelib +msgspec # Linting ruff @@ -14,4 +14,4 @@ redis python-memcached Flask-SQLAlchemy pymongo - +cachelib diff --git a/src/flask_session/__init__.py b/src/flask_session/__init__.py index e42ea269..89e7d1a8 100644 --- a/src/flask_session/__init__.py +++ b/src/flask_session/__init__.py @@ -67,6 +67,9 @@ def _get_interface(self, app): SESSION_SID_LENGTH = config.get( "SESSION_ID_LENGTH", Defaults.SESSION_SID_LENGTH ) + SESSION_SERIALIZATION_FORMAT = config.get( + "SESSION_SERIALIZATION_FORMAT", Defaults.SESSION_SERIALIZATION_FORMAT + ) # Redis settings SESSION_REDIS = config.get("SESSION_REDIS", Defaults.SESSION_REDIS) @@ -116,6 +119,7 @@ def _get_interface(self, app): "use_signer": SESSION_USE_SIGNER, "permanent": SESSION_PERMANENT, "sid_length": SESSION_SID_LENGTH, + "serialization_format": SESSION_SERIALIZATION_FORMAT, } if SESSION_TYPE == "redis": @@ -153,6 +157,6 @@ def _get_interface(self, app): cleanup_n_requests=SESSION_CLEANUP_N_REQUESTS, ) else: - raise RuntimeError(f"Unrecognized value for SESSION_TYPE: {SESSION_TYPE}") + raise ValueError(f"Unrecognized value for SESSION_TYPE: {SESSION_TYPE}") return session_interface diff --git a/src/flask_session/defaults.py b/src/flask_session/defaults.py index 775c248b..1df76711 100644 --- a/src/flask_session/defaults.py +++ b/src/flask_session/defaults.py @@ -8,6 +8,7 @@ class Defaults: SESSION_USE_SIGNER = False SESSION_PERMANENT = True SESSION_SID_LENGTH = 32 + SESSION_SERIALIZATION_FORMAT = "msgpack" # Clean up settings for non TTL backends (SQL, PostgreSQL, etc.) SESSION_CLEANUP_N_REQUESTS = None diff --git a/src/flask_session/sessions.py b/src/flask_session/sessions.py index d9eb0fc8..6f41d1eb 100644 --- a/src/flask_session/sessions.py +++ b/src/flask_session/sessions.py @@ -2,12 +2,8 @@ import time import warnings from abc import ABC - -try: - import cPickle as pickle -except ImportError: - import pickle import random +import msgspec from datetime import datetime from datetime import timedelta as TimeDelta from typing import Any, Optional @@ -89,6 +85,12 @@ def _sign(self, app, sid: str) -> str: sid_as_bytes = want_bytes(sid) return signer.sign(sid_as_bytes).decode("utf-8") + def _serialize(self, session: ServerSideSession) -> bytes: + return self.encoder.encode(dict(session)) + + def _deserialize(self, serialized_data): + return self.decoder.decode(serialized_data) + def _get_store_id(self, sid: str) -> str: return self.key_prefix + sid @@ -107,6 +109,7 @@ def __init__( use_signer: bool = Defaults.SESSION_USE_SIGNER, permanent: bool = Defaults.SESSION_PERMANENT, sid_length: int = Defaults.SESSION_SID_LENGTH, + serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT, cleanup_n_requests: Optional[int] = Defaults.SESSION_CLEANUP_N_REQUESTS, ): self.app = app @@ -131,6 +134,18 @@ def __init__( else: self._register_cleanup_app_command() + # Set the serialization format + if serialization_format == "msgpack": + self.decoder = msgspec.msgpack.Decoder() + self.encoder = msgspec.msgpack.Encoder() + elif serialization_format == "json": + self.decoder = msgspec.json.Decoder() + self.encoder = msgspec.json.Encoder() + else: + raise ValueError( + "Unrecognized value for SESSION_SERIALIZATION_FORMAT: {SESSION_SERIALIZATION_FORMAT}" + ) + def save_session( self, app: Flask, session: ServerSideSession, response: Response ) -> None: @@ -264,7 +279,6 @@ class RedisSessionInterface(ServerSideSessionInterface): The `use_signer` parameter was added. """ - serializer = pickle session_class = RedisSession ttl = True @@ -275,6 +289,7 @@ def __init__( use_signer: bool, permanent: bool, sid_length: int, + serialization_format: str, redis: Any = Defaults.SESSION_REDIS, ): if redis is None: @@ -282,7 +297,9 @@ def __init__( redis = Redis() self.redis = redis - super().__init__(app, key_prefix, use_signer, permanent, sid_length) + super().__init__( + app, key_prefix, use_signer, permanent, sid_length, serialization_format + ) @retry_query() def _retrieve_session_data(self, store_id: str) -> Optional[dict]: @@ -290,10 +307,12 @@ def _retrieve_session_data(self, store_id: str) -> Optional[dict]: serialized_session_data = self.redis.get(store_id) if serialized_session_data: try: - session_data = self.serializer.loads(serialized_session_data) + session_data = self._deserialize(serialized_session_data) return session_data - except pickle.UnpicklingError: - self.app.logger.error("Failed to unpickle session data", exc_info=True) + except msgspec.DecodeError: + self.app.logger.error( + "Failed to deserialize session data", exc_info=True + ) return None @retry_query() @@ -307,7 +326,7 @@ def _upsert_session( storage_time_to_live = total_seconds(session_lifetime) # Serialize the session data - serialized_session_data = self.serializer.dumps(dict(session)) + serialized_session_data = self._serialize(dict(session)) # Update existing or create new session in the database self.redis.set( @@ -333,7 +352,6 @@ class MemcachedSessionInterface(ServerSideSessionInterface): The `use_signer` parameter was added. """ - serializer = pickle session_class = MemcachedSession ttl = True @@ -344,12 +362,15 @@ def __init__( use_signer: bool, permanent: bool, sid_length: int, + serialization_format: str, client: Any = Defaults.SESSION_MEMCACHED, ): if client is None: client = self._get_preferred_memcache_client() self.client = client - super().__init__(app, key_prefix, use_signer, permanent, sid_length) + super().__init__( + app, key_prefix, use_signer, permanent, sid_length, serialization_format + ) def _get_preferred_memcache_client(self): clients = [ @@ -384,10 +405,12 @@ def _retrieve_session_data(self, store_id: str) -> Optional[dict]: serialized_session_data = self.client.get(store_id) if serialized_session_data: try: - session_data = self.serializer.loads(serialized_session_data) + session_data = self._deserialize(serialized_session_data) return session_data - except pickle.UnpicklingError: - self.app.logger.error("Failed to unpickle session data", exc_info=True) + except msgspec.DecodeError: + self.app.logger.error( + "Failed to deserialize session data", exc_info=True + ) return None @retry_query() @@ -401,7 +424,7 @@ def _upsert_session( storage_time_to_live = total_seconds(session_lifetime) # Serialize the session data - serialized_session_data = self.serializer.dumps(dict(session)) + serialized_session_data = self._serialize(dict(session)) # Update existing or create new session in the database self.client.set( @@ -431,7 +454,6 @@ class FileSystemSessionInterface(ServerSideSessionInterface): """ session_class = FileSystemSession - serializer = None ttl = True def __init__( @@ -441,6 +463,7 @@ def __init__( use_signer: bool, permanent: bool, sid_length: int, + serialization_format: str, cache_dir: str = Defaults.SESSION_FILE_DIR, threshold: int = Defaults.SESSION_FILE_THRESHOLD, mode: int = Defaults.SESSION_FILE_MODE, @@ -448,7 +471,9 @@ def __init__( from cachelib.file import FileSystemCache self.cache = FileSystemCache(cache_dir, threshold=threshold, mode=mode) - super().__init__(app, key_prefix, use_signer, permanent, sid_length) + super().__init__( + app, key_prefix, use_signer, permanent, sid_length, serialization_format + ) @retry_query() def _retrieve_session_data(self, store_id: str) -> Optional[dict]: @@ -494,7 +519,6 @@ class MongoDBSessionInterface(ServerSideSessionInterface): The `use_signer` parameter was added. """ - serializer = pickle session_class = MongoDBSession ttl = True @@ -505,6 +529,7 @@ def __init__( use_signer: bool, permanent: bool, sid_length: int, + serialization_format: str, client: Any = Defaults.SESSION_MONGODB, db: str = Defaults.SESSION_MONGODB_DB, collection: str = Defaults.SESSION_MONGODB_COLLECT, @@ -521,7 +546,9 @@ def __init__( # Create a TTL index on the expiration time, so that mongo can automatically delete expired sessions self.store.create_index("expiration", expireAfterSeconds=0) - super().__init__(app, key_prefix, use_signer, permanent, sid_length) + super().__init__( + app, key_prefix, use_signer, permanent, sid_length, serialization_format + ) @retry_query() def _retrieve_session_data(self, store_id: str) -> Optional[dict]: @@ -530,10 +557,12 @@ def _retrieve_session_data(self, store_id: str) -> Optional[dict]: if document: serialized_session_data = want_bytes(document["val"]) try: - session_data = self.serializer.loads(serialized_session_data) + session_data = self._deserialize(serialized_session_data) return session_data - except pickle.UnpicklingError: - self.app.logger.error("Failed to unpickle session data", exc_info=True) + except msgspec.DecodeError: + self.app.logger.error( + "Failed to deserialize session data", exc_info=True + ) return None @retry_query() @@ -550,7 +579,7 @@ def _upsert_session( storage_expiration_datetime = datetime.utcnow() + session_lifetime # Serialize the session data - serialized_session_data = self.serializer.dumps(dict(session)) + serialized_session_data = self._serialize(dict(session)) # Update existing or create new session in the database if self.use_deprecated_method: @@ -602,7 +631,6 @@ class SqlAlchemySessionInterface(ServerSideSessionInterface): The `use_signer` parameter was added. """ - serializer = pickle session_class = SqlAlchemySession ttl = False @@ -613,6 +641,7 @@ def __init__( use_signer: bool, permanent: bool, sid_length: int, + serialization_format: str, db: Any = Defaults.SESSION_SQLALCHEMY, table: str = Defaults.SESSION_SQLALCHEMY_TABLE, sequence: Optional[str] = Defaults.SESSION_SQLALCHEMY_SEQUENCE, @@ -630,7 +659,13 @@ def __init__( self.schema = schema self.bind_key = bind_key super().__init__( - app, key_prefix, use_signer, permanent, sid_length, cleanup_n_requests + app, + key_prefix, + use_signer, + permanent, + sid_length, + serialization_format, + cleanup_n_requests, ) # Create the Session database model @@ -698,11 +733,11 @@ def _retrieve_session_data(self, store_id: str) -> Optional[dict]: if record: serialized_session_data = want_bytes(record.data) try: - session_data = self.serializer.loads(serialized_session_data) + session_data = self._deserialize(serialized_session_data) return session_data - except pickle.UnpicklingError as e: + except msgspec.DecodeError as e: self.app.logger.exception( - e, "Failed to unpickle session data", exc_info=True + e, "Failed to deserialize session data", exc_info=True ) return None @@ -722,7 +757,7 @@ def _upsert_session( storage_expiration_datetime = datetime.utcnow() + session_lifetime # Serialize session data - serialized_session_data = self.serializer.dumps(dict(session)) + serialized_session_data = self._serialize(dict(session)) # Update existing or create new session in the database try: