diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml new file mode 100644 index 00000000..e8133f29 --- /dev/null +++ b/.github/workflows/ruff.yaml @@ -0,0 +1,8 @@ +name: Ruff +on: [push, pull_request] +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: chartboost/ruff-action@v1 \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..10e18a3a --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,16 @@ +name: Run unittests +on: [push, pull_request] +jobs: + unittests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: supercharge/redis-github-action@1.5.0 + - uses: niden/actions-memcached@v7 + - uses: actions/setup-python@v4 + with: + python-version: 'pypy3.9' + - name: Install testing requirements + run: pip3 install -r requirements/pytest.txt + - name: Run tests + run: pytest tests diff --git a/.gitignore b/.gitignore index 0171c027..a002c3cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,14 @@ /.idea/ /.vscode/ /.venv/ +/venv/ __pycache__/ /dist/ *.egg-info/ /.pytest_cache/ /.coverage /.coverage.* +*.coveragerc /htmlcov/ /.mypy_cache/ /.tox/ diff --git a/CHANGES.rst b/CHANGES.rst index af52d39f..84ee2292 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,22 @@ +Version 0.6.0-RC1 +------------------ + +Unreleased + +- Use ``should_set_cookie`` for preventing each request from saving the session again. +- Permanent session otherwise empty will not be saved. +- Use `secrets` module to generate session identifiers, with 256 bits of + entropy (was previously 122). +- Explicitly name support for python-memcached, pylibmc and pymemcache. +- Introduce SESSION_KEY_LENGTH to control the length of the session key in bytes, default is 32. +- Fix pymongo 4.0 compatibility. +- Fix expiry is None bug in SQLAlchemy. +- Fix bug when existing SQLAlchemy db instance. +- Support SQLAlchemy SESSION_SQLALCHEMY_SEQUENCE, SESSION_SQLALCHEMY_SCHEMA and SESSION_SQLALCHEMY_BINDKEY +- Drop support for Redis < 2.6.12. +- Fix empty sessions being saved. +- Support Flask 3.0 and Werkzeug 3.0 + Version 0.5.0 ------------- diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..81fe685d --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,32 @@ +Getting started +------------- + +Navigate to the project directory and run the following commands: + +Create and activate a virtual environment +.. code-block:: text + python -m venv .venv + .\venv\bin\activate + + +Install dependencies +.. code-block:: text + pip install -r requirements/dev.txt + pip install -r requirements/pytest.txt + + +Install Memecached and Redis and activate local server (optional) +.. code-block:: text + brew install memcached + brew install redis + + +Run the tests together or individually +.. code-block:: text + pytest tests + pytest tests/test_basic.py + + +Pull requests +------------- +Please check previous pull requests before submitting a new one. \ No newline at end of file diff --git a/README.rst b/README.rst index e7409d15..224e2fdb 100644 --- a/README.rst +++ b/README.rst @@ -3,3 +3,32 @@ Flask-Session Flask-Session is an extension for Flask that adds support for server-side sessions to your application. + + +.. image:: https://github.com/pallets-eco/flask-session/actions/workflows/test.yaml/badge.svg?branch=development + :target: https://github.com/pallets-eco/flask-session/actions/workflows/test.yaml?query=workflow%3ACI+branch%3Adeveloment + :alt: Tests + +.. image:: https://readthedocs.org/projects/flask-session/badge/?version=stable&style=flat + :target: https://flask-session.readthedocs.io + :alt: docs + +.. image:: https://img.shields.io/github/license/pallets-eco/flask-session + :target: ./LICENSE + :alt: BSD-3 Clause License + +.. image:: https://img.shields.io/pypi/v/flask-session.svg? + :target: https://pypi.org/project/flask-session + :alt: PyPI + +.. image:: https://img.shields.io/badge/dynamic/json?query=info.requires_python&label=python&url=https%3A%2F%2Fpypi.org%2Fpypi%2Fflask-session%2Fjson + :target: https://pypi.org/project/Flask-Session/ + :alt: PyPI - Python Version + +.. image:: https://img.shields.io/github/v/release/pallets-eco/flask-session?include_prereleases&label=latest-prerelease + :target: https://github.com/pallets-eco/flask-session/releases + :alt: pre-release + +.. image:: https://codecov.io/gh/pallets-eco/flask-session/branch/master/graph/badge.svg?token=yenl5fzxxr + :target: https://codecov.io/gh/pallets-eco/flask-session + :alt: codecov \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst index b3cc4c58..45103d85 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -10,7 +10,7 @@ API .. attribute:: sid - Session id, internally we use :func:`uuid.uuid4` to generate one + Session id, internally we use :func:`secrets.token_urlsafe` to generate one session id. You can access it with ``session.sid``. .. autoclass:: NullSessionInterface diff --git a/docs/conf.py b/docs/conf.py index 13effa6d..71d55a27 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,4 +22,7 @@ "github_button": True, "github_user": "pallets-eco", "github_repo": "flask-session", + "github_type": "star", + "github_banner": True, + "show_related": True, } diff --git a/docs/config.rst b/docs/config.rst index 2d68bad0..cd433fd5 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -1,98 +1,215 @@ Configuration ============= -The following configuration values exist for Flask-Session. Flask-Session -loads these values from your Flask application config, so you should configure -your app first before you pass it to Flask-Session. Note that these values -cannot be modified after the ``init_app`` was applyed so make sure to not -modify them at runtime. +Backend Configuration +--------------------- + +Here is an example of how to configure a redis backend: + +.. code-block:: python + + app.config['SESSION_TYPE'] = 'redis' + app.config['SESSION_REDIS'] = Redis.from_url('redis://127.0.0.1:6379') We are not supplying something like ``SESSION_REDIS_HOST`` and ``SESSION_REDIS_PORT``, if you want to use the ``RedisSessionInterface``, you should configure ``SESSION_REDIS`` to your own ``redis.Redis`` instance. -This gives you more flexibility, like maybe you want to use the same -``redis.Redis`` instance for cache purpose too, then you do not need to keep +This gives you more flexibility, such as using the same +``redis.Redis`` instance for cache purposes too, then you do not need to keep two ``redis.Redis`` instance in the same process. -The following configuration values are builtin configuration values within -Flask itself that are related to session. **They are all understood by -Flask-Session, for example, you should use PERMANENT_SESSION_LIFETIME -to control your session lifetime.** - -================================= ========================================= -``SESSION_COOKIE_NAME`` the name of the session cookie -``SESSION_COOKIE_DOMAIN`` the domain for the session cookie. If - this is not set, the cookie will be - valid for all subdomains of - ``SERVER_NAME``. -``SESSION_COOKIE_PATH`` the path for the session cookie. If - this is not set the cookie will be valid - for all of ``APPLICATION_ROOT`` or if - that is not set for ``'/'``. -``SESSION_COOKIE_HTTPONLY`` controls if the cookie should be set - with the httponly flag. Defaults to - `True`. -``SESSION_COOKIE_SECURE`` controls if the cookie should be set - with the secure flag. Defaults to - `False`. -``PERMANENT_SESSION_LIFETIME`` the lifetime of a permanent session as - :class:`datetime.timedelta` object. - Starting with Flask 0.8 this can also be - an integer representing seconds. -================================= ========================================= - -A list of configuration keys also understood by the extension: - -============================= ============================================== -``SESSION_TYPE`` Specifies which type of session interface to - use. Built-in session types: - - - **null**: NullSessionInterface (default) - - **redis**: RedisSessionInterface - - **memcached**: MemcachedSessionInterface - - **filesystem**: FileSystemSessionInterface - - **mongodb**: MongoDBSessionInterface - - **sqlalchemy**: SqlAlchemySessionInterface -``SESSION_PERMANENT`` Whether use permanent session or not, default - to be ``True`` -``SESSION_USE_SIGNER`` Whether sign the session cookie sid or not, - if set to ``True``, you have to set - :attr:`flask.Flask.secret_key`, default to be - ``False`` -``SESSION_KEY_PREFIX`` A prefix that is added before all session keys. - This makes it possible to use the same backend - storage server for different apps, default - "session:" -``SESSION_REDIS`` A ``redis.Redis`` instance, default connect to - ``127.0.0.1:6379`` -``SESSION_MEMCACHED`` A ``memcache.Client`` instance, default connect - to ``127.0.0.1:11211`` -``SESSION_FILE_DIR`` The directory where session files are stored. - Default to use `flask_session` directory under - current working directory. -``SESSION_FILE_THRESHOLD`` The maximum number of items the session stores - before it starts deleting some, default 500 -``SESSION_FILE_MODE`` The file mode wanted for the session files, - default 0600 -``SESSION_MONGODB`` A ``pymongo.MongoClient`` instance, default - connect to ``127.0.0.1:27017`` -``SESSION_MONGODB_DB`` The MongoDB database you want to use, default - "flask_session" -``SESSION_MONGODB_COLLECT`` The MongoDB collection you want to use, default - "sessions" -``SESSION_SQLALCHEMY`` A ``flask_sqlalchemy.SQLAlchemy`` instance - whose database connection URI is configured - using the ``SQLALCHEMY_DATABASE_URI`` parameter -``SESSION_SQLALCHEMY_TABLE`` The name of the SQL table you want to use, - default "sessions" -============================= ============================================== - -Basically you only need to configure ``SESSION_TYPE``. +If you do not set ``SESSION_REDIS``, Flask-Session will assume you are developing locally and create a +``redis.Redis`` instance for you. It is expected you supply an instance of +``redis.Redis`` in production. .. note:: By default, all non-null sessions in Flask-Session are permanent. -.. versionadded:: 0.2 +Relevant Flask Configuration Values +------------------------------------- +The following configuration values are builtin configuration values within +Flask itself that are relate to the Flask session cookie set on the browser. Flask-Session +loads these values from your Flask application config, so you should configure +your app first before you pass it to Flask-Session. + +Note that these values +cannot be modified after the ``init_app`` was applied so make sure to not +modify them at runtime. + +``PERMANENT_SESSION_LIFETIME`` effects not only the browser cookie lifetime but also +the expiration in the server side session storage. + + +.. py:data:: SESSION_COOKIE_NAME + + The name of the session cookie. + +.. py:data:: SESSION_COOKIE_DOMAIN + + The domain for the session cookie. If this is not set, the cookie will be valid for all subdomains of ``SERVER_NAME``. + +.. py:data:: SESSION_COOKIE_PATH + + The path for the session cookie. If this is not set the cookie will be valid for all of ``APPLICATION_ROOT`` or if that is not set for ``'/'``. + +.. py:data:: SESSION_COOKIE_HTTPONLY + + Controls if the cookie should be set with the httponly flag. + + Default: ``True`` + +.. py:data:: SESSION_COOKIE_SECURE + + Controls if the cookie should be set with the secure flag. Browsers will only send cookies with requests over HTTPS if the cookie is marked "secure". The application must be served over HTTPS for this to make sense. + + Default: ``False`` + +.. py:data:: PERMANENT_SESSION_LIFETIME + + The lifetime of a permanent session as :class:`datetime.timedelta` object. Starting with Flask 0.8 this can also be an integer representing seconds. + + +Flask-Session Configuration Values +---------------------------------- + +.. py:data:: SESSION_TYPE + + Specifies which type of session interface to use. Built-in session types: + + - **null**: NullSessionInterface (default) + - **redis**: RedisSessionInterface + - **memcached**: MemcachedSessionInterface + - **filesystem**: FileSystemSessionInterface + - **mongodb**: MongoDBSessionInterface + - **sqlalchemy**: SqlAlchemySessionInterface + +.. py:data:: SESSION_PERMANENT + + Whether use permanent session or not. + + Default: ``True`` + +.. py:data:: SESSION_USE_SIGNER + + Whether sign the session cookie sid or not, if set to ``True``, you have to set :attr:`flask.Flask.secret_key`. + + Default: ``False`` + +.. py:data:: SESSION_KEY_PREFIX + + A prefix that is added before all session keys. This makes it possible to use the same backend storage server for different apps. + + Default: ``'session:'`` + +.. py:data:: SESSION_ID_LENGTH + + The length of the session identifier in bytes (of entropy). + + Default: ``32`` + +.. versionadded:: 0.6 + ``SESSION_ID_LENGTH`` + +Backend-specific Configuration Values +--------------------------------------- + +Redis +~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:data:: SESSION_REDIS + + A ``redis.Redis`` instance. + + Default: Instance connected to ``127.0.0.1:6379`` + + +Memcached +~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:data:: SESSION_MEMCACHED + + A ``memcache.Client`` instance. + + Default: Instance connected to ``127.0.0.1:6379`` + + +FileSystem +~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:data:: SESSION_FILE_DIR + + The directory where session files are stored. + + Default: ``flask_session`` directory under current working directory. + +.. py:data:: SESSION_FILE_THRESHOLD + + The maximum number of items the session stores before it starts deleting some. + + Default: ``500`` + +.. py:data:: SESSION_FILE_MODE + + The file mode wanted for the session files. + + Default: ``0600`` + + +MongoDB +~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:data:: SESSION_MONGODB + + A ``pymongo.MongoClient`` instance. + + Default: Instance connected to ``127.0.0.1:27017`` + +.. py:data:: SESSION_MONGODB_DB + + The MongoDB database you want to use. + + Default: ``'flask_session'`` + +.. py:data:: SESSION_MONGODB_COLLECT + + The MongoDB collection you want to use. + + Default: ``'sessions'`` + + +SqlAlchemy +~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:data:: SESSION_SQLALCHEMY + + A ``flask_sqlalchemy.SQLAlchemy`` instance whose database connection URI is configured using the ``SQLALCHEMY_DATABASE_URI`` parameter. + + Must be set in flask_sqlalchemy version 3.0 or higher. + +.. py:data:: SESSION_SQLALCHEMY_TABLE + + The name of the SQL table you want to use. + + Default: ``'sessions'`` + +.. py:data:: SESSION_SQLALCHEMY_SEQUENCE + + The name of the sequence you want to use for the primary key. + + Default: ``None`` + +.. py:data:: SESSION_SQLALCHEMY_SCHEMA + + The name of the schema you want to use. + + Default: ``None`` + +.. py:data:: SESSION_SQLALCHEMY_BIND_KEY + + The name of the bind key you want to use. + + Default: ``None`` - ``SESSION_TYPE``: **sqlalchemy**, ``SESSION_USE_SIGNER`` +.. versionadded:: 0.6 + ``SESSION_SQLALCHEMY_BIND_KEY``, ``SESSION_SQLALCHEMY_SCHEMA``, ``SESSION_SQLALCHEMY_SEQUENCE`` diff --git a/docs/index.rst b/docs/index.rst index 9448e827..f1508d66 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,24 +7,14 @@ your application. .. _Flask: http://flask.palletsprojects.com/ -Installation ------------- - -Install from PyPI using an installer such as pip: - -.. code-block:: text - - $ pip install Flask-Session - - Table of Contents ----------------- .. toctree:: :maxdepth: 2 + installation quickstart - interfaces config api license diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 00000000..dadde718 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,45 @@ + +Installation +============ + +Install from PyPI using an installer such as pip: + +.. code-block:: text + + $ pip install Flask-Session + +Unless you are using the FileSystemCache, you will also need to choose and a backend and install an appropriate client library. + +For example, if you want to use Redis as your backend, you will need to install the redis-py client library: + +.. code-block:: text + + $ pip install redis + + +Supported Backends and Client Libraries +--------------------------------------- + + +.. list-table:: + :header-rows: 1 + + * - Backend + - Client Library + * - Redis + - redis-py_ + * - Memcached + - pylibmc_, python-memcached_, pymemcache_ + * - MongoDB + - pymongo_ + * - SQL Alchemy + - flask-sqlalchemy_ + +Other clients may work if they use the same commands as the ones listed above. + +.. _redis-py: https://github.com/andymccurdy/redis-py +.. _pylibmc: http://sendapatch.se/projects/pylibmc/ +.. _python-memcached: https://github.com/linsomniac/python-memcached +.. _pymemcache: https://github.com/pinterest/pymemcache +.. _pymongo: http://api.mongodb.org/python/current/index.html +.. _Flask-SQLAlchemy: https://github.com/pallets-eco/flask-sqlalchemy \ No newline at end of file diff --git a/docs/interfaces.rst b/docs/interfaces.rst deleted file mode 100644 index f45d66a4..00000000 --- a/docs/interfaces.rst +++ /dev/null @@ -1,68 +0,0 @@ -Built-in Session Interfaces -=========================== - -.. currentmodule:: flask_session - - -:class:`NullSessionInterface` ------------------------------ - -If you do not configure a different ``SESSION_TYPE``, this will be used to -generate nicer error messages. Will allow read-only access to the empty -session but fail on setting. - - -:class:`RedisSessionInterface` ------------------------------- - -Uses the Redis key-value store as a session backend. (`redis-py`_ required) - -Relevant configuration values: - -- SESSION_REDIS - - -:class:`MemcachedSessionInterface` ----------------------------------- - -Uses the Memcached as a session backend. (`pylibmc`_ or `memcache`_ required) - -- SESSION_MEMCACHED - - -:class:`FileSystemSessionInterface` ------------------------------------ - -Uses the :class:`cachelib.file.FileSystemCache` as a session backend. - -- SESSION_FILE_DIR -- SESSION_FILE_THRESHOLD -- SESSION_FILE_MODE - - -:class:`MongoDBSessionInterface` --------------------------------- - -Uses the MongoDB as a session backend. (`pymongo`_ required) - -- SESSION_MONGODB -- SESSION_MONGODB_DB -- SESSION_MONGODB_COLLECT - -.. _redis-py: https://github.com/andymccurdy/redis-py -.. _pylibmc: http://sendapatch.se/projects/pylibmc/ -.. _memcache: https://github.com/linsomniac/python-memcached -.. _pymongo: http://api.mongodb.org/python/current/index.html - - -:class:`SqlAlchemySessionInterface` ------------------------------------ - -.. versionadded:: 0.2 - -Uses SQLAlchemy as a session backend. (`Flask-SQLAlchemy`_ required) - -- SESSION_SQLALCHEMY -- SESSION_SQLALCHEMY_TABLE - -.. _Flask-SQLAlchemy: https://pythonhosted.org/Flask-SQLAlchemy/ diff --git a/examples/hello.py b/examples/hello.py index 5fd7257c..f6684720 100644 --- a/examples/hello.py +++ b/examples/hello.py @@ -1,24 +1,32 @@ from flask import Flask, session from flask_session import Session - -SESSION_TYPE = 'redis' - - app = Flask(__name__) app.config.from_object(__name__) +app.config.update( + { + "SESSION_TYPE": "sqlalchemy", + "SQLALCHEMY_DATABASE_URI": "sqlite:////tmp/test.db", + "SQLALCHEMY_USE_SIGNER": True, + } +) Session(app) -@app.route('/set/') +@app.route("/set/") def set(): - session['key'] = 'value' - return 'ok' + session["key"] = "value" + return "ok" -@app.route('/get/') +@app.route("/get/") def get(): - return session.get('key', 'not set') + import time + + start_time = time.time() + result = session.get("key", "not set") + print("get", (time.time() - start_time) * 1000) + return result if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index a5f9ad61..be1450ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,3 +48,23 @@ include = [ "test_session.py", ] exclude = ["docs/_build/"] + +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = ["E501"] \ No newline at end of file diff --git a/requirements/pytest.txt b/requirements/pytest.txt new file mode 100644 index 00000000..25311c8e --- /dev/null +++ b/requirements/pytest.txt @@ -0,0 +1,17 @@ +# Core +flask>=2.2 +cachelib + +# Linting +ruff + +# Testing +pytest +pytest-cov + +# Requirements for interfaces +redis +python-memcached +Flask-SQLAlchemy +pymongo + diff --git a/src/flask_session/__init__.py b/src/flask_session/__init__.py index e4d28547..9f5e315c 100644 --- a/src/flask_session/__init__.py +++ b/src/flask_session/__init__.py @@ -1,13 +1,18 @@ import os -from .sessions import NullSessionInterface, RedisSessionInterface, \ - MemcachedSessionInterface, FileSystemSessionInterface, \ - MongoDBSessionInterface, SqlAlchemySessionInterface +from .sessions import ( + FileSystemSessionInterface, + MemcachedSessionInterface, + MongoDBSessionInterface, + NullSessionInterface, + RedisSessionInterface, + SqlAlchemySessionInterface, +) -__version__ = '0.5.0' +__version__ = "0.6.0rc1" -class Session(object): +class Session: """This class is used to add Server-side Session to one or more Flask applications. @@ -51,47 +56,78 @@ def init_app(self, app): def _get_interface(self, app): config = app.config.copy() - config.setdefault('SESSION_TYPE', 'null') - config.setdefault('SESSION_PERMANENT', True) - config.setdefault('SESSION_USE_SIGNER', False) - config.setdefault('SESSION_KEY_PREFIX', 'session:') - config.setdefault('SESSION_REDIS', None) - config.setdefault('SESSION_MEMCACHED', None) - config.setdefault('SESSION_FILE_DIR', - os.path.join(os.getcwd(), 'flask_session')) - config.setdefault('SESSION_FILE_THRESHOLD', 500) - config.setdefault('SESSION_FILE_MODE', 384) - config.setdefault('SESSION_MONGODB', None) - config.setdefault('SESSION_MONGODB_DB', 'flask_session') - config.setdefault('SESSION_MONGODB_COLLECT', 'sessions') - config.setdefault('SESSION_SQLALCHEMY', None) - config.setdefault('SESSION_SQLALCHEMY_TABLE', 'sessions') - - if config['SESSION_TYPE'] == 'redis': + + # Flask-session specific settings + config.setdefault("SESSION_TYPE", "null") + config.setdefault("SESSION_PERMANENT", True) + config.setdefault("SESSION_USE_SIGNER", False) + config.setdefault("SESSION_KEY_PREFIX", "session:") + config.setdefault("SESSION_ID_LENGTH", 32) + + # Redis settings + config.setdefault("SESSION_REDIS", None) + + # Memcached settings + config.setdefault("SESSION_MEMCACHED", None) + + # Filesystem settings + config.setdefault( + "SESSION_FILE_DIR", os.path.join(os.getcwd(), "flask_session") + ) + config.setdefault("SESSION_FILE_THRESHOLD", 500) + config.setdefault("SESSION_FILE_MODE", 384) + + # MongoDB settings + config.setdefault("SESSION_MONGODB", None) + config.setdefault("SESSION_MONGODB_DB", "flask_session") + config.setdefault("SESSION_MONGODB_COLLECT", "sessions") + + # SQLAlchemy settings + config.setdefault("SESSION_SQLALCHEMY", None) + config.setdefault("SESSION_SQLALCHEMY_TABLE", "sessions") + config.setdefault("SESSION_SQLALCHEMY_SEQUENCE", None) + config.setdefault("SESSION_SQLALCHEMY_SCHEMA", None) + config.setdefault("SESSION_SQLALCHEMY_BIND_KEY", None) + + common_params = { + "key_prefix": config["SESSION_KEY_PREFIX"], + "use_signer": config["SESSION_USE_SIGNER"], + "permanent": config["SESSION_PERMANENT"], + "sid_length": config["SESSION_ID_LENGTH"], + } + + if config["SESSION_TYPE"] == "redis": session_interface = RedisSessionInterface( - config['SESSION_REDIS'], config['SESSION_KEY_PREFIX'], - config['SESSION_USE_SIGNER'], config['SESSION_PERMANENT']) - elif config['SESSION_TYPE'] == 'memcached': + config["SESSION_REDIS"], **common_params + ) + elif config["SESSION_TYPE"] == "memcached": session_interface = MemcachedSessionInterface( - config['SESSION_MEMCACHED'], config['SESSION_KEY_PREFIX'], - config['SESSION_USE_SIGNER'], config['SESSION_PERMANENT']) - elif config['SESSION_TYPE'] == 'filesystem': + config["SESSION_MEMCACHED"], **common_params + ) + elif config["SESSION_TYPE"] == "filesystem": session_interface = FileSystemSessionInterface( - config['SESSION_FILE_DIR'], config['SESSION_FILE_THRESHOLD'], - config['SESSION_FILE_MODE'], config['SESSION_KEY_PREFIX'], - config['SESSION_USE_SIGNER'], config['SESSION_PERMANENT']) - elif config['SESSION_TYPE'] == 'mongodb': + config["SESSION_FILE_DIR"], + config["SESSION_FILE_THRESHOLD"], + config["SESSION_FILE_MODE"], + **common_params, + ) + elif config["SESSION_TYPE"] == "mongodb": session_interface = MongoDBSessionInterface( - config['SESSION_MONGODB'], config['SESSION_MONGODB_DB'], - config['SESSION_MONGODB_COLLECT'], - config['SESSION_KEY_PREFIX'], config['SESSION_USE_SIGNER'], - config['SESSION_PERMANENT']) - elif config['SESSION_TYPE'] == 'sqlalchemy': + config["SESSION_MONGODB"], + config["SESSION_MONGODB_DB"], + config["SESSION_MONGODB_COLLECT"], + **common_params, + ) + elif config["SESSION_TYPE"] == "sqlalchemy": session_interface = SqlAlchemySessionInterface( - app, config['SESSION_SQLALCHEMY'], - config['SESSION_SQLALCHEMY_TABLE'], - config['SESSION_KEY_PREFIX'], config['SESSION_USE_SIGNER'], - config['SESSION_PERMANENT']) + app, + config["SESSION_SQLALCHEMY"], + config["SESSION_SQLALCHEMY_TABLE"], + config["SESSION_SQLALCHEMY_SEQUENCE"], + config["SESSION_SQLALCHEMY_SCHEMA"], + config["SESSION_SQLALCHEMY_BIND_KEY"], + **common_params, + ) else: session_interface = NullSessionInterface() diff --git a/src/flask_session/sessions.py b/src/flask_session/sessions.py index b7bd122f..19597491 100644 --- a/src/flask_session/sessions.py +++ b/src/flask_session/sessions.py @@ -1,23 +1,18 @@ -import sys +import secrets import time -from datetime import datetime -from uuid import uuid4 +from abc import ABC + try: import cPickle as pickle except ImportError: import pickle +from datetime import datetime, timezone + from flask.sessions import SessionInterface as FlaskSessionInterface from flask.sessions import SessionMixin +from itsdangerous import BadSignature, Signer, want_bytes from werkzeug.datastructures import CallbackDict -from itsdangerous import Signer, BadSignature, want_bytes - - -PY2 = sys.version_info[0] == 2 -if not PY2: - text_type = str -else: - text_type = unicode def total_seconds(td): @@ -27,9 +22,13 @@ def total_seconds(td): class ServerSideSession(CallbackDict, SessionMixin): """Baseclass for server-side based sessions.""" + def __bool__(self) -> bool: + return bool(dict(self)) and self.keys() != {"_permanent"} + def __init__(self, initial=None, sid=None, permanent=None): def on_update(self): self.modified = True + CallbackDict.__init__(self, initial, on_update) self.sid = sid if permanent: @@ -58,156 +57,207 @@ class SqlAlchemySession(ServerSideSession): class SessionInterface(FlaskSessionInterface): + def _generate_sid(self, session_id_length): + return secrets.token_urlsafe(session_id_length) + + def __get_signer(self, app): + if not hasattr(app, "secret_key") or not app.secret_key: + raise KeyError("SECRET_KEY must be set when SESSION_USE_SIGNER=True") + return Signer(app.secret_key, salt="flask-session", key_derivation="hmac") - def _generate_sid(self): - return str(uuid4()) + def _unsign(self, app, sid): + signer = self.__get_signer(app) + sid_as_bytes = signer.unsign(sid) + sid = sid_as_bytes.decode() + return sid - def _get_signer(self, app): - if not app.secret_key: - return None - return Signer(app.secret_key, salt='flask-session', - key_derivation='hmac') + def _sign(self, app, sid): + signer = self.__get_signer(app) + sid_as_bytes = want_bytes(sid) + return signer.sign(sid_as_bytes).decode("utf-8") class NullSessionInterface(SessionInterface): """Used to open a :class:`flask.sessions.NullSession` instance. + + If you do not configure a different ``SESSION_TYPE``, this will be used to + generate nicer error messages. Will allow read-only access to the empty + session but fail on setting. """ def open_session(self, app, request): return None -class RedisSessionInterface(SessionInterface): - """Uses the Redis key-value store as a session backend. +class ServerSideSessionInterface(SessionInterface, ABC): + """Used to open a :class:`flask.sessions.ServerSideSessionInterface` instance.""" - .. versionadded:: 0.2 - The `use_signer` parameter was added. + def __init__(self, db, key_prefix, use_signer=False, permanent=True, sid_length=32): + self.db = db + self.key_prefix = key_prefix + self.use_signer = use_signer + self.permanent = permanent + self.sid_length = sid_length + self.has_same_site_capability = hasattr(self, "get_cookie_samesite") + + def set_cookie_to_response(self, app, session, response, expires): + session_id = self._sign(app, session.sid) if self.use_signer else session.sid + domain = self.get_cookie_domain(app) + path = self.get_cookie_path(app) + httponly = self.get_cookie_httponly(app) + secure = self.get_cookie_secure(app) + samesite = None + if self.has_same_site_capability: + samesite = self.get_cookie_samesite(app) + + response.set_cookie( + app.config["SESSION_COOKIE_NAME"], + session_id, + expires=expires, + httponly=httponly, + domain=domain, + path=path, + secure=secure, + samesite=samesite, + ) + + def open_session(self, app, request): + sid = request.cookies.get(app.config["SESSION_COOKIE_NAME"]) + if not sid: + sid = self._generate_sid(self.sid_length) + return self.session_class(sid=sid, permanent=self.permanent) + if self.use_signer: + try: + sid = self._unsign(app, sid) + except BadSignature: + sid = self._generate_sid(self.sid_length) + return self.session_class(sid=sid, permanent=self.permanent) + return self.fetch_session(sid) + + def fetch_session(self, sid): + raise NotImplementedError() + + +class RedisSessionInterface(ServerSideSessionInterface): + """Uses the Redis key-value store as a session backend. (`redis-py` required) :param redis: A ``redis.Redis`` instance. :param key_prefix: A prefix that is added to all Redis store keys. :param use_signer: Whether to sign the session id cookie or not. :param permanent: Whether to use permanent session or not. + :param sid_length: The length of the generated session id in bytes. + + .. versionadded:: 0.6 + The `sid_length` parameter was added. + + .. versionadded:: 0.2 + The `use_signer` parameter was added. """ serializer = pickle session_class = RedisSession - def __init__(self, redis, key_prefix, use_signer=False, permanent=True): + def __init__(self, redis, key_prefix, use_signer, permanent, sid_length): if redis is None: from redis import Redis + redis = Redis() self.redis = redis - self.key_prefix = key_prefix - self.use_signer = use_signer - self.permanent = permanent - self.has_same_site_capability = hasattr(self, "get_cookie_samesite") + super().__init__(redis, key_prefix, use_signer, permanent, sid_length) - def open_session(self, app, request): - sid = request.cookies.get(app.config["SESSION_COOKIE_NAME"]) - if not sid: - sid = self._generate_sid() - return self.session_class(sid=sid, permanent=self.permanent) - if self.use_signer: - signer = self._get_signer(app) - if signer is None: - return None - try: - sid_as_bytes = signer.unsign(sid) - sid = sid_as_bytes.decode() - except BadSignature: - sid = self._generate_sid() - return self.session_class(sid=sid, permanent=self.permanent) + def fetch_session(self, sid): + # Get the saved session (value) from the database + prefixed_session_id = self.key_prefix + sid + value = self.redis.get(prefixed_session_id) - if not PY2 and not isinstance(sid, text_type): - sid = sid.decode('utf-8', 'strict') - val = self.redis.get(self.key_prefix + sid) - if val is not None: + # If the saved session still exists and hasn't auto-expired, load the session data from the document + if value is not None: try: - data = self.serializer.loads(val) - return self.session_class(data, sid=sid) - except: + session_data = self.serializer.loads(value) + return self.session_class(session_data, sid=sid) + except pickle.UnpicklingError: return self.session_class(sid=sid, permanent=self.permanent) + + # If the saved session does not exist, create a new session return self.session_class(sid=sid, permanent=self.permanent) def save_session(self, app, session, response): + if not self.should_set_cookie(app, session): + return + + # Get the domain and path for the cookie from the app config domain = self.get_cookie_domain(app) path = self.get_cookie_path(app) + + # If the session is empty, do not save it to the database or set a cookie if not session: + # If the session was deleted (empty and modified), delete the saved session from the database and tell the client to delete the cookie if session.modified: self.redis.delete(self.key_prefix + session.sid) - response.delete_cookie(app.config["SESSION_COOKIE_NAME"], - domain=domain, path=path) + response.delete_cookie( + app.config["SESSION_COOKIE_NAME"], domain=domain, path=path + ) return - # Modification case. There are upsides and downsides to - # emitting a set-cookie header each request. The behavior - # is controlled by the :meth:`should_set_cookie` method - # which performs a quick check to figure out if the cookie - # should be set or not. This is controlled by the - # SESSION_REFRESH_EACH_REQUEST config flag as well as - # the permanent flag on the session itself. - # if not self.should_set_cookie(app, session): - # return - conditional_cookie_kwargs = {} - httponly = self.get_cookie_httponly(app) - secure = self.get_cookie_secure(app) - if self.has_same_site_capability: - conditional_cookie_kwargs["samesite"] = self.get_cookie_samesite(app) - expires = self.get_expiration_time(app, session) - val = self.serializer.dumps(dict(session)) - self.redis.setex(name=self.key_prefix + session.sid, value=val, - time=total_seconds(app.permanent_session_lifetime)) - if self.use_signer: - session_id = self._get_signer(app).sign(want_bytes(session.sid)) - else: - session_id = session.sid - response.set_cookie(app.config["SESSION_COOKIE_NAME"], session_id, - expires=expires, httponly=httponly, - domain=domain, path=path, secure=secure, - **conditional_cookie_kwargs) + # Get the new expiration time for the session + expiration_datetime = self.get_expiration_time(app, session) + # Serialize the session data + serialized_session_data = self.serializer.dumps(dict(session)) -class MemcachedSessionInterface(SessionInterface): - """A Session interface that uses memcached as backend. + # Update existing or create new session in the database + self.redis.set( + name=self.key_prefix + session.sid, + value=serialized_session_data, + ex=total_seconds(app.permanent_session_lifetime), + ) + + # Set the browser cookie + self.set_cookie_to_response(app, session, response, expiration_datetime) - .. versionadded:: 0.2 - The `use_signer` parameter was added. + +class MemcachedSessionInterface(ServerSideSessionInterface): + """A Session interface that uses memcached as backend. (`pylibmc` or `python-memcached` or `pymemcache` required) :param client: A ``memcache.Client`` instance. :param key_prefix: A prefix that is added to all Memcached store keys. :param use_signer: Whether to sign the session id cookie or not. :param permanent: Whether to use permanent session or not. + :param sid_length: The length of the generated session id in bytes. + + .. versionadded:: 0.6 + The `sid_length` parameter was added. + + .. versionadded:: 0.2 + The `use_signer` parameter was added. + """ serializer = pickle session_class = MemcachedSession - def __init__(self, client, key_prefix, use_signer=False, permanent=True): + def __init__(self, client, key_prefix, use_signer, permanent, sid_length): if client is None: client = self._get_preferred_memcache_client() - if client is None: - raise RuntimeError('no memcache module found') self.client = client - self.key_prefix = key_prefix - self.use_signer = use_signer - self.permanent = permanent - self.has_same_site_capability = hasattr(self, "get_cookie_samesite") + super().__init__(client, key_prefix, use_signer, permanent, sid_length) def _get_preferred_memcache_client(self): - servers = ['127.0.0.1:11211'] - try: - import pylibmc - except ImportError: - pass - else: - return pylibmc.Client(servers) + clients = [ + ("pylibmc", ["127.0.0.1:11211"]), + ("memcache", ["127.0.0.1:11211"]), + ("pymemcache.client.base", "127.0.0.1:11211"), + ] - try: - import memcache - except ImportError: - pass - else: - return memcache.Client(servers) + for module_name, server in clients: + try: + module = __import__(module_name) + ClientClass = module.Client + return ClientClass(server) + except ImportError: + continue + + raise ImportError("No memcache module found") def _get_memcache_timeout(self, timeout): """ @@ -215,85 +265,66 @@ def _get_memcache_timeout(self, timeout): way. Call this function to obtain a safe value for your timeout. """ if timeout > 2592000: # 60*60*24*30, 30 days - # See http://code.google.com/p/memcached/wiki/FAQ - # "You can set expire times up to 30 days in the future. After that - # memcached interprets it as a date, and will expire the item after - # said date. This is a simple (but obscure) mechanic." - # - # This means that we have to switch to absolute timestamps. + # Switch to absolute timestamps. timeout += int(time.time()) return timeout - def open_session(self, app, request): - sid = request.cookies.get(app.config["SESSION_COOKIE_NAME"]) - if not sid: - sid = self._generate_sid() - return self.session_class(sid=sid, permanent=self.permanent) - if self.use_signer: - signer = self._get_signer(app) - if signer is None: - return None - try: - sid_as_bytes = signer.unsign(sid) - sid = sid_as_bytes.decode() - except BadSignature: - sid = self._generate_sid() - return self.session_class(sid=sid, permanent=self.permanent) + def fetch_session(self, sid): + # Get the saved session (item) from the database + prefixed_session_id = self.key_prefix + sid + item = self.client.get(prefixed_session_id) - full_session_key = self.key_prefix + sid - if PY2 and isinstance(full_session_key, unicode): - full_session_key = full_session_key.encode('utf-8') - val = self.client.get(full_session_key) - if val is not None: + # If the saved session still exists and hasn't auto-expired, load the session data from the document + if item is not None: try: - if not PY2: - val = want_bytes(val) - data = self.serializer.loads(val) - return self.session_class(data, sid=sid) - except: + session_data = self.serializer.loads(want_bytes(item)) + return self.session_class(session_data, sid=sid) + except pickle.UnpicklingError: return self.session_class(sid=sid, permanent=self.permanent) + + # If the saved session does not exist, create a new session return self.session_class(sid=sid, permanent=self.permanent) def save_session(self, app, session, response): + if not self.should_set_cookie(app, session): + return + + # Get the domain and path for the cookie from the app config domain = self.get_cookie_domain(app) path = self.get_cookie_path(app) - full_session_key = self.key_prefix + session.sid - if PY2 and isinstance(full_session_key, unicode): - full_session_key = full_session_key.encode('utf-8') + + # Generate a prefixed session id from the session id as a storage key + prefixed_session_id = self.key_prefix + session.sid + + # If the session is empty, do not save it to the database or set a cookie if not session: + # If the session was deleted (empty and modified), delete the saved session from the database and tell the client to delete the cookie if session.modified: - self.client.delete(full_session_key) - response.delete_cookie(app.config["SESSION_COOKIE_NAME"], - domain=domain, path=path) + self.client.delete(prefixed_session_id) + response.delete_cookie( + app.config["SESSION_COOKIE_NAME"], domain=domain, path=path + ) return - conditional_cookie_kwargs = {} - httponly = self.get_cookie_httponly(app) - secure = self.get_cookie_secure(app) - if self.has_same_site_capability: - conditional_cookie_kwargs["samesite"] = self.get_cookie_samesite(app) - expires = self.get_expiration_time(app, session) - if not PY2: - val = self.serializer.dumps(dict(session), 0) - else: - val = self.serializer.dumps(dict(session)) - self.client.set(full_session_key, val, self._get_memcache_timeout( - total_seconds(app.permanent_session_lifetime))) - if self.use_signer: - session_id = self._get_signer(app).sign(want_bytes(session.sid)) - else: - session_id = session.sid - response.set_cookie(app.config["SESSION_COOKIE_NAME"], session_id, - expires=expires, httponly=httponly, - domain=domain, path=path, secure=secure, - **conditional_cookie_kwargs) + # Get the new expiration time for the session + expiration_datetime = self.get_expiration_time(app, session) + # Serialize the session data + serialized_session_data = self.serializer.dumps(dict(session)) -class FileSystemSessionInterface(SessionInterface): - """Uses the :class:`cachelib.file.FileSystemCache` as a session backend. + # Update existing or create new session in the database + self.client.set( + prefixed_session_id, + serialized_session_data, + self._get_memcache_timeout(total_seconds(app.permanent_session_lifetime)), + ) - .. versionadded:: 0.2 - The `use_signer` parameter was added. + # Set the browser cookie + self.set_cookie_to_response(app, session, response, expiration_datetime) + + +class FileSystemSessionInterface(ServerSideSessionInterface): + """Uses the :class:`cachelib.file.FileSystemCache` as a session backend. :param cache_dir: the directory where session files are stored. :param threshold: the maximum number of items the session stores before it @@ -302,74 +333,84 @@ class FileSystemSessionInterface(SessionInterface): :param key_prefix: A prefix that is added to FileSystemCache store keys. :param use_signer: Whether to sign the session id cookie or not. :param permanent: Whether to use permanent session or not. + :param sid_length: The length of the generated session id in bytes. + + .. versionadded:: 0.6 + The `sid_length` parameter was added. + + .. versionadded:: 0.2 + The `use_signer` parameter was added. """ session_class = FileSystemSession - def __init__(self, cache_dir, threshold, mode, key_prefix, - use_signer=False, permanent=True): + def __init__( + self, + cache_dir, + threshold, + mode, + key_prefix, + use_signer, + permanent, + sid_length, + ): from cachelib.file import FileSystemCache + self.cache = FileSystemCache(cache_dir, threshold=threshold, mode=mode) - self.key_prefix = key_prefix - self.use_signer = use_signer - self.permanent = permanent - self.has_same_site_capability = hasattr(self, "get_cookie_samesite") + super().__init__(self.cache, key_prefix, use_signer, permanent, sid_length) - def open_session(self, app, request): - sid = request.cookies.get(app.config["SESSION_COOKIE_NAME"]) - if not sid: - sid = self._generate_sid() - return self.session_class(sid=sid, permanent=self.permanent) - if self.use_signer: - signer = self._get_signer(app) - if signer is None: - return None - try: - sid_as_bytes = signer.unsign(sid) - sid = sid_as_bytes.decode() - except BadSignature: - sid = self._generate_sid() - return self.session_class(sid=sid, permanent=self.permanent) + def fetch_session(self, sid): + # Get the saved session (item) from the database + prefixed_session_id = self.key_prefix + sid + item = self.cache.get(prefixed_session_id) + + # If the saved session exists and has not auto-expired, load the session data from the item + if item is not None: + return self.session_class(item, sid=sid) - data = self.cache.get(self.key_prefix + sid) - if data is not None: - return self.session_class(data, sid=sid) + # If the saved session does not exist, create a new session return self.session_class(sid=sid, permanent=self.permanent) def save_session(self, app, session, response): + if not self.should_set_cookie(app, session): + return + + # Get the domain and path for the cookie from the app config domain = self.get_cookie_domain(app) path = self.get_cookie_path(app) + + # Generate a prefixed session id from the session id as a storage key + prefixed_session_id = self.key_prefix + session.sid + + # If the session is empty, do not save it to the database or set a cookie if not session: + # If the session was deleted (empty and modified), delete the saved session from the database and tell the client to delete the cookie if session.modified: - self.cache.delete(self.key_prefix + session.sid) - response.delete_cookie(app.config["SESSION_COOKIE_NAME"], - domain=domain, path=path) + self.cache.delete(prefixed_session_id) + response.delete_cookie( + app.config["SESSION_COOKIE_NAME"], domain=domain, path=path + ) return - conditional_cookie_kwargs = {} - httponly = self.get_cookie_httponly(app) - secure = self.get_cookie_secure(app) - if self.has_same_site_capability: - conditional_cookie_kwargs["samesite"] = self.get_cookie_samesite(app) - expires = self.get_expiration_time(app, session) - data = dict(session) - self.cache.set(self.key_prefix + session.sid, data, - total_seconds(app.permanent_session_lifetime)) - if self.use_signer: - session_id = self._get_signer(app).sign(want_bytes(session.sid)) - else: - session_id = session.sid - response.set_cookie(app.config["SESSION_COOKIE_NAME"], session_id, - expires=expires, httponly=httponly, - domain=domain, path=path, secure=secure, - **conditional_cookie_kwargs) + # Get the new expiration time for the session + expiration_datetime = self.get_expiration_time(app, session) + # Serialize the session data (or just cast into dictionary in this case) + session_data = dict(session) -class MongoDBSessionInterface(SessionInterface): - """A Session interface that uses mongodb as backend. + # Update existing or create new session in the database + self.cache.set( + prefixed_session_id, + session_data, + total_seconds(app.permanent_session_lifetime), + ) - .. versionadded:: 0.2 - The `use_signer` parameter was added. + # Set the browser cookie + self.set_cookie_to_response(app, session, response, expiration_datetime) + + +class MongoDBSessionInterface(ServerSideSessionInterface): + """A Session interface that uses mongodb as backend. (`pymongo` required) :param client: A ``pymongo.MongoClient`` instance. :param db: The database you want to use. @@ -377,117 +418,196 @@ class MongoDBSessionInterface(SessionInterface): :param key_prefix: A prefix that is added to all MongoDB store keys. :param use_signer: Whether to sign the session id cookie or not. :param permanent: Whether to use permanent session or not. + :param sid_length: The length of the generated session id in bytes. + + .. versionadded:: 0.6 + The `sid_length` parameter was added. + + .. versionadded:: 0.2 + The `use_signer` parameter was added. """ serializer = pickle session_class = MongoDBSession - def __init__(self, client, db, collection, key_prefix, use_signer=False, - permanent=True): + def __init__( + self, + client, + db, + collection, + key_prefix, + use_signer, + permanent, + sid_length, + ): + import pymongo + if client is None: - from pymongo import MongoClient - client = MongoClient() + client = pymongo.MongoClient() + self.client = client self.store = client[db][collection] - self.key_prefix = key_prefix - self.use_signer = use_signer - self.permanent = permanent - self.has_same_site_capability = hasattr(self, "get_cookie_samesite") + self.use_deprecated_method = int(pymongo.version.split(".")[0]) < 4 + super().__init__(self.store, key_prefix, use_signer, permanent, sid_length) - def open_session(self, app, request): - sid = request.cookies.get(app.config["SESSION_COOKIE_NAME"]) - if not sid: - sid = self._generate_sid() - return self.session_class(sid=sid, permanent=self.permanent) - if self.use_signer: - signer = self._get_signer(app) - if signer is None: - return None - try: - sid_as_bytes = signer.unsign(sid) - sid = sid_as_bytes.decode() - except BadSignature: - sid = self._generate_sid() - return self.session_class(sid=sid, permanent=self.permanent) + def fetch_session(self, sid): + # Get the saved session (document) from the database + prefixed_session_id = self.key_prefix + sid + document = self.store.find_one({"id": prefixed_session_id}) - store_id = self.key_prefix + sid - document = self.store.find_one({'id': store_id}) - if document and document.get('expiration') <= datetime.utcnow(): - # Delete expired session - self.store.remove({'id': store_id}) - document = None + # If the expiration time is less than or equal to the current time (expired), delete the document + if document is not None: + expiration_datetime = document.get("expiration") + # tz_aware mongodb fix + expiration_datetime_tz_aware = expiration_datetime.replace( + tzinfo=timezone.utc + ) + now_datetime_tz_aware = datetime.utcnow().replace(tzinfo=timezone.utc) + if expiration_datetime is None or ( + expiration_datetime_tz_aware <= now_datetime_tz_aware + ): + if self.use_deprecated_method: + self.store.remove({"id": prefixed_session_id}) + else: + self.store.delete_one({"id": prefixed_session_id}) + document = None + + # If the saved session still exists after checking for expiration, load the session data from the document if document is not None: try: - val = document['val'] - data = self.serializer.loads(want_bytes(val)) - return self.session_class(data, sid=sid) - except: + session_data = self.serializer.loads(want_bytes(document["val"])) + return self.session_class(session_data, sid=sid) + except pickle.UnpicklingError: return self.session_class(sid=sid, permanent=self.permanent) + + # If the saved session does not exist, create a new session return self.session_class(sid=sid, permanent=self.permanent) def save_session(self, app, session, response): + if not self.should_set_cookie(app, session): + return + + # Get the domain and path for the cookie from the app config domain = self.get_cookie_domain(app) path = self.get_cookie_path(app) - store_id = self.key_prefix + session.sid + + # Generate a prefixed session id from the session id as a storage key + prefixed_session_id = self.key_prefix + session.sid + + # If the session is empty, do not save it to the database or set a cookie if not session: + # If the session was deleted (empty and modified), delete the saved session from the database and tell the client to delete the cookie if session.modified: - self.store.remove({'id': store_id}) - response.delete_cookie(app.config["SESSION_COOKIE_NAME"], - domain=domain, path=path) + if self.use_deprecated_method: + self.store.remove({"id": prefixed_session_id}) + else: + self.store.delete_one({"id": prefixed_session_id}) + response.delete_cookie( + app.config["SESSION_COOKIE_NAME"], domain=domain, path=path + ) return - conditional_cookie_kwargs = {} - httponly = self.get_cookie_httponly(app) - secure = self.get_cookie_secure(app) - if self.has_same_site_capability: - conditional_cookie_kwargs["samesite"] = self.get_cookie_samesite(app) - expires = self.get_expiration_time(app, session) - val = self.serializer.dumps(dict(session)) - self.store.update({'id': store_id}, - {'id': store_id, - 'val': val, - 'expiration': expires}, True) - if self.use_signer: - session_id = self._get_signer(app).sign(want_bytes(session.sid)) + # Get the new expiration time for the session + expiration_datetime = self.get_expiration_time(app, session) + + # Serialize the session data + serialized_session_data = self.serializer.dumps(dict(session)) + + # Update existing or create new session in the database + if self.use_deprecated_method: + self.store.update( + {"id": prefixed_session_id}, + { + "id": prefixed_session_id, + "val": serialized_session_data, + "expiration": expiration_datetime, + }, + True, + ) else: - session_id = session.sid - response.set_cookie(app.config["SESSION_COOKIE_NAME"], session_id, - expires=expires, httponly=httponly, - domain=domain, path=path, secure=secure, - **conditional_cookie_kwargs) - - -class SqlAlchemySessionInterface(SessionInterface): + self.store.update_one( + {"id": prefixed_session_id}, + { + "$set": { + "id": prefixed_session_id, + "val": serialized_session_data, + "expiration": expiration_datetime, + } + }, + True, + ) + + # Set the browser cookie + self.set_cookie_to_response(app, session, response, expiration_datetime) + + +class SqlAlchemySessionInterface(ServerSideSessionInterface): """Uses the Flask-SQLAlchemy from a flask app as a session backend. - .. versionadded:: 0.2 - :param app: A Flask app instance. :param db: A Flask-SQLAlchemy instance. :param table: The table name you want to use. :param key_prefix: A prefix that is added to all store keys. :param use_signer: Whether to sign the session id cookie or not. :param permanent: Whether to use permanent session or not. + :param sid_length: The length of the generated session id in bytes. + :param sequence: The sequence to use for the primary key if needed. + :param schema: The db schema to use + :param bind_key: The db bind key to use + + .. versionadded:: 0.6 + The `sid_length`, `sequence`, `schema` and `bind_key` parameters were added. + + .. versionadded:: 0.2 + The `use_signer` parameter was added. """ serializer = pickle session_class = SqlAlchemySession - def __init__(self, app, db, table, key_prefix, use_signer=False, - permanent=True): + def __init__( + self, + app, + db, + table, + sequence, + schema, + bind_key, + key_prefix, + use_signer, + permanent, + sid_length, + ): if db is None: from flask_sqlalchemy import SQLAlchemy + db = SQLAlchemy(app) + self.db = db - self.key_prefix = key_prefix - self.use_signer = use_signer - self.permanent = permanent - self.has_same_site_capability = hasattr(self, "get_cookie_samesite") + self.sequence = sequence + self.schema = schema + self.bind_key = bind_key + super().__init__(self.db, key_prefix, use_signer, permanent, sid_length) + # Create the Session database model class Session(self.db.Model): __tablename__ = table - id = self.db.Column(self.db.Integer, primary_key=True) + if self.schema is not None: + __table_args__ = {"schema": self.schema, "keep_existing": True} + else: + __table_args__ = {"keep_existing": True} + + if self.bind_key is not None: + __bind_key__ = self.bind_key + + # Set the database columns, support for id sequences + if sequence: + id = self.db.Column( + self.db.Integer, self.db.Sequence(sequence), primary_key=True + ) + else: + id = self.db.Column(self.db.Integer, primary_key=True) session_id = self.db.Column(self.db.String(255), unique=True) data = self.db.Column(self.db.LargeBinary) expiry = self.db.Column(self.db.DateTime) @@ -498,79 +618,80 @@ def __init__(self, session_id, data, expiry): self.expiry = expiry def __repr__(self): - return '' % self.data + return "" % self.data - # self.db.create_all() - self.sql_session_model = Session + with app.app_context(): + self.db.create_all() - def open_session(self, app, request): - sid = request.cookies.get(app.config["SESSION_COOKIE_NAME"]) - if not sid: - sid = self._generate_sid() - return self.session_class(sid=sid, permanent=self.permanent) - if self.use_signer: - signer = self._get_signer(app) - if signer is None: - return None - try: - sid_as_bytes = signer.unsign(sid) - sid = sid_as_bytes.decode() - except BadSignature: - sid = self._generate_sid() - return self.session_class(sid=sid, permanent=self.permanent) + self.sql_session_model = Session + def fetch_session(self, sid): + # Get the saved session (record) from the database store_id = self.key_prefix + sid - saved_session = self.sql_session_model.query.filter_by( - session_id=store_id).first() - if saved_session and saved_session.expiry <= datetime.utcnow(): - # Delete expired session - self.db.session.delete(saved_session) - self.db.session.commit() - saved_session = None - if saved_session: + record = self.sql_session_model.query.filter_by(session_id=store_id).first() + + # If the expiration time is less than or equal to the current time (expired), delete the document + if record is not None: + expiration_datetime = record.expiry + if expiration_datetime is None or expiration_datetime <= datetime.utcnow(): + self.db.session.delete(record) + self.db.session.commit() + record = None + + # If the saved session still exists after checking for expiration, load the session data from the document + if record: try: - val = saved_session.data - data = self.serializer.loads(want_bytes(val)) - return self.session_class(data, sid=sid) - except: + session_data = self.serializer.loads(want_bytes(record.data)) + return self.session_class(session_data, sid=sid) + except pickle.UnpicklingError: return self.session_class(sid=sid, permanent=self.permanent) return self.session_class(sid=sid, permanent=self.permanent) def save_session(self, app, session, response): + if not self.should_set_cookie(app, session): + return + + # Get the domain and path for the cookie from the app domain = self.get_cookie_domain(app) path = self.get_cookie_path(app) - store_id = self.key_prefix + session.sid - saved_session = self.sql_session_model.query.filter_by( - session_id=store_id).first() + + # Generate a prefixed session id + prefixed_session_id = self.key_prefix + session.sid + + # If the session is empty, do not save it to the database or set a cookie if not session: + # If the session was deleted (empty and modified), delete the saved session from the database and tell the client to delete the cookie if session.modified: - if saved_session: - self.db.session.delete(saved_session) - self.db.session.commit() - response.delete_cookie(app.config["SESSION_COOKIE_NAME"], - domain=domain, path=path) + self.sql_session_model.query.filter_by( + session_id=prefixed_session_id + ).delete() + self.db.session.commit() + response.delete_cookie( + app.config["SESSION_COOKIE_NAME"], domain=domain, path=path + ) return - conditional_cookie_kwargs = {} - httponly = self.get_cookie_httponly(app) - secure = self.get_cookie_secure(app) - if self.has_same_site_capability: - conditional_cookie_kwargs["samesite"] = self.get_cookie_samesite(app) - expires = self.get_expiration_time(app, session) - val = self.serializer.dumps(dict(session)) - if saved_session: - saved_session.data = val - saved_session.expiry = expires - self.db.session.commit() - else: - new_session = self.sql_session_model(store_id, val, expires) - self.db.session.add(new_session) - self.db.session.commit() - if self.use_signer: - session_id = self._get_signer(app).sign(want_bytes(session.sid)) + # Serialize session data + serialized_session_data = self.serializer.dumps(dict(session)) + + # Get the new expiration time for the session + expiration_datetime = self.get_expiration_time(app, session) + + # Update existing or create new session in the database + record = self.sql_session_model.query.filter_by( + session_id=prefixed_session_id + ).first() + if record: + record.data = serialized_session_data + record.expiry = expiration_datetime else: - session_id = session.sid - response.set_cookie(app.config["SESSION_COOKIE_NAME"], session_id, - expires=expires, httponly=httponly, - domain=domain, path=path, secure=secure, - **conditional_cookie_kwargs) + record = self.sql_session_model( + session_id=prefixed_session_id, + data=serialized_session_data, + expiry=expiration_datetime, + ) + self.db.session.add(record) + self.db.session.commit() + + # Set the browser cookie + self.set_cookie_to_response(app, session, response, expiration_datetime) diff --git a/test_session.py b/test_session.py deleted file mode 100644 index 6fb72a4e..00000000 --- a/test_session.py +++ /dev/null @@ -1,182 +0,0 @@ -import unittest -import tempfile - -import flask -from flask_session import Session - - -class FlaskSessionTestCase(unittest.TestCase): - - def test_null_session(self): - app = flask.Flask(__name__) - Session(app) - def expect_exception(f, *args, **kwargs): - try: - f(*args, **kwargs) - except RuntimeError as e: - self.assertTrue(e.args and 'session is unavailable' in e.args[0]) - else: - self.assertTrue(False, 'expected exception') - with app.test_request_context(): - self.assertTrue(flask.session.get('missing_key') is None) - expect_exception(flask.session.__setitem__, 'foo', 42) - expect_exception(flask.session.pop, 'foo') - - def test_redis_session(self): - app = flask.Flask(__name__) - app.config['SESSION_TYPE'] = 'redis' - Session(app) - @app.route('/set', methods=['POST']) - def set(): - flask.session['value'] = flask.request.form['value'] - return 'value set' - @app.route('/get') - def get(): - return flask.session['value'] - @app.route('/delete', methods=['POST']) - def delete(): - del flask.session['value'] - return 'value deleted' - - c = app.test_client() - self.assertEqual(c.post('/set', data={'value': '42'}).data, b'value set') - self.assertEqual(c.get('/get').data, b'42') - c.post('/delete') - - - def test_memcached_session(self): - app = flask.Flask(__name__) - app.config['SESSION_TYPE'] = 'memcached' - Session(app) - @app.route('/set', methods=['POST']) - def set(): - flask.session['value'] = flask.request.form['value'] - return 'value set' - @app.route('/get') - def get(): - return flask.session['value'] - @app.route('/delete', methods=['POST']) - def delete(): - del flask.session['value'] - return 'value deleted' - - c = app.test_client() - self.assertEqual(c.post('/set', data={'value': '42'}).data, b'value set') - self.assertEqual(c.get('/get').data, b'42') - c.post('/delete') - - - def test_filesystem_session(self): - app = flask.Flask(__name__) - app.config['SESSION_TYPE'] = 'filesystem' - app.config['SESSION_FILE_DIR'] = tempfile.gettempdir() - Session(app) - @app.route('/set', methods=['POST']) - def set(): - flask.session['value'] = flask.request.form['value'] - return 'value set' - @app.route('/get') - def get(): - return flask.session['value'] - @app.route('/delete', methods=['POST']) - def delete(): - del flask.session['value'] - return 'value deleted' - - c = app.test_client() - self.assertEqual(c.post('/set', data={'value': '42'}).data, b'value set') - self.assertEqual(c.get('/get').data, b'42') - c.post('/delete') - - def test_mongodb_session(self): - app = flask.Flask(__name__) - app.testing = True - app.config['SESSION_TYPE'] = 'mongodb' - Session(app) - @app.route('/set', methods=['POST']) - def set(): - flask.session['value'] = flask.request.form['value'] - return 'value set' - @app.route('/get') - def get(): - return flask.session['value'] - @app.route('/delete', methods=['POST']) - def delete(): - del flask.session['value'] - return 'value deleted' - - c = app.test_client() - self.assertEqual(c.post('/set', data={'value': '42'}).data, b'value set') - self.assertEqual(c.get('/get').data, b'42') - c.post('/delete') - - def test_flasksqlalchemy_session(self): - app = flask.Flask(__name__) - app.debug = True - app.config['SESSION_TYPE'] = 'sqlalchemy' - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' - Session(app) - @app.route('/set', methods=['POST']) - def set(): - flask.session['value'] = flask.request.form['value'] - return 'value set' - @app.route('/get') - def get(): - return flask.session['value'] - @app.route('/delete', methods=['POST']) - def delete(): - del flask.session['value'] - return 'value deleted' - - c = app.test_client() - self.assertEqual(c.post('/set', data={'value': '42'}).data, b'value ' - b'set') - self.assertEqual(c.get('/get').data, b'42') - c.post('/delete') - - def test_flasksqlalchemy_session_with_signer(self): - app = flask.Flask(__name__) - app.debug = True - app.secret_key = 'test_secret_key' - app.config['SESSION_TYPE'] = 'sqlalchemy' - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' - app.config['SESSION_USE_SIGNER'] = True - session = Session(app) - @app.route('/set', methods=['POST']) - def set(): - flask.session['value'] = flask.request.form['value'] - return 'value set' - @app.route('/get') - def get(): - return flask.session['value'] - @app.route('/delete', methods=['POST']) - def delete(): - del flask.session['value'] - return 'value deleted' - - c = app.test_client() - self.assertEqual(c.post('/set', data={'value': '42'}).data, b'value ' - b'set') - self.assertEqual(c.get('/get').data, b'42') - c.post('/delete') - - def test_session_use_signer(self): - app = flask.Flask(__name__) - app.secret_key = 'test_secret_key' - app.config['SESSION_TYPE'] = 'redis' - app.config['SESSION_USE_SIGNER'] = True - Session(app) - @app.route('/set', methods=['POST']) - def set(): - flask.session['value'] = flask.request.form['value'] - return 'value set' - @app.route('/get') - def get(): - return flask.session['value'] - - c = app.test_client() - self.assertEqual(c.post('/set', data={'value': '42'}).data, b'value set') - self.assertEqual(c.get('/get').data, b'42') - -if __name__ == "__main__": - unittest.main() diff --git a/tests/.coveragerc b/tests/.coveragerc new file mode 100644 index 00000000..cf0faa24 --- /dev/null +++ b/tests/.coveragerc @@ -0,0 +1,2 @@ +[report] +omit = tests/* diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..5f2e570e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +import sys + +sys.path.append("src") +import flask # noqa E402 +import flask_session # noqa E402 +import pytest # noqa E402 + + +@pytest.fixture(scope="function") +def app_utils(): + class Utils: + def create_app(self, config_dict=None): + app = flask.Flask(__name__) + if config_dict: + app.config.update(config_dict) + + @app.route("/set", methods=["POST"]) + def app_set(): + flask.session["value"] = flask.request.form["value"] + return "value set" + + @app.route("/modify", methods=["POST"]) + def app_modify(): + flask.session["value"] = flask.request.form["value"] + return "value set" + + @app.route("/delete", methods=["POST"]) + def app_del(): + del flask.session["value"] + return "value deleted" + + @app.route("/get") + def app_get(): + return flask.session.get("value") + + flask_session.Session(app) + return app + + def test_session_set(self, app): + client = app.test_client() + assert client.post("/set", data={"value": "42"}).data == b"value set" + assert client.get("/get").data == b"42" + + def test_session_modify(self, app): + client = app.test_client() + assert client.post("/set", data={"value": "42"}).data == b"value set" + assert client.post("/modify", data={"value": "43"}).data == b"value set" + assert client.get("/get").data == b"43" + + def test_session_delete(self, app): + client = app.test_client() + assert client.post("/set", data={"value": "42"}).data == b"value set" + assert client.get("/get").data == b"42" + client.post("/delete") + assert client.get("/get").data != b"42" + + def test_session_sign(self, app): + client = app.test_client() + response = client.post("/set", data={"value": "42"}) + assert response.data == b"value set" + # Check there are two parts to the cookie, the session ID and the signature + cookies = response.headers.getlist("Set-Cookie") + assert "." in cookies[0].split(";")[0] + + return Utils() diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 00000000..ae8bb63a --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +filterwarnings = + ignore::DeprecationWarning +junit_family=xunit2 +addopts = + --cov=. + --cov-config tests/.coveragerc + --cov-report term + --cov-report html:htmlcov diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 00000000..4596c97c --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,24 @@ +import flask +import flask_session +import pytest + + +def test_tot_seconds_func(): + import datetime + + td = datetime.timedelta(days=1) + assert flask_session.sessions.total_seconds(td) == 86400 + + +def test_null_session(): + """Invalid session should fail to get/set the flask session""" + app = flask.Flask(__name__) + app.secret_key = "alsdkfjaldkjsf" + flask_session.Session(app) + + with app.test_request_context(): + assert not flask.session.get("missing_key") + with pytest.raises(RuntimeError): + flask.session["foo"] = 42 + with pytest.raises(KeyError): + print(flask.session["foo"]) diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py new file mode 100644 index 00000000..939b2128 --- /dev/null +++ b/tests/test_filesystem.py @@ -0,0 +1,19 @@ +import tempfile + +import flask +import flask_session + + +class TestFileSystem: + def setup_method(self, _): + pass + + def test_basic(self, app_utils): + app = app_utils.create_app( + {"SESSION_TYPE": "filesystem", "SESSION_FILE_DIR": tempfile.gettempdir()} + ) + app_utils.test_session_set(app) + + # Should be using FileSystem class + with app.test_request_context(): + isinstance(flask.session, flask_session.sessions.FileSystemSession) diff --git a/tests/test_memcached.py b/tests/test_memcached.py new file mode 100644 index 00000000..aabbd7ff --- /dev/null +++ b/tests/test_memcached.py @@ -0,0 +1,17 @@ +import flask +import flask_session + + +class TestMemcached: + """This requires package: memcached + This needs to be running before test runs + """ + + def test_basic(self, app_utils): + app = app_utils.create_app({"SESSION_TYPE": "memcached"}) + + # Should be using Memecached + with app.test_request_context(): + isinstance(flask.session, flask_session.sessions.MemcachedSessionInterface) + + app_utils.test_session_set(app) diff --git a/tests/test_mongodb.py b/tests/test_mongodb.py new file mode 100644 index 00000000..15d1319a --- /dev/null +++ b/tests/test_mongodb.py @@ -0,0 +1,15 @@ +import flask +import flask_session + + +class TestMongoDB: + def test_basic(self, app_utils): + app = app_utils.create_app({"SESSION_TYPE": "mongodb"}) + + # Should be using MongoDB + with app.test_request_context(): + isinstance(flask.session, flask_session.sessions.MongoDBSession) + + # TODO: Need to test with mongodb service running, once + # that is available, then we can call + # app_utils.test_session_set diff --git a/tests/test_redis.py b/tests/test_redis.py new file mode 100644 index 00000000..63d543b5 --- /dev/null +++ b/tests/test_redis.py @@ -0,0 +1,68 @@ +import flask +import flask_session +from redis import Redis + + +class TestRedisSession: + def setup_method(self, method): + # Clear redis + r = Redis() + r.flushall() + + def _has_redis_prefix(self, prefix): + r = Redis() + return any(key.startswith(prefix) for key in r.keys()) #noqa SIM118 + + def test_redis_default(self, app_utils): + app = app_utils.create_app({"SESSION_TYPE": "redis"}) + + # Should be using Redis + with app.test_request_context(): + isinstance(flask.session, flask_session.sessions.RedisSession) + + app_utils.test_session_set(app) + + # There should be a session: object + assert self._has_redis_prefix(b"session:") + + self.setup_method(None) + app_utils.test_session_delete(app) + + # There should not be a session: object + assert not self._has_redis_prefix(b"session:") + + def test_redis_key_prefix(self, app_utils): + app = app_utils.create_app( + {"SESSION_TYPE": "redis", "SESSION_KEY_PREFIX": "sess-prefix:"} + ) + app_utils.test_session_set(app) + + # There should be a key in Redis that starts with the prefix set + assert not self._has_redis_prefix(b"session:") + assert self._has_redis_prefix(b"sess-prefix:") + + def test_redis_with_signer(self, app_utils): + app = app_utils.create_app( + { + "SESSION_TYPE": "redis", + "SESSION_USE_SIGNER": True, + } + ) + + # Without a secret key set, there should be an exception raised + # TODO: not working + # with pytest.raises(KeyError): + # app_utils.test_session_set(app) + + # With a secret key set, no exception should be thrown + app.secret_key = "test_key" + app_utils.test_session_set(app) + + # There should be a key in Redis that starts with the prefix set + assert self._has_redis_prefix(b"session:") + + # Clear redis + self.setup_method(None) + + # Check that the session is signed + app_utils.test_session_sign(app) diff --git a/tests/test_sqlalchemy.py b/tests/test_sqlalchemy.py new file mode 100644 index 00000000..4891a42b --- /dev/null +++ b/tests/test_sqlalchemy.py @@ -0,0 +1,31 @@ +import flask +import flask_session + + +class TestSQLAlchemy: + def test_basic(self, app_utils): + app = app_utils.create_app( + {"SESSION_TYPE": "sqlalchemy", "SQLALCHEMY_DATABASE_URI": "sqlite:///"} + ) + + # Should be using SqlAlchemy + with app.test_request_context(): + isinstance(flask.session, flask_session.sessions.SqlAlchemySession) + app.session_interface.db.create_all() + + app_utils.test_session_set(app) + app_utils.test_session_modify(app) + + def test_use_signer(self, app_utils): + app = app_utils.create_app( + { + "SESSION_TYPE": "sqlalchemy", + "SQLALCHEMY_DATABASE_URI": "sqlite:///", + "SQLALCHEMY_USE_SIGNER": True, + } + ) + + with app.test_request_context(): + app.session_interface.db.create_all() + + app_utils.test_session_set(app)