diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml index e8133f29..68c2df20 100644 --- a/.github/workflows/ruff.yaml +++ b/.github/workflows/ruff.yaml @@ -1,8 +1,11 @@ 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 + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: chartboost/ruff-action@v1 + with: + version: 0.1.11 + args: --select B diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 10e18a3a..03448275 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,16 +1,21 @@ 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 + unittests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8, 3.9, 3.10, 3.11, 3.12] + services: + mongodb: + image: mongo + ports: + - 27017:27017 + steps: + - uses: actions/checkout@v4 + - uses: supercharge/redis-github-action@1.5.0 + - uses: niden/actions-memcached@v7 + - name: Install testing requirements + run: pip3 install -r requirements/dev.txt + - name: Run tests + run: pytest tests diff --git a/.gitignore b/.gitignore index a002c3cb..86d6893f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,10 @@ __pycache__/ /.mypy_cache/ /.tox/ /docs/_build/ + +docs/.DS_Store +.python-version +.DS_Store +.python-version +requirements.lock +requirements-dev.lock \ No newline at end of file diff --git a/CHANGES.rst b/CHANGES.rst index 84ee2292..ee21c940 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,26 +1,72 @@ -Version 0.6.0-RC1 +0.7.0 - 2024-03-18 ------------------ -Unreleased +Changed +~~~~~~~~ +- Access session interfaces via subfolder, for example ``flask_session.redis.RedisSessionInterface`` (`2bc7df `_). +- Deprecate ``pickle`` in favor of ``msgspec``, which is configured with ``SESSION_SERIALIZATION_FORMAT`` to choose between ``'json'`` and ``'msgpack'``. All sessions will convert to msgspec upon first interaction with 0.7.0. Pickle is still available to read existing sessions, but will be removed in 1.0.0. (`c7f8ce `_, `c7f8ce `_) +- Deprecate ``SESSION_USE_SIGNER`` (`a5dba7 `_). +- Deprecate :class:`flask_session.filesystem.FileSystemSessionInterface` in favor of the broader :class:`flask_session.cachelib.CacheLibSessionInterface` (`2bc7df `_). + +Added +~~~~~~~ +- All sessions that are accessed or modified while using 0.7.0 will convert to msgspec. Once using 1.0.0, any sessions that are still in pickle will be cleared upon access. +- Add time-to-live expiration for MongoDB (`9acee3 `_). +- Add retry for SQL based storage (`#211 `_). +- Add ``flask session_cleanup`` command and alternatively, ``SESSION_CLEANUP_N_REQUESTS`` for SQLAlchemy or future non-TTL backends (`#211 `_). +- Add type hints (`7d7d58 `_). +- Add logo and additional documentation. +- Add vary cookie header when session modified or accessed as per flask's built-in session (`7ab698 `_). +- Add regenerate method to session interface to mitigate fixation (`#27 `_, `#39 `_)(`80df63 `_). + +Removed +~~~~~~~~~~ +- Remove null session in favour of relevant exception messages (`#107 `_, `#182 `_)(`d7ed1c `_). +- Drop support for Python 3.7 which is end-of-life and precludes use of msgspec (`bd7e5b `_). + +Fixed +~~~~~ +- Prevent session identifier reuse on storage miss (`#76 `_). +- Abstraction to improve consistency between backends. +- Enforce ``PERMANENT_SESSION_LIFETIME`` as expiration consistently for all backends (`#81 `_)(`86895b `_). +- Specifically include backend session interfaces in public API and document usage (`#210 `_). +- Fix non-permanent sessions not updating expiry (`#221 `_). + + +0.6.0 - 2024-01-16 +------------------ + +Changed +~~~~~~~~ + +- Use :meth:`~ServerSideSession.should_set_cookie` for preventing each request from saving the session again. +- Do not store a permanent session that is otherwise empty. +- Use `secrets` module to generate session identifiers, with 256 bits of entropy (was previously 122). +- Explicitly name support for ``python-memcached``, ``pylibmc`` and ``pymemcache`` for ``cachelib`` backend. + +Added +~~~~~~~ + +- Introduce ``SESSION_KEY_LENGTH`` to control the length of the session key in bytes, default is 32. +- Support SQLAlchemy ``SESSION_SQLALCHEMY_SEQUENCE``, ``SESSION_SQLALCHEMY_SCHEMA`` and ``SESSION_SQLALCHEMY_BINDKEY`` + +Removed +~~~~~~~~~~ + +- Drop support for Redis < 2.6.12. + +Fixed +~~~~~ -- 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 -------------- -Released 2023-05-11 +0.5.0 - 2023-05-11 +------------------- - Drop support for Python < 3.7. - Switch to ``pyproject.toml`` and Flit for packaging. @@ -28,32 +74,32 @@ Released 2023-05-11 - Replace use of ``session_cookie_name`` for Flask 2.3 compatibility. -Version 0.4.1 +0.4.1 ------------- - Temporarily pin Flask < 2.3. -Version 0.4.0 +0.4.0 ------------- - Added support for ``SESSION_COOKIE_SAMESITE``. -Version 0.3.2 +0.3.2 ------------- - Changed ``werkzeug.contrib.cache`` to ``cachelib``. -Version 0.3.1 +0.3.1 ------------- - ``SqlAlchemySessionInterface`` is using ``VARCHAR(255)`` to store session id now. - ``SqlAlchemySessionInterface`` won't run `db.create_all` anymore. -Version 0.3 +0.3 ----------- - ``SqlAlchemySessionInterface`` is using ``LargeBinary`` type to store data now. @@ -61,7 +107,7 @@ Version 0.3 - Fixed ``TypeError`` when getting ``store_id`` using a signer. -Version 0.2.3 +0.2.3 ------------- - Fixed signing failure in Python 3. @@ -70,19 +116,19 @@ Version 0.2.3 - Fixed ``StrictRedis`` support. -Version 0.2.2 +0.2.2 ------------- - Added support for non-permanent session. -Version 0.2.1 +0.2.1 ------------- - Fixed signing failure. -Version 0.2 +0.2 ----------- - Added ``SqlAlchemySessionInterface``. @@ -90,13 +136,13 @@ Version 0.2 - Various bugfixes. -Version 0.1.1 +0.1.1 ------------- -Fixed MongoDB backend ``InvalidDocument`` error. +- Fixed MongoDB backend ``InvalidDocument`` error. -Version 0.1 +0.1 ----------- - First public preview release. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 81fe685d..d9b7f0c7 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,32 +1,79 @@ Getting started -------------- +----------------- +Using pip +~~~~~~~~~~~ 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 +.. code-block:: bash + + $ python -m venv .venv + $ source .venv/bin/activate Install dependencies -.. code-block:: text - pip install -r requirements/dev.txt - pip install -r requirements/pytest.txt +.. code-block:: bash -Install Memecached and Redis and activate local server (optional) -.. code-block:: text - brew install memcached - brew install redis + $ pip install -r requirements/dev.in + $ pip install -r requirements/docs.in +Install the package in editable mode + +.. code-block:: bash + + $ pip install -e . + +Lint the code + +.. code-block:: bash + + $ ruff check --fix + +Build updated documentation locally + +.. code-block:: bash + + $ cd docs + $ make html + +or + +.. code-block:: bash + + $ sphinx-build -b html docs docs/_build Run the tests together or individually -.. code-block:: text - pytest tests - pytest tests/test_basic.py +.. code-block:: bash + + $ pytest tests + $ pytest tests/test_basic.py + +For easier startup and teardown of storage for testing you may use + +.. code-block:: bash + + $ docker-compose up -d + $ docker-compose down + +Using rye +~~~~~~~~~~~ + +.. code-block:: bash + + $ rye pin 3.11 + $ rye sync + + +.. code-block:: bash + + $ rye run python examples/hello.py + + +etc. Pull requests -------------- +-------------- Please check previous pull requests before submitting a new one. \ No newline at end of file diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 00000000..f8aafac8 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,23 @@ +## Contributors + +- [nebolax](https://github.com/nebolax) +- [Taragolis](https://github.com/Taragolis) +- [Lxstr](https://github.com/Lxstr) +- [yrro](https://github.com/yrro) +- [hluk](https://github.com/hluk) +- [idoshr](https://github.com/idoshr) +- [rayluo](https://github.com/rayluo) +- [davidism](https://github.com/davidism) +- [idocyabra](https://github.com/idocyabra) +- [darless](https://github.com/darless) +- [SqrtMinusOne](https://github.com/SqrtMinusOne) +- [tylersalminen](https://github.com/tylersalminen) +- [warrenbailey](https://github.com/warrenbailey) +- [imacat](https://github.com/imacat) +- [kim-sondrup](https://github.com/kim-sondrup) +- [bnjmn](https://github.com/bnjmn) +- [christopherpickering](https://github.com/christopherpickering) + +## Original Author + +- [fengsp](https://github.com/fengsp) diff --git a/README.rst b/README.rst index 224e2fdb..32c715aa 100644 --- a/README.rst +++ b/README.rst @@ -1,34 +1,108 @@ +.. image:: https://raw.githubusercontent.com/pallets-eco/flask-session/development/docs/_static/icon/favicon-192x192.png + :alt: Flask-Session + :target: https://flask-session.readthedocs.io + :align: left + :width: 60px + +============== 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://img.shields.io/github/actions/workflow/status/pallets-eco/flask-session/test.yaml?logo=github + :alt: GitHub Actions Workflow Status + :target: https://github.com/pallets-eco/flask-session/actions/workflows/test.yaml?query=workflow%3ACI+branch%3Adevelopment -.. image:: https://readthedocs.org/projects/flask-session/badge/?version=stable&style=flat +.. image:: https://img.shields.io/readthedocs/flask-session?logo=readthedocs :target: https://flask-session.readthedocs.io - :alt: docs + :alt: Documentation status -.. image:: https://img.shields.io/github/license/pallets-eco/flask-session +.. image:: https://img.shields.io/github/license/pallets-eco/flask-session?logo=bsd :target: ./LICENSE :alt: BSD-3 Clause License -.. image:: https://img.shields.io/pypi/v/flask-session.svg? +.. image:: https://common-changelog.org/badge.svg + :target: https://common-changelog.org + :alt: Common Changelog + +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&label=style + :target: https://github.com/astral-sh/ruff + :alt: Code style: ruff + +.. image:: https://img.shields.io/pypi/v/flask-session.svg?logo=pypi :target: https://pypi.org/project/flask-session - :alt: PyPI + :alt: PyPI - Latest Version -.. image:: https://img.shields.io/badge/dynamic/json?query=info.requires_python&label=python&url=https%3A%2F%2Fpypi.org%2Fpypi%2Fflask-session%2Fjson +.. image:: https://img.shields.io/badge/dynamic/json?query=info.requires_python&label=python&logo=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://img.shields.io/discord/531221516914917387?logo=discord + :target: https://discord.gg/pallets + :alt: Discord + +.. image:: https://img.shields.io/pypi/dm/flask-session?logo=pypi + :target: https://pypistats.org/packages/flask-session + :alt: PyPI - Downloads + +Installing +------------ +Install and update using pip: + +.. code-block:: bash + + $ pip install flask-session + +A Simple Example +-------------------- + +.. code-block:: python + + from flask import Flask, session + from flask_session import Session + + app = Flask(__name__) + # Check Configuration section for more details + SESSION_TYPE = 'redis' + app.config.from_object(__name__) + Session(app) + + @app.route('/set/') + def set(): + session['key'] = 'value' + return 'ok' + + @app.route('/get/') + def get(): + return session.get('key', 'not set') + +Supported Storage Types +------------------------ +- Redis +- Memcached +- FileSystem +- MongoDB +- SQLALchemy + +Documentation +------------- +Learn more at the official `Flask-Session Documentation `_. + +Maintainers +------------ +- `Lxstr `_ +- Pallets Team + +Contribute +---------- +Thanks to all those who have contributed to Flask-Session. A full list can be found at `CONTRIBUTORS.md `_. + +If you want to contribute, please check the `CONTRIBUTING.rst `_. + +Donate +-------- +The Pallets organization develops and supports Flask-Session and other popular packages. In order to grow the community of contributors and users, and allow the maintainers to devote more time to the projects, please donate today. + -.. 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..c5838f6a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.8' + +services: + mongo: + image: mongo:latest + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + + redis: + image: redis:latest + ports: + - "6379:6379" + volumes: + - redis_data:/data + + memcached: + image: memcached:latest + ports: + - "11211:11211" + +volumes: + postgres_data: + mongo_data: + redis_data: \ No newline at end of file diff --git a/docs/_static/icon/android-chrome-192x192.png b/docs/_static/icon/android-chrome-192x192.png new file mode 100644 index 00000000..52abd205 Binary files /dev/null and b/docs/_static/icon/android-chrome-192x192.png differ diff --git a/docs/_static/icon/android-chrome-512x512.png b/docs/_static/icon/android-chrome-512x512.png new file mode 100644 index 00000000..bca0e991 Binary files /dev/null and b/docs/_static/icon/android-chrome-512x512.png differ diff --git a/docs/_static/icon/apple-touch-icon.png b/docs/_static/icon/apple-touch-icon.png new file mode 100644 index 00000000..3132b481 Binary files /dev/null and b/docs/_static/icon/apple-touch-icon.png differ diff --git a/docs/_static/icon/favicon-16x16.png b/docs/_static/icon/favicon-16x16.png new file mode 100644 index 00000000..d68d09b5 Binary files /dev/null and b/docs/_static/icon/favicon-16x16.png differ diff --git a/docs/_static/icon/favicon-192x192.png b/docs/_static/icon/favicon-192x192.png new file mode 100644 index 00000000..ae051c7d Binary files /dev/null and b/docs/_static/icon/favicon-192x192.png differ diff --git a/docs/_static/icon/favicon-32x32.png b/docs/_static/icon/favicon-32x32.png new file mode 100644 index 00000000..4b6a31eb Binary files /dev/null and b/docs/_static/icon/favicon-32x32.png differ diff --git a/docs/_static/icon/favicon-48x48.png b/docs/_static/icon/favicon-48x48.png new file mode 100644 index 00000000..c88e2d84 Binary files /dev/null and b/docs/_static/icon/favicon-48x48.png differ diff --git a/docs/_static/icon/favicon-512x512.png b/docs/_static/icon/favicon-512x512.png new file mode 100644 index 00000000..63aecac1 Binary files /dev/null and b/docs/_static/icon/favicon-512x512.png differ diff --git a/docs/_static/icon/icon-master.psd b/docs/_static/icon/icon-master.psd new file mode 100644 index 00000000..46f0cb58 Binary files /dev/null and b/docs/_static/icon/icon-master.psd differ diff --git a/docs/_static/icon/mstile-150x150.png b/docs/_static/icon/mstile-150x150.png new file mode 100644 index 00000000..f319dcbe Binary files /dev/null and b/docs/_static/icon/mstile-150x150.png differ diff --git a/docs/_static/icon/safari-pinned-tab.svg b/docs/_static/icon/safari-pinned-tab.svg new file mode 100644 index 00000000..85c34d56 --- /dev/null +++ b/docs/_static/icon/safari-pinned-tab.svg @@ -0,0 +1,44 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + diff --git a/docs/_static/logo/logo-black.png b/docs/_static/logo/logo-black.png new file mode 100644 index 00000000..63b24ce1 Binary files /dev/null and b/docs/_static/logo/logo-black.png differ diff --git a/docs/_static/logo/logo-dark-long.png b/docs/_static/logo/logo-dark-long.png new file mode 100644 index 00000000..94eeb240 Binary files /dev/null and b/docs/_static/logo/logo-dark-long.png differ diff --git a/docs/_static/logo/logo-dark.png b/docs/_static/logo/logo-dark.png new file mode 100644 index 00000000..1f1d3993 Binary files /dev/null and b/docs/_static/logo/logo-dark.png differ diff --git a/docs/_static/logo/logo-light-long.png b/docs/_static/logo/logo-light-long.png new file mode 100644 index 00000000..a2650306 Binary files /dev/null and b/docs/_static/logo/logo-light-long.png differ diff --git a/docs/_static/logo/logo-light.png b/docs/_static/logo/logo-light.png new file mode 100644 index 00000000..dd4191d9 Binary files /dev/null and b/docs/_static/logo/logo-light.png differ diff --git a/docs/_static/logo/logo-master-long.psd b/docs/_static/logo/logo-master-long.psd new file mode 100644 index 00000000..65cbf556 Binary files /dev/null and b/docs/_static/logo/logo-master-long.psd differ diff --git a/docs/_static/logo/logo-master.psd b/docs/_static/logo/logo-master.psd new file mode 100644 index 00000000..fc8a1cea Binary files /dev/null and b/docs/_static/logo/logo-master.psd differ diff --git a/docs/_static/logo/logo-social-dark.png b/docs/_static/logo/logo-social-dark.png new file mode 100644 index 00000000..4a8d74b1 Binary files /dev/null and b/docs/_static/logo/logo-social-dark.png differ diff --git a/docs/_static/logo/logo-social.png b/docs/_static/logo/logo-social.png new file mode 100644 index 00000000..58915a2c Binary files /dev/null and b/docs/_static/logo/logo-social.png differ diff --git a/docs/_static/sequence.webp b/docs/_static/sequence.webp new file mode 100644 index 00000000..bd0e3603 Binary files /dev/null and b/docs/_static/sequence.webp differ diff --git a/docs/_static/styles.css b/docs/_static/styles.css new file mode 100644 index 00000000..8a373f51 --- /dev/null +++ b/docs/_static/styles.css @@ -0,0 +1,13 @@ +@import url('https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400;1,700&family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap'); + +.padded { + padding: 40px; +} + +table { + max-width: 680px; + width: -webkit-fill-available; + width: -moz-available; + width: fill-available; + width: stretch; +} diff --git a/docs/api.rst b/docs/api.rst index 45103d85..8779f4d6 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,21 +1,22 @@ API --- +Anything documented here is part of the public API that Flask-Session provides, unless otherwise indicated. Anything not documented here is considered internal or private and may change at any time. + + .. module:: flask_session .. autoclass:: Session :members: init_app -.. autoclass:: flask_session.sessions.ServerSideSession - - .. attribute:: sid +.. autoclass:: flask_session.base.ServerSideSession - Session id, internally we use :func:`secrets.token_urlsafe` to generate one - session id. You can access it with ``session.sid``. +.. autoclass:: flask_session.base.ServerSideSessionInterface + :members: regenerate -.. autoclass:: NullSessionInterface -.. autoclass:: RedisSessionInterface -.. autoclass:: MemcachedSessionInterface -.. autoclass:: FileSystemSessionInterface -.. autoclass:: MongoDBSessionInterface -.. autoclass:: SqlAlchemySessionInterface +.. autoclass:: flask_session.redis.RedisSessionInterface +.. autoclass:: flask_session.memcached.MemcachedSessionInterface +.. autoclass:: flask_session.filesystem.FileSystemSessionInterface +.. autoclass:: flask_session.cachelib.CacheLibSessionInterface +.. autoclass:: flask_session.mongodb.MongoDBSessionInterface +.. autoclass:: flask_session.sqlalchemy.SqlAlchemySessionInterface \ No newline at end of file diff --git a/docs/changes.rst b/docs/changes.rst index 955deaf2..9703f52e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -1,4 +1,4 @@ Changes -======= +======== .. include:: ../CHANGES.rst diff --git a/docs/conf.py b/docs/conf.py index 71d55a27..a4e9b16f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,26 +3,101 @@ project = "Flask-Session" author = "Pallets Community Ecosystem" copyright = f"2014, {author}" -release = importlib.metadata.version("Flask-Session") +version = release = importlib.metadata.version("Flask-Session") -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.intersphinx", -] +# General -------------------------------------------------------------- + +extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx_favicon"] intersphinx_mapping = { - "python": ("http://docs.python.org/", None), - "flask": ("http://flask.palletsprojects.com/", None), - "werkzeug": ("http://werkzeug.palletsprojects.com/", None), - "flask-sqlalchemy": ("http://flask-sqlalchemy.palletsprojects.com/", None), + "python": ("https://docs.python.org/", None), + "flask": ("https://flask.palletsprojects.com/", None), + "werkzeug": ("https://werkzeug.palletsprojects.com/", None), + "flask-sqlalchemy": ("https://flask-sqlalchemy.palletsprojects.com/", None), + "redis": ("https://redis-py.readthedocs.io/en/stable/", None), } -html_theme = "alabaster" + +# HTML ----------------------------------------------------------------- + +favicons = [ + {"rel": "icon", "href": "icon.svg", "type": "image/svg+xml"}, + {"rel": "icon", "sizes": "16x16", "href": "favicon-16x16.png", "type": "image/png"}, + {"rel": "icon", "sizes": "32x32", "href": "favicon-32x32.png", "type": "image/png"}, + {"rel": "icon", "sizes": "48x48", "href": "favicon-48x48.png", "type": "image/png"}, + { + "rel": "icon", + "sizes": "192x192", + "href": "favicon-192x192.png", + "type": "image/png", + }, + { + "rel": "icon", + "sizes": "512x512", + "href": "favicon-512x512.png", + "type": "image/png", + }, + { + "rel": "apple-touch-icon", + "sizes": "180x180", + "href": "apple-touch-icon-180x180.png", + "type": "image/png", + }, + { + "rel": "mask-icon", + "href": "safari-pinned-tab.svg", + }, +] +html_copy_source = False +html_css_files = [ + "styles.css", + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/fontawesome.min.css", + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/solid.min.css", + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/brands.min.css", +] +html_domain_indices = False +html_static_path = ["_static"] +html_theme = "furo" html_theme_options = { - "github_button": True, - "github_user": "pallets-eco", - "github_repo": "flask-session", - "github_type": "star", - "github_banner": True, - "show_related": True, + "announcement": "Flask-Session is switching serializer to msgspec in 1.0.0. Version 0.7.0 will migrate existing sessions upon read or write.", + "source_repository": "https://github.com/pallets-eco/flask-session/", + "source_branch": "main", + "source_directory": "docs/", + "light_logo": "logo/logo-light.png", + "dark_logo": "logo/logo-dark.png", + "light_css_variables": { + "font-stack": "'Atkinson Hyperlegible', sans-serif", + "font-stack--monospace": "'Source Code Pro', monospace", + "color-brand-primary": "#39A9BE", + "color-brand-content": "#39A9BE", + }, + "dark_css_variables": { + "font-stack": "'Atkinson Hyperlegible', sans-serif", + "font-stack--monospace": "'Source Code Pro', monospace", + "color-brand-primary": "#39A9BE", + "color-brand-content": "#39A9BE", + }, + "sidebar_hide_name": True, + "navigation_with_keys": True, + "footer_icons": [ + { + "name": "GitHub", + "url": "https://github.com/pallets-eco/flask-session", + "html": "", + "class": "fa-brands fa-solid fa-github fa-lg", + }, + { + "name": "Discord", + "url": "https://discord.gg/pallets", + "html": "", + "class": "fa-brands fa-solid fa-discord fa-lg", + }, + { + "name": "PyPI", + "url": "https://pypi.org/project/Flask-Session/", + "html": "", + "class": "fa-brands fa-solid fa-python fa-lg", + }, + ], } +html_use_index = False diff --git a/docs/config.rst b/docs/config.rst index cd433fd5..44ff02af 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -1,86 +1,32 @@ Configuration ============= -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, 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. - -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. - -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. +.. include:: config_example.rst -.. py:data:: SESSION_COOKIE_DOMAIN +.. include:: config_nonpermanent.rst - The domain for the session cookie. If this is not set, the cookie will be valid for all subdomains of ``SERVER_NAME``. +.. include:: config_cleanup.rst -.. py:data:: SESSION_COOKIE_PATH +.. include:: config_exceptions.rst - 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 ``'/'``. +.. include:: config_serialization.rst -.. py:data:: SESSION_COOKIE_HTTPONLY +.. include:: config_flask.rst - 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 +Flask-Session configuration values ---------------------------------- +These are specific to Flask-Session. + .. 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 + - **filesystem**: FileSystemSessionInterface (Deprecated in 0.7.0, will be removed in 1.0.0 in favor of CacheLibSessionInterface) + - **cachelib**: CacheLibSessionInterface - **mongodb**: MongoDBSessionInterface - **sqlalchemy**: SqlAlchemySessionInterface @@ -92,13 +38,18 @@ Flask-Session Configuration Values .. 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`. + Whether sign the session cookie sid or not, if set to ``True``, you have to set :attr:`flask.Flask.secret_key`. + + .. note:: + This feature is historical and generally only relevant if you are using client-side sessions ie. not Flask-Session. SESSION_ID_LENGTH provides the relevant entropy for session identifiers. Default: ``False`` + .. deprecated:: 0.7.0 + .. 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. + A prefix that is added before all session keys. This makes it easier to use the same backend storage server for different apps. Default: ``'session:'`` @@ -108,11 +59,29 @@ Flask-Session Configuration Values Default: ``32`` -.. versionadded:: 0.6 + .. versionadded:: 0.6.0 + +.. 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 + +.. deprecated:: 0.7.0 + ``SESSION_USE_SIGNER`` + +.. versionadded:: 0.7.0 + ``SESSION_SERIALIZATION_FORMAT`` + +.. versionadded:: 0.6.0 ``SESSION_ID_LENGTH`` -Backend-specific Configuration Values ---------------------------------------- + +Storage configuration +--------------------- + Redis ~~~~~~~~~~~~~~~~~~~~~~~ @@ -143,18 +112,41 @@ FileSystem Default: ``flask_session`` directory under current working directory. + .. deprecated:: 0.7.0 + .. py:data:: SESSION_FILE_THRESHOLD The maximum number of items the session stores before it starts deleting some. Default: ``500`` + .. deprecated:: 0.7.0 + .. py:data:: SESSION_FILE_MODE The file mode wanted for the session files. Default: ``0600`` + .. deprecated:: 0.7.0 + +CacheLib +~~~~~~~~~~~~~~~~~~~~~~~ +.. py:data:: SESSION_CACHELIB + + Any valid `cachelib backend `_. This allows you maximum flexibility in choosing the cache backend and it's configuration. + + The following would set a cache directory called "flask_session" and a threshold of 500 items before it starts deleting some. + + .. code-block:: python + + app.config['SESSION_CACHELIB'] = FileSystemCache(cache_dir='flask_session', threshold=500) + + .. important:: + + A ``default_timeout`` set in any of the ``CacheLib`` backends will be overrode by the ``PERMANENT_SESSION_LIFETIME`` when each stored session's expiry is set. + + Default: ``FileSystemCache`` in ``./flask_session`` directory. MongoDB ~~~~~~~~~~~~~~~~~~~~~~~ @@ -211,5 +203,20 @@ SqlAlchemy Default: ``None`` -.. versionadded:: 0.6 +.. py:data:: SESSION_CLEANUP_N_REQUESTS + + Only applicable to non-TTL backends. + + The average number of requests after which Flask-Session will perform a session cleanup. This involves removing all session data that is older than ``PERMANENT_SESSION_LIFETIME``. Using the app command ``flask session_cleanup`` instead is preferable. + + Default: ``None`` + +.. deprecated:: 0.7.0 + + ``SESSION_FILE_DIR``, ``SESSION_FILE_THRESHOLD``, ``SESSION_FILE_MODE``. Use ``SESSION_CACHELIB`` instead. + +.. versionadded:: 0.7.0 + ``SESSION_CLEANUP_N_REQUESTS`` + +.. versionadded:: 0.6.0 ``SESSION_SQLALCHEMY_BIND_KEY``, ``SESSION_SQLALCHEMY_SCHEMA``, ``SESSION_SQLALCHEMY_SEQUENCE`` diff --git a/docs/config_cleanup.rst b/docs/config_cleanup.rst new file mode 100644 index 00000000..ac535f11 --- /dev/null +++ b/docs/config_cleanup.rst @@ -0,0 +1,18 @@ +Scheduled session cleanup +------------------------- + + +.. important :: + + In the case of ``SQLAlchemy``, expired sessions are not automatically deleted from the database. You must use one of the following scheduled cleanup methods. + + +Run the the following command regularly with a cron job or scheduler such as Heroku Scheduler to clean up expired sessions. This is the recommended way to clean up expired sessions. + +.. code-block:: bash + + flask session_cleanup + +Alternatively, set the configuration variable ``SESSION_CLEANUP_N_REQUESTS`` to the average number of requests after which the cleanup should be performed. This is less desirable than using the scheduled app command cleanup as it may slow down some requests but may be useful for small applications or rapid development. + +This is not required for the ``Redis``, ``Memecached``, ``Filesystem``, ``Mongodb`` storage engines, as they support time-to-live for records. \ No newline at end of file diff --git a/docs/config_example.rst b/docs/config_example.rst new file mode 100644 index 00000000..9960da07 --- /dev/null +++ b/docs/config_example.rst @@ -0,0 +1,22 @@ +Example +--------------------- + +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``, instead you should configure ``SESSION_REDIS`` to your own :meth:`redis.Redis` instance. +This gives you more flexibility, such as using the same instance for cache purposes too, then you do not need to keep +two instances in the same process. + +If you do not set ``SESSION_REDIS``, Flask-Session will assume you are developing locally and create a +:meth:`redis.Redis` instance for you. It is expected you supply an instance of +:meth:`redis.Redis` in production. + +.. note:: + + By default, sessions in Flask-Session are permanent with an expiration of 31 days. \ No newline at end of file diff --git a/docs/config_exceptions.rst b/docs/config_exceptions.rst new file mode 100644 index 00000000..e192eba3 --- /dev/null +++ b/docs/config_exceptions.rst @@ -0,0 +1,39 @@ +Retries +-------- + +Only for SQL based storage, upon an exception, Flask-Session will retry with backoff up to 3 times. If the operation still fails after 3 retries, the exception will be raised. + +For other storage types, the retry logic is either included or can be configured in the client setup. Refer to the relevant client documentation for more information. + +Redis example with retries on certain errors: + +.. code-block:: python + + from redis.backoff import ExponentialBackoff + from redis.retry import Retry + from redis.client import Redis + from redis.exceptions import ( + BusyLoadingError, + ConnectionError, + TimeoutError + ) + ... + + retry = Retry(ExponentialBackoff(), 3) + SESSION_REDIS = Redis(host='localhost', port=6379, retry=retry, retry_on_error=[BusyLoadingError, ConnectionError, TimeoutError]) + + +Logging +------------------- + +If you want to show user more helpful error messages, you can use `Flask's error handling`_. For example: + +.. code-block:: python + + @app.errorhandler(RedisError) + def handle_redis_error(error): + app.logger.error(f"Redis error encountered: {error}") + return "A problem occurred with our Redis service. Please try again later.", 500 + + +.. _Flask's error handling: https://flask.palletsprojects.com/en/3.0.x/errorhandling/ \ No newline at end of file diff --git a/docs/config_flask.rst b/docs/config_flask.rst new file mode 100644 index 00000000..540ccb1a --- /dev/null +++ b/docs/config_flask.rst @@ -0,0 +1,38 @@ + +Relevant Flask configuration values +------------------------------------- +The following configuration values are from +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. + +These values cannot be modified after the ``init_app`` was applied so make sure to not +modify them at runtime. + +`SESSION_COOKIE_NAME`_ + +`SESSION_COOKIE_DOMAIN`_ + +`SESSION_COOKIE_PATH`_ + +`SESSION_COOKIE_HTTPONLY`_ + +`SESSION_COOKIE_SECURE`_ + +`SESSION_COOKIE_SAMESITE`_ + +`SESSION_REFRESH_EACH_REQUEST`_ + +`PERMANENT_SESSION_LIFETIME`_ + +.. _SESSION_COOKIE_NAME: https://flask.palletsprojects.com/en/latest/config/#SESSION_COOKIE_NAME +.. _SESSION_COOKIE_DOMAIN: https://flask.palletsprojects.com/en/latest/config/#SESSION_COOKIE_DOMAIN +.. _SESSION_COOKIE_PATH: https://flask.palletsprojects.com/en/latest/config/#SESSION_COOKIE_PATH +.. _SESSION_COOKIE_HTTPONLY: https://flask.palletsprojects.com/en/latest/config/#SESSION_COOKIE_HTTPONLY +.. _SESSION_COOKIE_SECURE: https://flask.palletsprojects.com/en/latest/config/#SESSION_COOKIE_SECURE +.. _SESSION_COOKIE_SAMESITE: https://flask.palletsprojects.com/en/latest/config/#SESSION_COOKIE_SAMESITE +.. _PERMANENT_SESSION_LIFETIME: https://flask.palletsprojects.com/en/latest/config/#PERMANENT_SESSION_LIFETIME +.. _SESSION_REFRESH_EACH_REQUEST: https://flask.palletsprojects.com/en/latest/config/#SESSION_REFRESH_EACH_REQUEST + +.. note:: + ``PERMANENT_SESSION_LIFETIME`` is also used to set the expiration time of the session data on the server side, regardless of ``SESSION_PERMANENT``. diff --git a/docs/config_nonpermanent.rst b/docs/config_nonpermanent.rst new file mode 100644 index 00000000..2f6c644c --- /dev/null +++ b/docs/config_nonpermanent.rst @@ -0,0 +1,18 @@ +Non-permanent sessions +------------------------------------ + +.. caution:: + + Flask-session is primarily designed to be used with permanent sessions. If you want to use non-permanent sessions, you must set ``SESSION_PERMANENT=False`` and be aware of significant limitations. + +Flask terminology regarding it's built-in client-side session is inherited by Flask-Session: + +- **Permanent session**: A cookie is stored in the browser and not deleted until it expires (has expiry). Also known as a persistent cookie. +- **Non-permanent session**: A cookie is stored in the browser and is deleted when the browser or tab is closed (no expiry). Also known as a session cookie or non-persistent cookie. + +Either cookie can be removed earlier if requested by the server, for example during logout. + +In the case of non-permanent server-side sessions, the server has no way to know when the browser is closed and it's session cookie removed as a result, so it cannot confidently know when to delete the stored session data linked to that browser. This can lead to a large number of stale sessions being stored on the server. + +To mitigate this somewhat, Flask-Session always sets server-side expiration time using ``PERMANENT_SESSION_LIFETIME``. As such, ``PERMANENT_SESSION_LIFETIME`` can be set to a very short time to further mitigate this. + diff --git a/docs/config_serialization.rst b/docs/config_serialization.rst new file mode 100644 index 00000000..7f3049fc --- /dev/null +++ b/docs/config_serialization.rst @@ -0,0 +1,12 @@ +Serialization +------------------------------------ + +.. warning:: + + Flask-session versions below 1.0.0 use pickle serialization (or fallback) for session storage. While not a direct vulnerability, it is a potential security risk. If you are using a version below 1.0.0, it is recommended to upgrade to the latest version as soon as it's available. + +From 0.7.0 the serializer is msgspec, which is configurable using ``SESSION_SERIALIZATION_FORMAT``. The default format is ``'msgpack'`` which has 30% storage reduction compared to ``'json'``. The ``'json'`` format may be helpful for debugging, easier viewing or compatibility. Switching between the two should be seamless, even for existing sessions. + +All sessions that are accessed or modified while using 0.7.0 will convert to a msgspec format. Once using 1.0.0, any sessions that are still in pickle format will be cleared upon access. + +The msgspec library has speed and memory advantages over other libraries. However, if you want to use a different library (such as pickle or orjson), you can override the :attr:`session_interface.serializer`. diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 00000000..9907be9f --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,4 @@ +Contributing +=============== + +.. include:: ../CONTRIBUTING.rst diff --git a/docs/index.rst b/docs/index.rst index f1508d66..462f0ae9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,21 +1,15 @@ -Flask-Session -============= - -Flask-Session is an extension for `Flask`_ that adds support for server-side sessions to -your application. - -.. _Flask: http://flask.palletsprojects.com/ - - Table of Contents ----------------- .. toctree:: :maxdepth: 2 + introduction installation - quickstart + usage config + security api + contributing license changes diff --git a/docs/installation.rst b/docs/installation.rst index dadde718..1bf19d40 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -4,42 +4,88 @@ Installation Install from PyPI using an installer such as pip: -.. code-block:: text +.. code-block:: bash $ 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. +Flask-Session's only required dependency is msgspec for serialization, which has no sub-dependencies. -For example, if you want to use Redis as your backend, you will need to install the redis-py client library: +However, you also need to choose a storage type and install an appropriate client library so the app can communicate with storage. For example, if you want to use Redis as your storage, you will need to install the redis-py client library: -.. code-block:: text +.. code-block:: bash $ pip install redis +Redis is the recommended storage type for Flask-Session, as it has the most complete support for the features of Flask-Session with minimal configuration. -Supported Backends and Client Libraries ---------------------------------------- +.. warning:: + Flask-Session versions below 1.0.0 (not yet released), use pickle_ as the default serializer, which may have security implications in production if your storage is ever compromised. + + +Direct support +--------------- + +Flask-Session has an increasing number of directly supported storage and client libraries. .. list-table:: :header-rows: 1 + :align: left - * - Backend + * - Storage - Client Library * - Redis - redis-py_ * - Memcached - - pylibmc_, python-memcached_, pymemcache_ + - pylibmc_, python-memcached_, libmc_ or pymemcache_ * - MongoDB - pymongo_ * - SQL Alchemy - flask-sqlalchemy_ -Other clients may work if they use the same commands as the ones listed above. +Other libraries may work if they use the same commands as the ones listed above. + +Cachelib +-------- + +Flask-Session also indirectly supports storage and client libraries via cachelib_, which is a wrapper around various cache libraries. You must also install cachelib_ itself to use these. + +.. list-table:: + :header-rows: 1 + :align: left + + * - Storage + - Client Library + * - File System + - Not required + * - Simple Memory + - Not required + * - UWSGI + - uwsgi_ + * - Redis + - redis-py_ + * - Memcached + - pylibmc_, python-memcached_, libmc_ or `google.appengine.api.memcached`_ + * - MongoDB + - pymongo_ + * - DynamoDB + - boto3_ + + +.. warning:: + + As of writing, cachelib_ still uses pickle_ as the default serializer, which may have security implications in production if your storage is ever compromised. + -.. _redis-py: https://github.com/andymccurdy/redis-py +.. _redis-py: https://github.com/redis/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 +.. _pymongo: https://pymongo.readthedocs.io/en/stable +.. _Flask-SQLAlchemy: https://github.com/pallets-eco/flask-sqlalchemy +.. _cachelib: https://cachelib.readthedocs.io/en/stable/ +.. _google.appengine.api.memcached: https://cloud.google.com/appengine/docs/legacy/standard/python/memcache +.. _boto3: https://boto3.amazonaws.com/v1/documentation/api/latest/index.html +.. _libmc: https://github.com/douban/libmc +.. _uwsgi: https://uwsgi-docs.readthedocs.io/en/latest/WSGIquickstart.html +.. _pickle: https://docs.python.org/3/library/pickle.html \ No newline at end of file diff --git a/docs/introduction.rst b/docs/introduction.rst new file mode 100644 index 00000000..d6e7aadd --- /dev/null +++ b/docs/introduction.rst @@ -0,0 +1,28 @@ +Introduction +============= + +Flask-Session is an extension for `Flask`_ that adds support for server-side sessions to +your application. + +.. _Flask: https://flask.palletsprojects.com/en/3.0.x/ + +Client-side vs Server-side sessions +------------------------------------ + +Client-side sessions store session data in the client's browser. +This is done by placing it in a cookie that is sent to and from the client on each request and response. +This can be any small, basic information about that client or their interactions for quick retrieval (up to 4kB). + +Server-side sessions differ by storing session data in server-side storage. +A cookie is also used, but it only contains the session identifier that links the client to their corresponding data on the server. + +.. tip:: + There are generally (some exceptions) no individual session size limitations for server-side sessions, + but developers should be cautious about abusing this for large amounts or types of data that would be more suited for actual database storage. + +Flask-Session sequence diagram +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. image:: /_static/sequence.webp + :alt: sequence diagram for flask-session + :class: padded highlight \ No newline at end of file diff --git a/docs/quickstart.rst b/docs/quickstart.rst deleted file mode 100644 index ba77df84..00000000 --- a/docs/quickstart.rst +++ /dev/null @@ -1,39 +0,0 @@ -Quick Start -=========== - -.. currentmodule:: flask_session - - -Create your Flask application, load the configuration of choice, and -then create the :class:`Session` object by passing it the application. - -The ``Session`` instance is not used for direct access, you should always use -:class:`flask.session`. - -.. code-block:: python - - from flask import Flask, session - from flask_session import Session - - app = Flask(__name__) - # Check Configuration section for more details - SESSION_TYPE = 'redis' - app.config.from_object(__name__) - Session(app) - - @app.route('/set/') - def set(): - session['key'] = 'value' - return 'ok' - - @app.route('/get/') - def get(): - return session.get('key', 'not set') - -You may also set up your application later using :meth:`~Session.init_app` -method. - -.. code-block:: python - - sess = Session() - sess.init_app(app) diff --git a/docs/security.rst b/docs/security.rst new file mode 100644 index 00000000..66a42691 --- /dev/null +++ b/docs/security.rst @@ -0,0 +1,55 @@ +.. currentmodule:: flask_session + +Security +========== + +.. warning:: + + Flask is a micro-framework and does not provide all security features out of the box. It is important to configure security settings for your application. + +Flask configuration +-------------------- + +Please refer to documentation for `Flask`_, `OWASP`_, and other resources such as `MDN`_ for the latest information on best practice. + +Consider the following Flask configurations in production: + +.. list-table:: + :header-rows: 1 + :align: left + + * - Setting + - Consideration + * - SESSION_COOKIE_SECURE + - Set to ``True`` if your application is served over HTTPS. + * - SESSION_COOKIE_NAME + - Use ``__Secure-`` or ``__Host-`` prefix according to MDN docs. + * - SESSION_COOKIE_SAMESITE + - Use ``Lax`` or ``Strict`` + +You can use a security plugin such as `Flask-Talisman`_ to set these and more. + +Storage +------------------ + +Take care to secure your storage and storage client connection. For example, setup SSL/TLS and storage authentication. + + +Session fixation +------------------ + +Session fixation is an attack that permits an attacker to hijack a valid user session. The attacker can fixate a user's session by providing them with a session identifier. The attacker can then use the session identifier to impersonate the user. +As one tool among others that can mitigate session fixation, is regenerating the session identifier when a user logs in. This can be done by calling the :meth:`flask.Flask.session_interface.regenerate` method. This method is defined in :class:`flask_session.base.ServerSideSession`. + +.. code-block:: python + + @app.route('/login') + def login(): + # your login logic ... + app.session_interface.regenerate(session) + # your response ... + +.. _Flask: https://flask.palletsprojects.com/en/2.3.x/security/#set-cookie-options +.. _MDN: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies +.. _OWASP: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html +.. _Flask-Talisman: https://github.com/wntrblm/flask-talisman \ No newline at end of file diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 00000000..ce389615 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,65 @@ +Usage +=========== + +Quickstart +----------- + +.. currentmodule:: flask_session + + +Create your :class:`~flask.Flask` application, load the configuration of choice, and +then create the :class:`Session` object by passing it the application. + +.. code-block:: python + + from flask import Flask, session + from flask_session import Session + + app = Flask(__name__) + + SESSION_TYPE = 'redis' + SESSION_REDIS = Redis(host='localhost', port=6379) + app.config.from_object(__name__) + Session(app) + + @app.route('/set/') + def set(): + session['key'] = 'value' + return 'ok' + + @app.route('/get/') + def get(): + return session.get('key', 'not set') + +See the configuration section for more details. + +.. note:: + + You can not use :class:`~Session` instance directly, what :class:`~Session` does + is just change the :attr:`~flask.Flask.session_interface` attribute on + your Flask applications. You should always use :class:`flask.session` when accessing or modifying the current session. + + +Alternative initialization +--------------------------- + +Rather than calling :class:`~Session`, you may initialize later using :meth:`~Session.init_app`. + +.. code-block:: python + + ... + sess = Session() + sess.init_app(app) + +Or, if you prefer to directly set parameters rather than using the configuration constants, you can initialize by setting an instance of :class:`flask_session.redis.RedisSessionInterface` directly to the :attr:`flask.Flask.session_interface`. + +.. code-block:: python + + from flask import Flask, session + from flask_session.redis import RedisSessionInterface + from redis import Redis + + app = Flask(__name__) + + redis = Redis(host='localhost', port=6379) + app.session_interface = RedisSessionInterface(client=redis) \ No newline at end of file diff --git a/examples/hello.py b/examples/hello.py index f6684720..4afc2c1a 100644 --- a/examples/hello.py +++ b/examples/hello.py @@ -5,9 +5,7 @@ app.config.from_object(__name__) app.config.update( { - "SESSION_TYPE": "sqlalchemy", - "SQLALCHEMY_DATABASE_URI": "sqlite:////tmp/test.db", - "SQLALCHEMY_USE_SIGNER": True, + "SESSION_TYPE": "redis", } ) Session(app) @@ -21,13 +19,20 @@ def set(): @app.route("/get/") def get(): - import time - - start_time = time.time() result = session.get("key", "not set") - print("get", (time.time() - start_time) * 1000) return result +@app.route("/delete/") +def delete(): + del session["key"] + return "deleted" + + +@app.route("/") +def hello(): + return "hello world" + + if __name__ == "__main__": app.run(debug=True) diff --git a/examples/kitchen-sink.py b/examples/kitchen-sink.py new file mode 100644 index 00000000..539957e4 --- /dev/null +++ b/examples/kitchen-sink.py @@ -0,0 +1,63 @@ +from flask import Flask, session +from flask_session import Session +from redis.exceptions import RedisError + +app = Flask(__name__) +app.config.from_object(__name__) +app.config.update( + { + "SESSION_TYPE": "redis", + "SECRET_KEY": "sdfads", + "SESSION_SERIALIZATION_FORMAT": "json", + } +) + +Session(app) + + +@app.route("/") +def index(): + return "No cookies in this response if it is your first visit." + + +@app.route("/add-apple/") +def set(): + session["apple_count"] = session.get("apple_count", 0) + 1 + return "ok" + + +@app.route("/get-apples/") +def get(): + result = str(session.get("apple_count", "no apples")) + return result + + +@app.route("/login/") +def login(): + # Mitigate session fixation attacks + # If the session is not empty (/add-apple/ was previously visited), the session will be regenerated + app.session_interface.regenerate(session) + # Here you would authenticate the user first + session["logged_in"] = True + return "logged in" + + +@app.route("/logout/") +def delete(): + session.clear() + return "deleted" + + +@app.route("/error/") +def error(): + raise RedisError("An error occurred with Redis") + + +@app.errorhandler(RedisError) +def handle_redis_error(error): + app.logger.error(f"Redis error encountered: {error}") + return "A problem occurred with our Redis service. Please try again later.", 500 + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/pyproject.toml b/pyproject.toml index be1450ac..b31e38f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,10 +18,10 @@ classifiers = [ "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Software Development :: Libraries :: Application Frameworks", ] -requires-python = ">=3.7" +requires-python = ">=3.8" dependencies = [ "flask>=2.2", - "cachelib", + "msgspec>=0.18.6", ] dynamic = ["version"] @@ -67,4 +67,21 @@ select = [ # isort "I", ] -ignore = ["E501"] \ No newline at end of file +ignore = ["E501"] + +[tool.rye] +managed = true +dev-dependencies = [ + "ruff>=0.3.3", + "pytest>=7.4.4", + "pytest-cov>=4.1.0", + "redis>=5.0.3", + "python-memcached>=1.62", + "flask-sqlalchemy>=3.0.5", + "pymongo>=4.6.2", + "cachelib>=0.10.2", + "msgspec>=0.18.6", + "sphinx>=7.1.2", + "furo>=2024.1.29", + "sphinx-favicon>=1.0.1", +] diff --git a/requirements/pytest.txt b/requirements/dev.txt similarity index 94% rename from requirements/pytest.txt rename to requirements/dev.txt index 25311c8e..465f83bb 100644 --- a/requirements/pytest.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/requirements/docs.in b/requirements/docs.in index 6966869c..7da2e193 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -1 +1,10 @@ sphinx +furo +sphinx-favicon + +# Install clients +redis +cachelib +pymongo +flask_sqlalchemy +pymemcache \ No newline at end of file diff --git a/requirements/docs.txt b/requirements/docs.txt index 2cf365d9..84bfc531 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,37 +1,76 @@ -# SHA1:b9aaf35e80441f415c3a3d3c53695d0efded116a # -# This file is autogenerated by pip-compile-multi -# To update, run: +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: # -# pip-compile-multi +# pip-compile --output-file=requirements/docs.txt requirements/docs.in # alabaster==0.7.13 # via sphinx babel==2.12.1 # via sphinx +beautifulsoup4==4.12.3 + # via furo +blinker==1.7.0 + # via flask +cachelib==0.12.0 + # via -r requirements/docs.in certifi==2023.5.7 # via requests charset-normalizer==3.1.0 # via requests +click==8.1.7 + # via flask +dnspython==2.6.1 + # via pymongo docutils==0.19 # via sphinx +flask==3.0.2 + # via flask-sqlalchemy +flask-sqlalchemy==3.1.1 + # via -r requirements/docs.in +furo==2024.1.29 + # via -r requirements/docs.in idna==3.4 # via requests imagesize==1.4.1 # via sphinx +itsdangerous==2.1.2 + # via flask jinja2==3.1.2 - # via sphinx + # via + # flask + # sphinx markupsafe==2.1.2 - # via jinja2 + # via + # jinja2 + # werkzeug packaging==23.1 # via sphinx pygments==2.15.1 - # via sphinx + # via + # furo + # sphinx +pymemcache==4.0.0 + # via -r requirements/docs.in +pymongo==4.6.2 + # via -r requirements/docs.in +redis==5.0.1 + # via -r requirements/docs.in requests==2.30.0 # via sphinx snowballstemmer==2.2.0 # via sphinx +soupsieve==2.5 + # via beautifulsoup4 sphinx==7.0.0 + # via + # -r requirements/docs.in + # furo + # sphinx-basic-ng + # sphinx-favicon +sphinx-basic-ng==1.0.0b2 + # via furo +sphinx-favicon==1.0.1 # via -r requirements/docs.in sphinxcontrib-applehelp==1.0.4 # via sphinx @@ -45,5 +84,11 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx +sqlalchemy==2.0.27 + # via flask-sqlalchemy +typing-extensions==4.10.0 + # via sqlalchemy urllib3==2.0.2 # via requests +werkzeug==3.0.1 + # via flask diff --git a/src/flask_session/__init__.py b/src/flask_session/__init__.py index 9f5e315c..b1abf810 100644 --- a/src/flask_session/__init__.py +++ b/src/flask_session/__init__.py @@ -1,45 +1,24 @@ -import os +from .defaults import Defaults -from .sessions import ( - FileSystemSessionInterface, - MemcachedSessionInterface, - MongoDBSessionInterface, - NullSessionInterface, - RedisSessionInterface, - SqlAlchemySessionInterface, -) - -__version__ = "0.6.0rc1" +__version__ = "0.7.0" class Session: """This class is used to add Server-side Session to one or more Flask applications. - There are two usage modes. One is initialize the instance with a very - specific Flask application:: + :param app: A Flask app instance. + + For a typical setup use the following initialization:: app = Flask(__name__) Session(app) - The second possibility is to create the object once and configure the - application later:: - - sess = Session() - - def create_app(): - app = Flask(__name__) - sess.init_app(app) - return app - - By default Flask-Session will use :class:`NullSessionInterface`, you - really should configurate your app to use a different SessionInterface. - .. note:: You can not use ``Session`` instance directly, what ``Session`` does is just change the :attr:`~flask.Flask.session_interface` attribute on - your Flask applications. + your Flask applications. You should always use :class:`flask.session`. """ def __init__(self, app=None): @@ -48,87 +27,145 @@ def __init__(self, app=None): self.init_app(app) def init_app(self, app): - """This is used to set up session for your app object. + """This the the alternate setup method, typically used in an application factory pattern:: + + sess = Session() + + def create_app(): + app = Flask(__name__) + sess.init_app(app) + return app :param app: the Flask app object with proper configuration. """ app.session_interface = self._get_interface(app) def _get_interface(self, app): - config = app.config.copy() + config = app.config # 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) + SESSION_TYPE = config.get("SESSION_TYPE", Defaults.SESSION_TYPE) + + SESSION_PERMANENT = config.get("SESSION_PERMANENT", Defaults.SESSION_PERMANENT) + SESSION_USE_SIGNER = config.get( + "SESSION_USE_SIGNER", Defaults.SESSION_USE_SIGNER + ) # TODO: remove in 1.0 + SESSION_KEY_PREFIX = config.get( + "SESSION_KEY_PREFIX", Defaults.SESSION_KEY_PREFIX + ) + SESSION_ID_LENGTH = config.get("SESSION_ID_LENGTH", Defaults.SESSION_ID_LENGTH) + SESSION_SERIALIZATION_FORMAT = config.get( + "SESSION_SERIALIZATION_FORMAT", Defaults.SESSION_SERIALIZATION_FORMAT + ) # Redis settings - config.setdefault("SESSION_REDIS", None) + SESSION_REDIS = config.get("SESSION_REDIS", Defaults.SESSION_REDIS) # Memcached settings - config.setdefault("SESSION_MEMCACHED", None) + SESSION_MEMCACHED = config.get("SESSION_MEMCACHED", Defaults.SESSION_MEMCACHED) + + # CacheLib settings + SESSION_CACHELIB = config.get("SESSION_CACHELIB", Defaults.SESSION_CACHELIB) # Filesystem settings - config.setdefault( - "SESSION_FILE_DIR", os.path.join(os.getcwd(), "flask_session") + # TODO: remove in 1.0 + SESSION_FILE_DIR = config.get("SESSION_FILE_DIR", Defaults.SESSION_FILE_DIR) + SESSION_FILE_THRESHOLD = config.get( + "SESSION_FILE_THRESHOLD", Defaults.SESSION_FILE_THRESHOLD ) - config.setdefault("SESSION_FILE_THRESHOLD", 500) - config.setdefault("SESSION_FILE_MODE", 384) + SESSION_FILE_MODE = config.get("SESSION_FILE_MODE", Defaults.SESSION_FILE_MODE) # MongoDB settings - config.setdefault("SESSION_MONGODB", None) - config.setdefault("SESSION_MONGODB_DB", "flask_session") - config.setdefault("SESSION_MONGODB_COLLECT", "sessions") + SESSION_MONGODB = config.get("SESSION_MONGODB", Defaults.SESSION_MONGODB) + SESSION_MONGODB_DB = config.get( + "SESSION_MONGODB_DB", Defaults.SESSION_MONGODB_DB + ) + SESSION_MONGODB_COLLECT = config.get( + "SESSION_MONGODB_COLLECT", Defaults.SESSION_MONGODB_COLLECT + ) # 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) + SESSION_SQLALCHEMY = config.get( + "SESSION_SQLALCHEMY", Defaults.SESSION_SQLALCHEMY + ) + SESSION_SQLALCHEMY_TABLE = config.get( + "SESSION_SQLALCHEMY_TABLE", Defaults.SESSION_SQLALCHEMY_TABLE + ) + SESSION_SQLALCHEMY_SEQUENCE = config.get( + "SESSION_SQLALCHEMY_SEQUENCE", Defaults.SESSION_SQLALCHEMY_SEQUENCE + ) + SESSION_SQLALCHEMY_SCHEMA = config.get( + "SESSION_SQLALCHEMY_SCHEMA", Defaults.SESSION_SQLALCHEMY_SCHEMA + ) + SESSION_SQLALCHEMY_BIND_KEY = config.get( + "SESSION_SQLALCHEMY_BIND_KEY", Defaults.SESSION_SQLALCHEMY_BIND_KEY + ) + SESSION_CLEANUP_N_REQUESTS = config.get( + "SESSION_CLEANUP_N_REQUESTS", Defaults.SESSION_CLEANUP_N_REQUESTS + ) common_params = { - "key_prefix": config["SESSION_KEY_PREFIX"], - "use_signer": config["SESSION_USE_SIGNER"], - "permanent": config["SESSION_PERMANENT"], - "sid_length": config["SESSION_ID_LENGTH"], + "app": app, + "key_prefix": SESSION_KEY_PREFIX, + "use_signer": SESSION_USE_SIGNER, + "permanent": SESSION_PERMANENT, + "sid_length": SESSION_ID_LENGTH, + "serialization_format": SESSION_SERIALIZATION_FORMAT, } - if config["SESSION_TYPE"] == "redis": + SESSION_TYPE = SESSION_TYPE.lower() + + if SESSION_TYPE == "redis": + from .redis import RedisSessionInterface + session_interface = RedisSessionInterface( - config["SESSION_REDIS"], **common_params + **common_params, + client=SESSION_REDIS, ) - elif config["SESSION_TYPE"] == "memcached": + elif SESSION_TYPE == "memcached": + from .memcached import MemcachedSessionInterface + session_interface = MemcachedSessionInterface( - config["SESSION_MEMCACHED"], **common_params + **common_params, + client=SESSION_MEMCACHED, ) - elif config["SESSION_TYPE"] == "filesystem": + elif SESSION_TYPE == "filesystem": + from .filesystem import FileSystemSessionInterface + session_interface = FileSystemSessionInterface( - config["SESSION_FILE_DIR"], - config["SESSION_FILE_THRESHOLD"], - config["SESSION_FILE_MODE"], **common_params, + cache_dir=SESSION_FILE_DIR, + threshold=SESSION_FILE_THRESHOLD, + mode=SESSION_FILE_MODE, ) - elif config["SESSION_TYPE"] == "mongodb": + elif SESSION_TYPE == "cachelib": + from .cachelib import CacheLibSessionInterface + + session_interface = CacheLibSessionInterface( + **common_params, client=SESSION_CACHELIB + ) + elif SESSION_TYPE == "mongodb": + from .mongodb import MongoDBSessionInterface + session_interface = MongoDBSessionInterface( - config["SESSION_MONGODB"], - config["SESSION_MONGODB_DB"], - config["SESSION_MONGODB_COLLECT"], **common_params, + client=SESSION_MONGODB, + db=SESSION_MONGODB_DB, + collection=SESSION_MONGODB_COLLECT, ) - elif config["SESSION_TYPE"] == "sqlalchemy": + elif SESSION_TYPE == "sqlalchemy": + from .sqlalchemy import SqlAlchemySessionInterface + session_interface = SqlAlchemySessionInterface( - app, - config["SESSION_SQLALCHEMY"], - config["SESSION_SQLALCHEMY_TABLE"], - config["SESSION_SQLALCHEMY_SEQUENCE"], - config["SESSION_SQLALCHEMY_SCHEMA"], - config["SESSION_SQLALCHEMY_BIND_KEY"], **common_params, + client=SESSION_SQLALCHEMY, + table=SESSION_SQLALCHEMY_TABLE, + sequence=SESSION_SQLALCHEMY_SEQUENCE, + schema=SESSION_SQLALCHEMY_SCHEMA, + bind_key=SESSION_SQLALCHEMY_BIND_KEY, + cleanup_n_requests=SESSION_CLEANUP_N_REQUESTS, ) else: - session_interface = NullSessionInterface() + raise ValueError(f"Unrecognized value for SESSION_TYPE: {SESSION_TYPE}") return session_interface diff --git a/src/flask_session/_utils.py b/src/flask_session/_utils.py new file mode 100644 index 00000000..cd7f6855 --- /dev/null +++ b/src/flask_session/_utils.py @@ -0,0 +1,67 @@ +""" +MIT License + +Copyright (c) 2023 giuppep + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import time +from functools import wraps +from typing import Any, Callable + +from flask import current_app + + +def total_seconds(timedelta): + return int(timedelta.total_seconds()) + + +def retry_query( + *, max_attempts: int = 3, delay: float = 0.3, backoff: int = 2 +) -> Callable[..., Any]: + """Decorator to retry a query when an OperationalError is raised. + + Args: + max_attempts: Maximum number of attempts. Defaults to 3. + delay: Delay between attempts in seconds. Defaults to 0.3. + backoff: Backoff factor. Defaults to 2. + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + for attempt in range(max_attempts): + try: + return func(*args, **kwargs) + # TODO: use proper exception type + except Exception as e: + if attempt == max_attempts - 1: + raise e + + sleep_time = delay * backoff**attempt + current_app.logger.exception( + f"Exception when querying database ({e})." + f"Retrying ({attempt + 1}/{max_attempts}) in {sleep_time:.2f}s." + ) + time.sleep(sleep_time) + + return wrapper + + return decorator diff --git a/src/flask_session/base.py b/src/flask_session/base.py new file mode 100644 index 00000000..04c6473c --- /dev/null +++ b/src/flask_session/base.py @@ -0,0 +1,385 @@ +import secrets +import warnings +from abc import ABC, abstractmethod +from contextlib import suppress + +try: + import cPickle as pickle +except ImportError: + import pickle + +import random +from datetime import timedelta as TimeDelta +from typing import Any, Dict, Optional + +import msgspec +from flask import Flask, Request, Response +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 ._utils import retry_query +from .defaults import Defaults + + +class ServerSideSession(CallbackDict, SessionMixin): + """Baseclass for server-side based sessions. This can be accessed through ``flask.session``. + + .. attribute:: sid + + Session id, internally we use :func:`secrets.token_urlsafe` to generate one + session id. + + .. attribute:: modified + + When data is changed, this is set to ``True``. Only the session dictionary + itself is tracked; if the session contains mutable data (for example a nested + dict) then this must be set to ``True`` manually when modifying that data. The + session cookie will only be written to the response if this is ``True``. + + .. attribute:: accessed + + When data is read (or attempted read) or written, this is set to ``True``. Used by + :class:`.ServerSideSessionInterface` to add a ``Vary: Cookie`` + header, which allows caching proxies to cache different pages for + different users. + + Default is ``False``. + + .. attribute:: permanent + + This sets and reflects the ``'_permanent'`` key in the dict. + + Default is ``False``. + + """ + + def __bool__(self) -> bool: + return bool(dict(self)) and self.keys() != {"_permanent"} + + def __init__( + self, + initial: Optional[Dict[str, Any]] = None, + sid: Optional[str] = None, + permanent: Optional[bool] = None, + ): + def on_update(self) -> None: + self.modified = True + self.accessed = True + + CallbackDict.__init__(self, initial, on_update) + self.sid = sid + if permanent: + self.permanent = permanent + self.modified = False + self.accessed = False + + def __getitem__(self, key: str) -> Any: + self.accessed = True + return super().__getitem__(key) + + def get(self, key: str, default: Any = None) -> Any: + self.accessed = True + return super().get(key, default) + + def setdefault(self, key: str, default: Any = None) -> Any: + self.accessed = True + return super().setdefault(key, default) + + def clear(self) -> None: + """Clear the session except for the '_permanent' key.""" + permanent = self.get("_permanent", False) + super().clear() + self["_permanent"] = permanent + + +class Serializer(ABC): + """Baseclass for session serialization.""" + + @abstractmethod + def decode(self, serialized_data: bytes) -> dict: + """Deserialize the session data.""" + raise NotImplementedError() + + @abstractmethod + def encode(self, session: ServerSideSession) -> bytes: + """Serialize the session data.""" + raise NotImplementedError() + + +class MsgSpecSerializer(Serializer): + def __init__(self, app: Flask, format: str): + self.app: Flask = app + self.encoder: msgspec.msgpack.Encoder or msgspec.json.Encoder + self.decoder: msgspec.msgpack.Decoder or msgspec.json.Decoder + self.alternate_decoder: msgspec.msgpack.Decoder or msgspec.json.Decoder + + if format == "msgpack": + self.encoder = msgspec.msgpack.Encoder() + self.decoder = msgspec.msgpack.Decoder() + self.alternate_decoder = msgspec.json.Decoder() + elif format == "json": + self.encoder = msgspec.json.Encoder() + self.decoder = msgspec.json.Decoder() + self.alternate_decoder = msgspec.msgpack.Decoder() + else: + raise ValueError(f"Unsupported serialization format: {format}") + + def encode(self, session: ServerSideSession) -> bytes: + """Serialize the session data.""" + try: + return self.encoder.encode(dict(session)) + except Exception as e: + self.app.logger.error(f"Failed to serialize session data: {e}") + raise + + def decode(self, serialized_data: bytes) -> dict: + """Deserialize the session data.""" + # TODO: Remove the pickle fallback in 1.0.0 + with suppress(msgspec.DecodeError): + return self.decoder.decode(serialized_data) + with suppress(msgspec.DecodeError): + return self.alternate_decoder.decode(serialized_data) + with suppress(pickle.UnpicklingError): + return pickle.loads(serialized_data) + # If all decoders fail, raise the final exception + self.app.logger.error("Failed to deserialize session data", exc_info=True) + raise pickle.UnpicklingError("Failed to deserialize session data") + + +class ServerSideSessionInterface(FlaskSessionInterface, ABC): + """Used to open a :class:`flask.sessions.ServerSideSessionInterface` instance.""" + + session_class = ServerSideSession + serializer = None + ttl = True + + def __init__( + self, + app: Flask, + key_prefix: str = Defaults.SESSION_KEY_PREFIX, + use_signer: bool = Defaults.SESSION_USE_SIGNER, + permanent: bool = Defaults.SESSION_PERMANENT, + sid_length: int = Defaults.SESSION_ID_LENGTH, + serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT, + cleanup_n_requests: Optional[int] = Defaults.SESSION_CLEANUP_N_REQUESTS, + ): + self.app = app + self.key_prefix = key_prefix + self.use_signer = use_signer + if use_signer: + warnings.warn( + "The 'use_signer' option is deprecated and will be removed in the next minor release. " + "Please update your configuration accordingly or open an issue.", + DeprecationWarning, + stacklevel=1, + ) + self.permanent = permanent + self.sid_length = sid_length + self.has_same_site_capability = hasattr(self, "get_cookie_samesite") + self.cleanup_n_requests = cleanup_n_requests + + # Cleanup settings for non-TTL databases only + if getattr(self, "ttl", None) is False: + if self.cleanup_n_requests: + self.app.before_request(self._cleanup_n_requests) + else: + self._register_cleanup_app_command() + + # Set the serialization format + self.serializer = MsgSpecSerializer(format=serialization_format, app=app) + + # INTERNAL METHODS + + def _generate_sid(self, session_id_length: int) -> str: + """Generate a random session id.""" + return secrets.token_urlsafe(session_id_length) + + # TODO: Remove in 1.0.0 + def _get_signer(self, app: Flask) -> Signer: + 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") + + # TODO: Remove in 1.0.0 + def _unsign(self, app, sid: str) -> str: + signer = self._get_signer(app) + sid_as_bytes = signer.unsign(sid) + sid = sid_as_bytes.decode() + return sid + + # TODO: Remove in 1.0.0 + def _sign(self, app, sid: str) -> str: + signer = self._get_signer(app) + sid_as_bytes = want_bytes(sid) + return signer.sign(sid_as_bytes).decode("utf-8") + + def _get_store_id(self, sid: str) -> str: + return self.key_prefix + sid + + def should_set_storage(self, app: Flask, session: ServerSideSession) -> bool: + """Used by session backends to determine if session in storage + should be set for this session cookie for this response. If the session + has been modified, the session is set to storage. If + the ``SESSION_REFRESH_EACH_REQUEST`` config is true, the session is + always set to storage. In the second case, this means refreshing the + storage expiry even if the session has not been modified. + + .. versionadded:: 0.7.0 + """ + + return session.modified or app.config["SESSION_REFRESH_EACH_REQUEST"] + + # CLEANUP METHODS FOR NON TTL DATABASES + + def _register_cleanup_app_command(self): + """ + Register a custom Flask CLI command for cleaning up expired sessions. + + Run the command with `flask session_cleanup`. Run with a cron job + or scheduler such as Heroku Scheduler to automatically clean up expired sessions. + """ + + @self.app.cli.command("session_cleanup") + def session_cleanup(): + with self.app.app_context(): + self._delete_expired_sessions() + + def _cleanup_n_requests(self) -> None: + """ + Delete expired sessions on average every N requests. + + This is less desirable than using the scheduled app command cleanup as it may + slow down some requests but may be useful for rapid development. + """ + if self.cleanup_n_requests and random.randint(0, self.cleanup_n_requests) == 0: + self._delete_expired_sessions() + + # SECURITY API METHODS + + def regenerate(self, session: ServerSideSession) -> None: + """Regenerate the session id for the given session. Can be used by calling ``flask.session_interface.regenerate()``.""" + if session: + # Remove the old session from storage + self._delete_session(self._get_store_id(session.sid)) + # Generate a new session ID + new_sid = self._generate_sid(self.sid_length) + session.sid = new_sid + # Mark the session as modified to ensure it gets saved + session.modified = True + + # METHODS OVERRIDE FLASK SESSION INTERFACE + + def save_session( + self, app: Flask, session: ServerSideSession, response: Response + ) -> None: + + # Get the domain and path for the cookie from the app + domain = self.get_cookie_domain(app) + path = self.get_cookie_path(app) + name = self.get_cookie_name(app) + + # Generate a prefixed session id + store_id = self._get_store_id(session.sid) + + # Add a "Vary: Cookie" header if the session was accessed at all. + # This assumes the app is checking the session values in a request that + # behaves differently based on those values. ie. session.get("is_authenticated") + if session.accessed: + response.vary.add("Cookie") + + # 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._delete_session(store_id) + response.delete_cookie(key=name, domain=domain, path=path) + response.vary.add("Cookie") + return + + if not self.should_set_storage(app, session): + return + + # Update existing or create new session in the database + self._upsert_session(app.permanent_session_lifetime, session, store_id) + + if not self.should_set_cookie(app, session): + return + + # Get the additional required cookie settings + value = self._sign(app, session.sid) if self.use_signer else session.sid + expires = self.get_expiration_time(app, session) + httponly = self.get_cookie_httponly(app) + secure = self.get_cookie_secure(app) + samesite = ( + self.get_cookie_samesite(app) if self.has_same_site_capability else None + ) + + # Set the browser cookie + response.set_cookie( + key=name, + value=value, + expires=expires, + httponly=httponly, + domain=domain, + path=path, + secure=secure, + samesite=samesite, + ) + response.vary.add("Cookie") + + def open_session(self, app: Flask, request: Request) -> ServerSideSession: + # Get the session ID from the cookie + sid = request.cookies.get(app.config["SESSION_COOKIE_NAME"]) + + # If there's no session ID, generate a new one + if not sid: + sid = self._generate_sid(self.sid_length) + return self.session_class(sid=sid, permanent=self.permanent) + # If the session ID is signed, unsign it + 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) + + # Retrieve the session data from the database + store_id = self._get_store_id(sid) + saved_session_data = self._retrieve_session_data(store_id) + + # If the saved session exists, load the session data from the document + if saved_session_data is not None: + return self.session_class(saved_session_data, sid=sid) + + # If the saved session does not exist, create a new session + sid = self._generate_sid(self.sid_length) + return self.session_class(sid=sid, permanent=self.permanent) + + # METHODS TO BE IMPLEMENTED BY SUBCLASSES + + @abstractmethod + @retry_query() # use only when retry not supported directly by the client + def _retrieve_session_data(self, store_id: str) -> Optional[dict]: + """Get the saved session from the session storage.""" + raise NotImplementedError() + + @abstractmethod + @retry_query() # use only when retry not supported directly by the client + def _delete_session(self, store_id: str) -> None: + """Delete session from the session storage.""" + raise NotImplementedError() + + @abstractmethod + @retry_query() # use only when retry not supported directly by the client + def _upsert_session( + self, session_lifetime: TimeDelta, session: ServerSideSession, store_id: str + ) -> None: + """Update existing or create new session in the session storage.""" + raise NotImplementedError() + + @retry_query() # use only when retry not supported directly by the client + def _delete_expired_sessions(self) -> None: + """Delete expired sessions from the session storage. Only required for non-TTL databases.""" + pass diff --git a/src/flask_session/cachelib/__init__.py b/src/flask_session/cachelib/__init__.py new file mode 100644 index 00000000..60735222 --- /dev/null +++ b/src/flask_session/cachelib/__init__.py @@ -0,0 +1 @@ +from .cachelib import CacheLibSession, CacheLibSessionInterface # noqa: F401 diff --git a/src/flask_session/cachelib/cachelib.py b/src/flask_session/cachelib/cachelib.py new file mode 100644 index 00000000..16a48a49 --- /dev/null +++ b/src/flask_session/cachelib/cachelib.py @@ -0,0 +1,76 @@ +import warnings +from datetime import timedelta as TimeDelta +from typing import Optional + +from cachelib.file import FileSystemCache +from flask import Flask + +from .._utils import total_seconds +from ..base import ServerSideSession, ServerSideSessionInterface +from ..defaults import Defaults + + +class CacheLibSession(ServerSideSession): + pass + + +class CacheLibSessionInterface(ServerSideSessionInterface): + """Uses any :class:`cachelib` backend as a session storage. + + :param client: A :class:`cachelib` backend instance. + :param key_prefix: A prefix that is added to storage 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 serialization_format: The serialization format to use for the session data. + """ + + session_class = CacheLibSession + ttl = True + + def __init__( + self, + app: Flask = None, + client: Optional[FileSystemCache] = Defaults.SESSION_CACHELIB, + key_prefix: str = Defaults.SESSION_KEY_PREFIX, + use_signer: bool = Defaults.SESSION_USE_SIGNER, + permanent: bool = Defaults.SESSION_PERMANENT, + sid_length: int = Defaults.SESSION_ID_LENGTH, + serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT, + ): + + if client is None: + warnings.warn( + "No valid cachelib instance provided, attempting to create a new instance on localhost with default settings.", + RuntimeWarning, + stacklevel=1, + ) + client = FileSystemCache("flask_session", threshold=500) + + self.cache = client + + super().__init__( + None, key_prefix, use_signer, permanent, sid_length, serialization_format + ) + + def _retrieve_session_data(self, store_id: str) -> Optional[dict]: + # Get the saved session (item) from the database + return self.cache.get(store_id) + + def _delete_session(self, store_id: str) -> None: + self.cache.delete(store_id) + + def _upsert_session( + self, session_lifetime: TimeDelta, session: ServerSideSession, store_id: str + ) -> None: + storage_time_to_live = total_seconds(session_lifetime) + + # Serialize the session data (or just cast into dictionary in this case) + session_data = dict(session) + + # Update existing or create new session in the database + self.cache.set( + key=store_id, + value=session_data, + timeout=storage_time_to_live, + ) diff --git a/src/flask_session/defaults.py b/src/flask_session/defaults.py new file mode 100644 index 00000000..4823a851 --- /dev/null +++ b/src/flask_session/defaults.py @@ -0,0 +1,41 @@ +import os + + +class Defaults: + # Flask-session specific settings + SESSION_TYPE = "null" + SESSION_KEY_PREFIX = "session:" + SESSION_USE_SIGNER = False + SESSION_PERMANENT = True + SESSION_ID_LENGTH = 32 + SESSION_SERIALIZATION_FORMAT = "msgpack" + + # Clean up settings for non TTL backends (SQL, PostgreSQL, etc.) + SESSION_CLEANUP_N_REQUESTS = None + + # Redis settings + SESSION_REDIS = None + + # Memcached settings + SESSION_MEMCACHED = None + + # CacheLib settings + SESSION_CACHELIB = None + + # Filesystem settings + # TODO: remove in 1.0 + SESSION_FILE_DIR = os.path.join(os.getcwd(), "flask_session") + SESSION_FILE_THRESHOLD = 500 + SESSION_FILE_MODE = 384 + + # MongoDB settings + SESSION_MONGODB = None + SESSION_MONGODB_DB = "flask_session" + SESSION_MONGODB_COLLECT = "sessions" + + # SQLAlchemy settings + SESSION_SQLALCHEMY = None + SESSION_SQLALCHEMY_TABLE = "sessions" + SESSION_SQLALCHEMY_SEQUENCE = None + SESSION_SQLALCHEMY_SCHEMA = None + SESSION_SQLALCHEMY_BIND_KEY = None diff --git a/src/flask_session/filesystem/__init__.py b/src/flask_session/filesystem/__init__.py new file mode 100644 index 00000000..2285f25e --- /dev/null +++ b/src/flask_session/filesystem/__init__.py @@ -0,0 +1 @@ +from .filesystem import FileSystemSession, FileSystemSessionInterface # noqa: F401 diff --git a/src/flask_session/filesystem/filesystem.py b/src/flask_session/filesystem/filesystem.py new file mode 100644 index 00000000..dd98b379 --- /dev/null +++ b/src/flask_session/filesystem/filesystem.py @@ -0,0 +1,109 @@ +import warnings +from datetime import timedelta as TimeDelta +from typing import Optional + +from cachelib.file import FileSystemCache +from flask import Flask + +from .._utils import total_seconds +from ..base import ServerSideSession, ServerSideSessionInterface +from ..defaults import Defaults + + +class FileSystemSession(ServerSideSession): + pass + + +class FileSystemSessionInterface(ServerSideSessionInterface): + """Uses the :class:`cachelib.file.FileSystemCache` as a session storage. + + :param key_prefix: A prefix that is added to storage 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 serialization_format: The serialization format to use for the session data. + :param cache_dir: the directory where session files are stored. + :param threshold: the maximum number of items the session stores before it + :param mode: the file mode wanted for the session files, default 0600 + + .. versionadded:: 0.7 + The `serialization_format` and `app` parameters were added. + + .. versionadded:: 0.6 + The `sid_length` parameter was added. + + .. versionadded:: 0.2 + The `use_signer` parameter was added. + """ + + session_class = FileSystemSession + ttl = True + + def __init__( + self, + app: Flask, + key_prefix: str = Defaults.SESSION_KEY_PREFIX, + use_signer: bool = Defaults.SESSION_USE_SIGNER, + permanent: bool = Defaults.SESSION_PERMANENT, + sid_length: int = Defaults.SESSION_ID_LENGTH, + serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT, + cache_dir: str = Defaults.SESSION_FILE_DIR, + threshold: int = Defaults.SESSION_FILE_THRESHOLD, + mode: int = Defaults.SESSION_FILE_MODE, + ): + + # Deprecation warnings + if cache_dir != Defaults.SESSION_FILE_DIR: + warnings.warn( + "'SESSION_FILE_DIR' is deprecated and will be removed in a future release. Instead pass FileSystemCache(directory, threshold, mode) instance as SESSION_CACHELIB.", + DeprecationWarning, + stacklevel=1, + ) + if threshold != Defaults.SESSION_FILE_THRESHOLD: + warnings.warn( + "'SESSION_FILE_THRESHOLD' is deprecated and will be removed in a future release. Instead pass FileSystemCache(directory, threshold, mode) instance as SESSION_CLIENT.", + DeprecationWarning, + stacklevel=1, + ) + if mode != Defaults.SESSION_FILE_MODE: + warnings.warn( + "'SESSION_FILE_MODE' is deprecated and will be removed in a future release. Instead pass FileSystemCache(directory, threshold, mode) instance as SESSION_CLIENT.", + DeprecationWarning, + stacklevel=1, + ) + + warnings.warn( + "FileSystemSessionInterface is deprecated and will be removed in a future release. Instead use the CacheLib backend directly.", + DeprecationWarning, + stacklevel=1, + ) + + self.cache = FileSystemCache( + cache_dir=cache_dir, threshold=threshold, mode=mode + ) + + super().__init__( + app, key_prefix, use_signer, permanent, sid_length, serialization_format + ) + + def _retrieve_session_data(self, store_id: str) -> Optional[dict]: + # Get the saved session (item) from the database + return self.cache.get(store_id) + + def _delete_session(self, store_id: str) -> None: + self.cache.delete(store_id) + + def _upsert_session( + self, session_lifetime: TimeDelta, session: ServerSideSession, store_id: str + ) -> None: + storage_time_to_live = total_seconds(session_lifetime) + + # Serialize the session data (or just cast into dictionary in this case) + session_data = dict(session) + + # Update existing or create new session in the database + self.cache.set( + key=store_id, + value=session_data, + timeout=storage_time_to_live, + ) diff --git a/src/flask_session/memcached/__init__.py b/src/flask_session/memcached/__init__.py new file mode 100644 index 00000000..4a65c924 --- /dev/null +++ b/src/flask_session/memcached/__init__.py @@ -0,0 +1 @@ +from .memcached import MemcachedSession, MemcachedSessionInterface # noqa: F401 diff --git a/src/flask_session/memcached/memcached.py b/src/flask_session/memcached/memcached.py new file mode 100644 index 00000000..2a192d81 --- /dev/null +++ b/src/flask_session/memcached/memcached.py @@ -0,0 +1,122 @@ +import time +import warnings +from datetime import timedelta as TimeDelta +from typing import Any, Optional, Protocol + +from flask import Flask + +from .._utils import total_seconds +from ..base import ServerSideSession, ServerSideSessionInterface +from ..defaults import Defaults + + +class MemcacheClientProtocol(Protocol): + def get(self, key: str) -> Optional[Any]: ... + def set(self, key: str, value: Any, timeout: int) -> bool: ... + def delete(self, key: str) -> bool: ... + + +class MemcachedSession(ServerSideSession): + pass + + +class MemcachedSessionInterface(ServerSideSessionInterface): + """A Session interface that uses memcached as session storage. (`pylibmc`, `libmc`, `python-memcached` or `pymemcache` required) + + :param client: A ``memcache.Client`` instance. + :param key_prefix: A prefix that is added to all storage 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 serialization_format: The serialization format to use for the session data. + + .. versionadded:: 0.7 + The `serialization_format` and `app` parameters were added. + + .. versionadded:: 0.6 + The `sid_length` parameter was added. + + .. versionadded:: 0.2 + The `use_signer` parameter was added. + """ + + serializer = ServerSideSessionInterface.serializer + session_class = MemcachedSession + ttl = True + + def __init__( + self, + app: Flask, + client: Optional[MemcacheClientProtocol] = Defaults.SESSION_MEMCACHED, + key_prefix: str = Defaults.SESSION_KEY_PREFIX, + use_signer: bool = Defaults.SESSION_USE_SIGNER, + permanent: bool = Defaults.SESSION_PERMANENT, + sid_length: int = Defaults.SESSION_ID_LENGTH, + serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT, + ): + if client is None or not all( + hasattr(client, method) for method in ["get", "set", "delete"] + ): + warnings.warn( + "No valid memcache.Client instance provided, attempting to create a new instance on localhost with default settings.", + RuntimeWarning, + stacklevel=1, + ) + client = self._get_preferred_memcache_client() + self.client = client + super().__init__( + app, key_prefix, use_signer, permanent, sid_length, serialization_format + ) + + def _get_preferred_memcache_client(self): + clients = [ + ("pylibmc", ["127.0.0.1:11211"]), + ("memcache", ["127.0.0.1:11211"]), # python-memcached + ("pymemcache.client.base", "127.0.0.1:11211"), + ("libmc", ["localhost:11211"]), + ] + + 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: int) -> int: + """ + Memcached deals with long (> 30 days) timeouts in a special + way. Call this function to obtain a safe value for your timeout. + """ + if timeout > 2592000: # 60*60*24*30, 30 days + # Switch to absolute timestamps. + timeout += int(time.time()) + return timeout + + def _retrieve_session_data(self, store_id: str) -> Optional[dict]: + # Get the saved session (item) from the database + serialized_session_data = self.client.get(store_id) + if serialized_session_data: + return self.serializer.decode(serialized_session_data) + return None + + def _delete_session(self, store_id: str) -> None: + self.client.delete(store_id) + + def _upsert_session( + self, session_lifetime: TimeDelta, session: ServerSideSession, store_id: str + ) -> None: + storage_time_to_live = total_seconds(session_lifetime) + + # Serialize the session data + serialized_session_data = self.serializer.encode(session) + + # Update existing or create new session in the database + self.client.set( + store_id, + serialized_session_data, + self._get_memcache_timeout(storage_time_to_live), + ) diff --git a/src/flask_session/mongodb/__init__.py b/src/flask_session/mongodb/__init__.py new file mode 100644 index 00000000..f9aa01d4 --- /dev/null +++ b/src/flask_session/mongodb/__init__.py @@ -0,0 +1 @@ +from .mongodb import MongoDBSession, MongoDBSessionInterface # noqa: F401 diff --git a/src/flask_session/mongodb/mongodb.py b/src/flask_session/mongodb/mongodb.py new file mode 100644 index 00000000..60639fda --- /dev/null +++ b/src/flask_session/mongodb/mongodb.py @@ -0,0 +1,119 @@ +import warnings +from datetime import datetime +from datetime import timedelta as TimeDelta +from typing import Optional + +from flask import Flask +from itsdangerous import want_bytes +from pymongo import MongoClient, version + +from ..base import ServerSideSession, ServerSideSessionInterface +from ..defaults import Defaults + + +class MongoDBSession(ServerSideSession): + pass + + +class MongoDBSessionInterface(ServerSideSessionInterface): + """A Session interface that uses mongodb as session storage. (`pymongo` required) + + :param client: A ``pymongo.MongoClient`` instance. + :param key_prefix: A prefix that is added to all storage 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 serialization_format: The serialization format to use for the session data. + :param db: The database to use. + :param collection: The collection to use. + + .. versionadded:: 0.7 + The `serialization_format` and `app` parameters were added. + + .. versionadded:: 0.6 + The `sid_length` parameter was added. + + .. versionadded:: 0.2 + The `use_signer` parameter was added. + """ + + session_class = MongoDBSession + ttl = True + + def __init__( + self, + app: Flask, + client: Optional[MongoClient] = Defaults.SESSION_MONGODB, + key_prefix: str = Defaults.SESSION_KEY_PREFIX, + use_signer: bool = Defaults.SESSION_USE_SIGNER, + permanent: bool = Defaults.SESSION_PERMANENT, + sid_length: int = Defaults.SESSION_ID_LENGTH, + serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT, + db: str = Defaults.SESSION_MONGODB_DB, + collection: str = Defaults.SESSION_MONGODB_COLLECT, + ): + + if client is None or not isinstance(client, MongoClient): + warnings.warn( + "No valid MongoClient instance provided, attempting to create a new instance on localhost with default settings.", + RuntimeWarning, + stacklevel=1, + ) + client = MongoClient() + + self.client = client + self.store = client[db][collection] + self.use_deprecated_method = int(version.split(".")[0]) < 4 + + # 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, serialization_format + ) + + def _retrieve_session_data(self, store_id: str) -> Optional[dict]: + # Get the saved session (document) from the database + document = self.store.find_one({"id": store_id}) + if document: + serialized_session_data = want_bytes(document["val"]) + return self.serializer.decode(serialized_session_data) + return None + + def _delete_session(self, store_id: str) -> None: + if self.use_deprecated_method: + self.store.remove({"id": store_id}) + else: + self.store.delete_one({"id": store_id}) + + def _upsert_session( + self, session_lifetime: TimeDelta, session: ServerSideSession, store_id: str + ) -> None: + storage_expiration_datetime = datetime.utcnow() + session_lifetime + + # Serialize the session data + serialized_session_data = self.serializer.encode(session) + + # Update existing or create new session in the database + if self.use_deprecated_method: + self.store.update( + {"id": store_id}, + { + "id": store_id, + "val": serialized_session_data, + "expiration": storage_expiration_datetime, + }, + True, + ) + else: + self.store.update_one( + {"id": store_id}, + { + "$set": { + "id": store_id, + "val": serialized_session_data, + "expiration": storage_expiration_datetime, + } + }, + True, + ) diff --git a/src/flask_session/redis/__init__.py b/src/flask_session/redis/__init__.py new file mode 100644 index 00000000..65b6323a --- /dev/null +++ b/src/flask_session/redis/__init__.py @@ -0,0 +1 @@ +from .redis import RedisSession, RedisSessionInterface # noqa: F401 diff --git a/src/flask_session/redis/redis.py b/src/flask_session/redis/redis.py new file mode 100644 index 00000000..320c6566 --- /dev/null +++ b/src/flask_session/redis/redis.py @@ -0,0 +1,85 @@ +import warnings +from datetime import timedelta as TimeDelta +from typing import Optional + +from flask import Flask +from redis import Redis + +from .._utils import total_seconds +from ..base import ServerSideSession, ServerSideSessionInterface +from ..defaults import Defaults + + +class RedisSession(ServerSideSession): + pass + + +class RedisSessionInterface(ServerSideSessionInterface): + """Uses the Redis key-value store as a session storage. (`redis-py` required) + + :param client: A ``redis.Redis`` instance. + :param key_prefix: A prefix that is added to all storage 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 serialization_format: The serialization format to use for the session data. + + .. versionadded:: 0.7 + The `serialization_format` and `app` parameters were added. + + .. versionadded:: 0.6 + The `sid_length` parameter was added. + + .. versionadded:: 0.2 + The `use_signer` parameter was added. + """ + + session_class = RedisSession + ttl = True + + def __init__( + self, + app: Flask, + client: Optional[Redis] = Defaults.SESSION_REDIS, + key_prefix: str = Defaults.SESSION_KEY_PREFIX, + use_signer: bool = Defaults.SESSION_USE_SIGNER, + permanent: bool = Defaults.SESSION_PERMANENT, + sid_length: int = Defaults.SESSION_ID_LENGTH, + serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT, + ): + if client is None or not isinstance(client, Redis): + warnings.warn( + "No valid Redis instance provided, attempting to create a new instance on localhost with default settings.", + RuntimeWarning, + stacklevel=1, + ) + client = Redis() + self.client = client + super().__init__( + app, key_prefix, use_signer, permanent, sid_length, serialization_format + ) + + def _retrieve_session_data(self, store_id: str) -> Optional[dict]: + # Get the saved session (value) from the database + serialized_session_data = self.client.get(store_id) + if serialized_session_data: + return self.serializer.decode(serialized_session_data) + return None + + def _delete_session(self, store_id: str) -> None: + self.client.delete(store_id) + + def _upsert_session( + self, session_lifetime: TimeDelta, session: ServerSideSession, store_id: str + ) -> None: + storage_time_to_live = total_seconds(session_lifetime) + + # Serialize the session data + serialized_session_data = self.serializer.encode(session) + + # Update existing or create new session in the database + self.client.set( + name=store_id, + value=serialized_session_data, + ex=storage_time_to_live, + ) diff --git a/src/flask_session/sessions.py b/src/flask_session/sessions.py deleted file mode 100644 index 19597491..00000000 --- a/src/flask_session/sessions.py +++ /dev/null @@ -1,697 +0,0 @@ -import secrets -import time -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 - - -def total_seconds(td): - return td.days * 60 * 60 * 24 + td.seconds - - -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: - self.permanent = permanent - self.modified = False - - -class RedisSession(ServerSideSession): - pass - - -class MemcachedSession(ServerSideSession): - pass - - -class FileSystemSession(ServerSideSession): - pass - - -class MongoDBSession(ServerSideSession): - pass - - -class SqlAlchemySession(ServerSideSession): - pass - - -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 _unsign(self, app, sid): - signer = self.__get_signer(app) - sid_as_bytes = signer.unsign(sid) - sid = sid_as_bytes.decode() - return sid - - 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 ServerSideSessionInterface(SessionInterface, ABC): - """Used to open a :class:`flask.sessions.ServerSideSessionInterface` instance.""" - - 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, permanent, sid_length): - if redis is None: - from redis import Redis - - redis = Redis() - self.redis = redis - super().__init__(redis, key_prefix, use_signer, permanent, sid_length) - - 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 the saved session still exists and hasn't auto-expired, load the session data from the document - if value is not None: - try: - 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 - ) - return - - # 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 - 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) - - -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, permanent, sid_length): - if client is None: - client = self._get_preferred_memcache_client() - self.client = client - super().__init__(client, key_prefix, use_signer, permanent, sid_length) - - def _get_preferred_memcache_client(self): - clients = [ - ("pylibmc", ["127.0.0.1:11211"]), - ("memcache", ["127.0.0.1:11211"]), - ("pymemcache.client.base", "127.0.0.1:11211"), - ] - - 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): - """ - Memcached deals with long (> 30 days) timeouts in a special - way. Call this function to obtain a safe value for your timeout. - """ - if timeout > 2592000: # 60*60*24*30, 30 days - # Switch to absolute timestamps. - timeout += int(time.time()) - return timeout - - 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) - - # If the saved session still exists and hasn't auto-expired, load the session data from the document - if item is not None: - try: - 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) - - # 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(prefixed_session_id) - response.delete_cookie( - app.config["SESSION_COOKIE_NAME"], domain=domain, path=path - ) - return - - # 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 - self.client.set( - prefixed_session_id, - serialized_session_data, - self._get_memcache_timeout(total_seconds(app.permanent_session_lifetime)), - ) - - # 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 - starts deleting some. - :param mode: the file mode wanted for the session files, default 0600 - :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, - permanent, - sid_length, - ): - from cachelib.file import FileSystemCache - - self.cache = FileSystemCache(cache_dir, threshold=threshold, mode=mode) - super().__init__(self.cache, key_prefix, use_signer, permanent, sid_length) - - 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) - - # 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(prefixed_session_id) - response.delete_cookie( - app.config["SESSION_COOKIE_NAME"], domain=domain, path=path - ) - return - - # 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) - - # Update existing or create new session in the database - self.cache.set( - prefixed_session_id, - session_data, - total_seconds(app.permanent_session_lifetime), - ) - - # 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. - :param collection: The collection you want to use. - :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, - permanent, - sid_length, - ): - import pymongo - - if client is None: - client = pymongo.MongoClient() - - self.client = client - self.store = client[db][collection] - self.use_deprecated_method = int(pymongo.version.split(".")[0]) < 4 - super().__init__(self.store, key_prefix, use_signer, permanent, sid_length) - - 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}) - - # 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: - 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) - - # 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: - 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 - - # 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: - 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. - - :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, - 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.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 - - 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) - - def __init__(self, session_id, data, expiry): - self.session_id = session_id - self.data = data - self.expiry = expiry - - def __repr__(self): - return "" % self.data - - with app.app_context(): - self.db.create_all() - - self.sql_session_model = Session - - def fetch_session(self, sid): - # Get the saved session (record) from the database - store_id = self.key_prefix + sid - 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: - 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) - - # 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: - 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 - - # 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: - 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/src/flask_session/sqlalchemy/__init__.py b/src/flask_session/sqlalchemy/__init__.py new file mode 100644 index 00000000..41e033a6 --- /dev/null +++ b/src/flask_session/sqlalchemy/__init__.py @@ -0,0 +1 @@ +from .sqlalchemy import SqlAlchemySession, SqlAlchemySessionInterface # noqa: F401 diff --git a/src/flask_session/sqlalchemy/sqlalchemy.py b/src/flask_session/sqlalchemy/sqlalchemy.py new file mode 100644 index 00000000..4841c84b --- /dev/null +++ b/src/flask_session/sqlalchemy/sqlalchemy.py @@ -0,0 +1,189 @@ +import warnings +from datetime import datetime +from datetime import timedelta as TimeDelta +from typing import Any, Optional + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from itsdangerous import want_bytes +from sqlalchemy import Column, DateTime, Integer, LargeBinary, Sequence, String + +from .._utils import retry_query +from ..base import ServerSideSession, ServerSideSessionInterface +from ..defaults import Defaults + + +class SqlAlchemySession(ServerSideSession): + pass + + +def create_session_model(db, table_name, schema=None, bind_key=None, sequence=None): + class Session(db.Model): + __tablename__ = table_name + __table_args__ = {"schema": schema} if schema else {} + __bind_key__ = bind_key + + id = ( + Column(Integer, Sequence(sequence), primary_key=True) + if sequence + else Column(Integer, primary_key=True) + ) + session_id = Column(String(255), unique=True) + data = Column(LargeBinary) + expiry = Column(DateTime) + + def __init__(self, session_id: str, data: Any, expiry: datetime): + self.session_id = session_id + self.data = data + self.expiry = expiry + + def __repr__(self): + return f"" + + return Session + + +class SqlAlchemySessionInterface(ServerSideSessionInterface): + """Uses the Flask-SQLAlchemy from a flask app as session storage. + + :param app: A Flask app instance. + :param client: A Flask-SQLAlchemy instance. + :param key_prefix: A prefix that is added to all storage 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 serialization_format: The serialization format to use for the session data. + :param table: The table name you want to use. + :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. + :param cleanup_n_requests: Delete expired sessions on average every N requests. + + .. versionadded:: 0.7 + db changed to client to be standard on all session interfaces. + The `cleanup_n_request` parameter was added. + + .. versionadded:: 0.6 + The `sid_length`, `sequence`, `schema` and `bind_key` parameters were added. + + .. versionadded:: 0.2 + The `use_signer` parameter was added. + """ + + session_class = SqlAlchemySession + ttl = False + + def __init__( + self, + app: Optional[Flask], + client: Optional[SQLAlchemy] = Defaults.SESSION_SQLALCHEMY, + key_prefix: str = Defaults.SESSION_KEY_PREFIX, + use_signer: bool = Defaults.SESSION_USE_SIGNER, + permanent: bool = Defaults.SESSION_PERMANENT, + sid_length: int = Defaults.SESSION_ID_LENGTH, + serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT, + table: str = Defaults.SESSION_SQLALCHEMY_TABLE, + sequence: Optional[str] = Defaults.SESSION_SQLALCHEMY_SEQUENCE, + schema: Optional[str] = Defaults.SESSION_SQLALCHEMY_SCHEMA, + bind_key: Optional[str] = Defaults.SESSION_SQLALCHEMY_BIND_KEY, + cleanup_n_requests: Optional[int] = Defaults.SESSION_CLEANUP_N_REQUESTS, + ): + self.app = app + + if client is None or not isinstance(client, SQLAlchemy): + warnings.warn( + "No valid SQLAlchemy instance provided, attempting to create a new instance on localhost with default settings.", + RuntimeWarning, + stacklevel=1, + ) + client = SQLAlchemy(app) + self.client = client + + # Create the session model + self.sql_session_model = create_session_model( + client, table, schema, bind_key, sequence + ) + # Create the table if it does not exist + with app.app_context(): + if bind_key: + engine = self.client.get_engine(app, bind=bind_key) + else: + engine = self.client.engine + self.sql_session_model.__table__.create(bind=engine, checkfirst=True) + + super().__init__( + app, + key_prefix, + use_signer, + permanent, + sid_length, + serialization_format, + cleanup_n_requests, + ) + + @retry_query() + def _delete_expired_sessions(self) -> None: + try: + self.client.session.query(self.sql_session_model).filter( + self.sql_session_model.expiry <= datetime.utcnow() + ).delete(synchronize_session=False) + self.client.session.commit() + except Exception: + self.client.session.rollback() + raise + + @retry_query() + def _retrieve_session_data(self, store_id: str) -> Optional[dict]: + # Get the saved session (record) from the database + record = self.sql_session_model.query.filter_by(session_id=store_id).first() + + # "Delete the session record if it is expired as SQL has no TTL ability + if record and (record.expiry is None or record.expiry <= datetime.utcnow()): + try: + self.client.session.delete(record) + self.client.session.commit() + except Exception: + self.client.session.rollback() + raise + record = None + + if record: + serialized_session_data = want_bytes(record.data) + return self.serializer.decode(serialized_session_data) + return None + + @retry_query() + def _delete_session(self, store_id: str) -> None: + try: + self.sql_session_model.query.filter_by(session_id=store_id).delete() + self.client.session.commit() + except Exception: + self.client.session.rollback() + raise + + @retry_query() + def _upsert_session( + self, session_lifetime: TimeDelta, session: ServerSideSession, store_id: str + ) -> None: + storage_expiration_datetime = datetime.utcnow() + session_lifetime + + # Serialize session data + serialized_session_data = self.serializer.encode(session) + + # Update existing or create new session in the database + try: + record = self.sql_session_model.query.filter_by(session_id=store_id).first() + if record: + record.data = serialized_session_data + record.expiry = storage_expiration_datetime + else: + record = self.sql_session_model( + session_id=store_id, + data=serialized_session_data, + expiry=storage_expiration_datetime, + ) + self.client.session.add(record) + self.client.session.commit() + except Exception: + self.client.session.rollback() + raise diff --git a/tests/conftest.py b/tests/conftest.py index 5f2e570e..8ab8425c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,12 @@ def create_app(self, config_dict=None): app = flask.Flask(__name__) if config_dict: app.config.update(config_dict) + app.config["SESSION_SERIALIZATION_FORMAT"] = "json" + app.config["SESSION_PERMANENT"] = False + + @app.route("/", methods=["GET"]) + def app_hello(): + return "hello world" @app.route("/set", methods=["POST"]) def app_set(): @@ -31,35 +37,46 @@ def app_del(): @app.route("/get") def app_get(): - return flask.session.get("value") + return flask.session.get("value", "no value set") flask_session.Session(app) return app - def test_session_set(self, app): + def test_session(self, app): client = app.test_client() + + # Check no value is set from previous tests + assert client.get("/get").data not in [b"42", b"43", b"44"] + + # Check if the Vary header is not set + rv = client.get("/") + assert "Vary" not in rv.headers + + # Set a value and check it 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" + # Check if the Vary header is set + rv = client.get("/get") + assert rv.headers["Vary"] == "Cookie" + + # Modify and delete the value assert client.post("/modify", data={"value": "43"}).data == b"value set" assert client.get("/get").data == b"43" + assert client.post("/delete").data == b"value deleted" + assert client.get("/get").data == b"no value set" - def test_session_delete(self, app): + def test_session_with_cookie(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] + # Access cookies from the response to cross check with the stored session + response = client.post("/set", data={"value": "44"}) + session_cookie = None + for cookie in response.headers.getlist("Set-Cookie"): + if "session=" in cookie: + session_cookie = cookie + break + assert session_cookie is not None, "Session cookie was not set." + return session_cookie return Utils() diff --git a/tests/test_basic.py b/tests/test_basic.py index 4596c97c..1a904266 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -3,22 +3,9 @@ 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"]) + with pytest.raises(ValueError): + app = flask.Flask(__name__) + app.secret_key = "alsdkfjaldkjsf" + flask_session.Session(app) diff --git a/tests/test_cachelib.py b/tests/test_cachelib.py new file mode 100644 index 00000000..eda9bbb1 --- /dev/null +++ b/tests/test_cachelib.py @@ -0,0 +1,48 @@ +import os +import shutil +from contextlib import contextmanager + +import flask +from cachelib.file import FileSystemCache +from flask_session.cachelib import CacheLibSession + + +class TestCachelibSession: + session_dir = "testing_session_storage" + + @contextmanager + def setup_filesystem(self): + try: + yield + finally: + pass + if self.session_dir and os.path.isdir(self.session_dir): + shutil.rmtree(self.session_dir) + + def retrieve_stored_session(self, key, app): + return app.session_interface.cache.get(key) + + def test_filesystem_default(self, app_utils): + app = app_utils.create_app( + { + "SESSION_TYPE": "cachelib", + "SESSION_SERIALIZATION_FORMAT": "json", + "SESSION_CACHELIB": FileSystemCache( + threshold=500, cache_dir=self.session_dir + ), + } + ) + + # Should be using CacheLib (FileSystem) + with self.setup_filesystem(), app.test_request_context(): + assert isinstance( + flask.session, + CacheLibSession, + ) + app_utils.test_session(app) + + # Check if the session is stored in the filesystem + cookie = app_utils.test_session_with_cookie(app) + session_id = cookie.split(";")[0].split("=")[1] + stored_session = self.retrieve_stored_session(f"session:{session_id}", app) + assert stored_session.get("value") == "44" diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py index 939b2128..132eea28 100644 --- a/tests/test_filesystem.py +++ b/tests/test_filesystem.py @@ -1,19 +1,44 @@ -import tempfile +import os +import shutil +from contextlib import contextmanager import flask -import flask_session +from flask_session.filesystem import FileSystemSession -class TestFileSystem: - def setup_method(self, _): - pass +class TestFileSystemSession: + session_dir = "testing_session_storage" - def test_basic(self, app_utils): + @contextmanager + def setup_filesystem(self): + try: + yield + finally: + pass + if self.session_dir and os.path.isdir(self.session_dir): + shutil.rmtree(self.session_dir) + + def retrieve_stored_session(self, key, app): + return app.session_interface.cache.get(key) + + def test_filesystem_default(self, app_utils): app = app_utils.create_app( - {"SESSION_TYPE": "filesystem", "SESSION_FILE_DIR": tempfile.gettempdir()} + { + "SESSION_TYPE": "filesystem", + "SESSION_FILE_DIR": self.session_dir, + } ) - app_utils.test_session_set(app) - # Should be using FileSystem class - with app.test_request_context(): - isinstance(flask.session, flask_session.sessions.FileSystemSession) + # Should be using FileSystem + with self.setup_filesystem(), app.test_request_context(): + assert isinstance( + flask.session, + FileSystemSession, + ) + app_utils.test_session(app) + + # Check if the session is stored in the filesystem + cookie = app_utils.test_session_with_cookie(app) + session_id = cookie.split(";")[0].split("=")[1] + stored_session = self.retrieve_stored_session(f"session:{session_id}", app) + assert stored_session.get("value") == "44" diff --git a/tests/test_memcached.py b/tests/test_memcached.py index aabbd7ff..c4bb1aa6 100644 --- a/tests/test_memcached.py +++ b/tests/test_memcached.py @@ -1,17 +1,45 @@ +import json +from contextlib import contextmanager + import flask -import flask_session +import memcache # Import the memcache library +from flask_session.memcached import MemcachedSession + + +class TestMemcachedSession: + """This requires package: python-memcached""" + @contextmanager + def setup_memcached(self): + self.mc = memcache.Client(["127.0.0.1:11211"], debug=0) + try: + self.mc.flush_all() + yield + finally: + self.mc.flush_all() + # Memcached connections are pooled, no close needed -class TestMemcached: - """This requires package: memcached - This needs to be running before test runs - """ + def retrieve_stored_session(self, key): + return self.mc.get(key) - def test_basic(self, app_utils): - app = app_utils.create_app({"SESSION_TYPE": "memcached"}) + def test_memcached_default(self, app_utils): + with self.setup_memcached(): + app = app_utils.create_app( + {"SESSION_TYPE": "memcached", "SESSION_MEMCACHED": self.mc} + ) - # Should be using Memecached - with app.test_request_context(): - isinstance(flask.session, flask_session.sessions.MemcachedSessionInterface) + with app.test_request_context(): + assert isinstance( + flask.session, + MemcachedSession, + ) + app_utils.test_session(app) - app_utils.test_session_set(app) + # Check if the session is stored in Memcached + cookie = app_utils.test_session_with_cookie(app) + session_id = cookie.split(";")[0].split("=")[1] + byte_string = self.retrieve_stored_session(f"session:{session_id}") + stored_session = ( + json.loads(byte_string.decode("utf-8")) if byte_string else {} + ) + assert stored_session.get("value") == "44" diff --git a/tests/test_mongodb.py b/tests/test_mongodb.py index 15d1319a..0e3638be 100644 --- a/tests/test_mongodb.py +++ b/tests/test_mongodb.py @@ -1,15 +1,49 @@ +import json +from contextlib import contextmanager + import flask -import flask_session +from flask_session.mongodb import MongoDBSession +from itsdangerous import want_bytes +from pymongo import MongoClient + + +class TestMongoSession: + """This requires package: pymongo""" + + @contextmanager + def setup_mongo(self): + self.client = MongoClient() + self.db = self.client.flask_session + self.collection = self.db.sessions + try: + self.collection.delete_many({}) + yield + finally: + self.collection.delete_many({}) + self.client.close() + def retrieve_stored_session(self, key): + document = self.collection.find_one({"id": key}) + return want_bytes(document["val"]) -class TestMongoDB: - def test_basic(self, app_utils): - app = app_utils.create_app({"SESSION_TYPE": "mongodb"}) + def test_mongo_default(self, app_utils): + with self.setup_mongo(): + app = app_utils.create_app( + { + "SESSION_TYPE": "mongodb", + "SESSION_MONGODB": self.client, + } + ) - # Should be using MongoDB - with app.test_request_context(): - isinstance(flask.session, flask_session.sessions.MongoDBSession) + with app.test_request_context(): + assert isinstance(flask.session, MongoDBSession) + app_utils.test_session(app) - # TODO: Need to test with mongodb service running, once - # that is available, then we can call - # app_utils.test_session_set + # Check if the session is stored in MongoDB + cookie = app_utils.test_session_with_cookie(app) + session_id = cookie.split(";")[0].split("=")[1] + byte_string = self.retrieve_stored_session(f"session:{session_id}") + stored_session = ( + json.loads(byte_string.decode("utf-8")) if byte_string else {} + ) + assert stored_session.get("value") == "44" diff --git a/tests/test_redis.py b/tests/test_redis.py index 63d543b5..7c59964b 100644 --- a/tests/test_redis.py +++ b/tests/test_redis.py @@ -1,68 +1,42 @@ +import json +from contextlib import contextmanager + import flask -import flask_session +from flask_session.redis import RedisSession 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) + """This requires package: redis""" - # There should not be a session: object - assert not self._has_redis_prefix(b"session:") + @contextmanager + def setup_redis(self): + self.r = Redis() + try: + self.r.flushall() + yield + finally: + self.r.flushall() + self.r.close() - 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) + def retrieve_stored_session(self, key): + return self.r.get(key) - # 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) + def test_redis_default(self, app_utils): + with self.setup_redis(): + app = app_utils.create_app( + {"SESSION_TYPE": "redis", "SESSION_REDIS": self.r} + ) + + with app.test_request_context(): + assert isinstance(flask.session, RedisSession) + app_utils.test_session(app) + + # Check if the session is stored in Redis + cookie = app_utils.test_session_with_cookie(app) + session_id = cookie.split(";")[0].split("=")[1] + byte_string = self.retrieve_stored_session(f"session:{session_id}") + stored_session = ( + json.loads(byte_string.decode("utf-8")) if byte_string else {} + ) + assert stored_session.get("value") == "44" diff --git a/tests/test_sqlalchemy.py b/tests/test_sqlalchemy.py index 4891a42b..e8c34b9e 100644 --- a/tests/test_sqlalchemy.py +++ b/tests/test_sqlalchemy.py @@ -1,31 +1,59 @@ +import json +from contextlib import contextmanager + import flask -import flask_session +import pytest +from flask_session.sqlalchemy import SqlAlchemySession +from sqlalchemy import text class TestSQLAlchemy: - def test_basic(self, app_utils): - app = app_utils.create_app( - {"SESSION_TYPE": "sqlalchemy", "SQLALCHEMY_DATABASE_URI": "sqlite:///"} - ) + """This requires package: sqlalchemy""" - # Should be using SqlAlchemy - with app.test_request_context(): - isinstance(flask.session, flask_session.sessions.SqlAlchemySession) - app.session_interface.db.create_all() + @contextmanager + def setup_sqlalchemy(self, app): + try: + app.session_interface.client.session.execute(text("DELETE FROM sessions")) + app.session_interface.client.session.commit() + yield + finally: + app.session_interface.client.session.execute(text("DELETE FROM sessions")) + app.session_interface.client.session.close() - app_utils.test_session_set(app) - app_utils.test_session_modify(app) + def retrieve_stored_session(self, key, app): + session_model = ( + app.session_interface.client.session.query( + app.session_interface.sql_session_model + ) + .filter_by(session_id=key) + .first() + ) + if session_model: + return session_model.data + return None + @pytest.mark.filterwarnings("ignore:No valid SQLAlchemy instance provided") 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.app_context() and self.setup_sqlalchemy( + app + ) and app.test_request_context(): + assert isinstance( + flask.session, + SqlAlchemySession, + ) + app_utils.test_session(app) - with app.test_request_context(): - app.session_interface.db.create_all() - - app_utils.test_session_set(app) + # Check if the session is stored in SQLAlchemy + cookie = app_utils.test_session_with_cookie(app) + session_id = cookie.split(";")[0].split("=")[1] + byte_string = self.retrieve_stored_session(f"session:{session_id}", app) + stored_session = ( + json.loads(byte_string.decode("utf-8")) if byte_string else {} + ) + assert stored_session.get("value") == "44"