44 Commits

Author SHA1 Message Date
daniel156161 ce173e43c7 install uv into project 2025-10-31 13:17:55 +01:00
daniel156161 b3105423f8 fix that it can set the responce cookie correctly for newer werkzeug version 2025-09-18 18:16:18 +02:00
Kroket Ltd 249a7abd89 Bump to 3.0.0 2023-11-24 04:12:03 +02:00
Kroket Ltd 093d28fe62 Merge pull request #19 from jonathonfletcher/master
do not unpack initial in call to ServerSideSession's super.
2023-11-24 04:07:24 +02:00
Jonathon Fletcher 7e43c76ad0 do not unpack initial in call to ServerSideSession's super. 2023-11-04 11:50:46 -07:00
Kroket Ltd c25a62412d Merge pull request #16 from kroketio/session_cookie_name_fix
Session cookie name fix
2023-10-17 01:42:31 +03:00
Kroket Ltd 9186fae9e7 bump version to 2.1.0 2023-10-17 01:41:24 +03:00
Kroket Ltd 31a56ddd47 session_cookie_name fix due to https://github.com/pallets/flask/pull/4995/files 2023-10-17 01:40:11 +03:00
Sander 6ad2a3789d Update CHANGELOG, bump version 2023-01-16 19:31:09 +02:00
Sander d08156f193 Merge pull request #12 from degenhard/master
Change for python 3.11 aioredis to redis.
2023-01-16 12:17:46 +02:00
Peter Heise 39e5fc5c13 Updated setup.py redis requirement. 2023-01-16 10:16:33 +01:00
Peter Heise 237f2354ad Changed redis package in readme. 2023-01-15 21:42:18 +01:00
Peter Heise 5119f60434 Changed aioredis import for python 3.11 2023-01-15 21:38:47 +01:00
Kroket Ltd 2f53caa654 Bump version 2022-11-24 17:14:14 +01:00
Sander cc99a72901 Merge pull request #11 from adrienyhuel/master
Fix MemcachedSession
2022-11-24 17:09:45 +01:00
Adrien YHUEL 5301b4418f Fix MemcachedSession
Remove asyncio loop argument
Remove asyncio.coroutine annotation as it is removed in Python 3.11
2022-11-24 15:10:48 +01:00
Kroket Ltd 3e6b102b34 Transfer copyright, bump version 2022-11-13 03:36:15 +02:00
Sander 65b44db7df Merge pull request #10 from smithk86/mongodb
add session handler for mongodb: MongoDBSessionInterface
2022-03-20 12:55:20 +02:00
Kyle Smith b9f2dc0067 add session handler for mongodb: MongoDBSessionInterface 2022-03-17 15:51:45 -04:00
Sander a19f227d88 Merge pull request #9 from sanderfoobar/self-cfg-update-version
dont use app.config, bump version
2022-03-10 15:01:28 +02:00
Sander 53a82fda3f Bump version 2022-03-10 15:00:32 +02:00
Sander a82799d358 use self._config instead of app.config 2022-03-10 14:59:32 +02:00
Sander 80f39ec79b Merge pull request #8 from sanderfoobar/session-uri-docs
Change README to support new config option `SESSION_URI`
2022-03-10 14:55:03 +02:00
Sander 9d02429aee Change README to support new config option SESSION_URI 2022-03-10 14:54:41 +02:00
Sander 13fcc653c6 Merge pull request #7 from rubikscuber/patch-1
respect custom redis uri
2022-03-10 14:52:40 +02:00
rubikscuber 16fb57d62a add SESSION_URI example 2022-03-10 13:51:13 +01:00
rubikscuber 43956f045f Update sessions.py 2022-03-10 13:45:28 +01:00
rubikscuber cdd237b5f9 respect custom redis uri
enable usage of app.config["REDIS_URI"]
2022-03-10 11:28:03 +01:00
sander 38bf10d3d2 Bump version to 1.0.3 2021-08-31 22:41:47 +02:00
Sander cb9c6339e5 Merge pull request #6 from adrienyhuel/master
Make quart-session compatible with aioredis 2.0
2021-08-31 21:22:06 +02:00
Adrien YHUEL 264a9ad369 Make quart-session compatible with aioredis 2.0 2021-08-31 04:33:41 +02:00
sferdi0 ff41a72222 Merge pull request #5 from filak/master
SameSite support and Memcached config
2021-05-19 22:44:21 +02:00
filak 8daae3a673 SameSite support 2021-05-12 19:22:17 +02:00
filakx 004871c495 Memcached config 2021-04-20 23:13:20 +02:00
Sander 6d9ebbb264 Correctly bump version 2020-12-09 15:39:42 +01:00
Sander e136ab0101 Actually implement a working NullSessionInterface, fixes pgjones/quart#105, bump version 2020-12-09 15:39:42 +01:00
Sander 51668878df Release 1.0.0
- Added support for arbitrary usage of caching backends.
    - Exposed `get`, `set`, `delete` on the session interface for direct usage.
- Renamed `SESSION_HIJACK_REVERSE_PROXY` to `SESSION_REVERSE_PROXY`.
- Renamed `SESSION_HIJACK_PROTECTION` to `SESSION_PROTECTION`.
- Removed fallback when `X-Forwarded-For` is not present whilst USING `SESSION_REVERSE_PROXY`, emit error instead.
- Fixed a bug where session timeouts would default to 600 seconds.
- Deprecated/disabled the `dirty()` method.
2020-12-09 15:39:42 +01:00
Sander 45b8147b0a Add link to Quart repository on Gitlab 2020-12-09 15:39:42 +01:00
Sander b52e896ef0 Refactor addr 2020-12-09 15:39:42 +01:00
Sander 4012836eee Update example 2020-12-09 15:39:42 +01:00
Sander ad2a1db6c5 Release 0.0.1 2020-12-09 15:39:42 +01:00
Sander f05c49105e Update README 2020-12-09 15:39:42 +01:00
Sander 0f9ba5052c Redis+Trio support 2020-12-09 15:39:42 +01:00
Sander fb5678afbb Initial commit 2020-12-09 15:39:42 +01:00
9 changed files with 409 additions and 196 deletions
+36
View File
@@ -0,0 +1,36 @@
### 2.1.0
- `session_cookie_name` fix
### 2.0.0
- Move from aioredis to redis
### 1.0.7
- Updated memcached support (removed asyncio.loop, removed coroutine decorator)
### 1.0.6
- MongoDB support
- Transfer copyright to Kroket Ltd.
### 1.0.3 2021-08-31
- Migrated to aioredis 2
- SameSite support https://github.com/kroketio/quart-session/commit/8daae3a6734e8f7da13954d5a1a5da8f5fc5a49a
- Memcached stuff https://github.com/filak/quart-session/commit/004871c495a069784e57e604b69f65af1b7e645a
### 1.0.0 2020-01-15
- Added support for arbitrary usage of caching backends.
- Exposed `get`, `set`, `delete` on the session interface for direct usage.
- Renamed `SESSION_HIJACK_REVERSE_PROXY` to `SESSION_REVERSE_PROXY`.
- Renamed `SESSION_HIJACK_PROTECTION` to `SESSION_PROTECTION`.
- Removed fallback when `X-Forwarded-For` is not present whilst USING `SESSION_REVERSE_PROXY`, emit error instead.
- Fixed a bug where session timeouts would default to 600 seconds.
- Deprecated/disabled the `dirty()` method.
### 0.0.1 2020-01-04
- Released initial pre alpha version.
+1 -1
View File
@@ -1,5 +1,5 @@
Copyright (c) 2014 by Shipeng Feng. Copyright (c) 2014 by Shipeng Feng.
Copyright (c) 2020 by dsc. Copyright (c) 2020 by Kroket Ltd.
Some rights reserved. Some rights reserved.
+10
View File
@@ -0,0 +1,10 @@
include LICENSE
include CHANGELOG.md
include README.md
include setup.cfg
recursive-include quart_session *.py
recursive-include quart_session *.md
exclude .gitlab-ci.yml
exclude examples
exclude docs
exclude venv
+84 -73
View File
@@ -1,21 +1,22 @@
# Quart-session # Quart-Session
Quart-Session is an extension for Quart that adds support for ![pyversions](https://img.shields.io/pypi/pyversions/Quart-Session.svg) [![pypiversion](https://badge.fury.io/py/Quart-Session.svg)](https://pypi.org/project/Quart-Session/) ![PyPI license](https://img.shields.io/pypi/l/Quart-Session.svg)
Quart-Session is an extension for [Quart](https://gitlab.com/pgjones/quart/blob/master/README.rst) that adds support for
server-side sessions to your application. server-side sessions to your application.
Based on [flask-session](https://pypi.org/project/Flask-Session/). Based on [flask-session](https://pypi.org/project/Flask-Session/).
## Quick start ## Quick start
Quart-Session can be installed via pipenv or Quart-Session can be installed via pipenv or pip,
pip,
```bash ```bash
$ pipenv install quart-session $ pipenv install quart-session
$ pip install quart-session $ pip install quart-session
``` ```
and requires Python 3.7.0 or higher. A fairly minimal Quart-Session example is, and requires Python 3.7.0 or higher. A minimal Quart-Session example is:
```python3 ```python3
from quart import Quart, session from quart import Quart, session
@@ -28,7 +29,11 @@ Session(app)
@app.route('/') @app.route('/')
async def hello(): async def hello():
session["foo"] = "bar" session["foo"] = "bar"
return 'hello' return "session key 'foo' set"
@app.route('/foo')
async def foo():
return session.get("foo", "session key 'foo' not found")
app.run() app.run()
``` ```
@@ -36,9 +41,9 @@ app.run()
## Features ## Features
### Redis support ### Redis
via `aioredis`. via `redis>=4.4.0`.
```python3 ```python3
app = Quart(__name__) app = Quart(__name__)
@@ -46,8 +51,16 @@ app.config['SESSION_TYPE'] = 'redis'
Session(app) Session(app)
``` ```
If you already have a `aioredis.Client` instance and you'd like to share By default, Quart-session connects to Redis at `127.0.0.1:6379`. If you
it with the session interface, have a different location, use `SESSION_URI`
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_URI'] = 'redis://:password@localhost:6379'
```
Alternatively, for extra control, you may provide your own `aioredis.Client` instance altogether.
```python3 ```python3
app = Quart(__name__) app = Quart(__name__)
@@ -55,15 +68,17 @@ app.config['SESSION_TYPE'] = 'redis'
@app.before_serving @app.before_serving
async def setup(): async def setup():
cache = await aioredis.create_redis_pool({"address": "..."}) cache = await aioredis.Redis(
host="foobar.com",
port=6379,
password="foobar"
)
app.config['SESSION_REDIS'] = cache app.config['SESSION_REDIS'] = cache
Session(app) Session(app)
``` ```
By default, Quart-session creates a single connection to Redis, while #### Trio
the example above creates a connection pool.
#### Trio support
Quart-Session comes with [an (experimental) Redis client](quart_session/redis_trio) for use with the [Trio](https://trio.readthedocs.io/en/stable/) eventloop. Quart-Session comes with [an (experimental) Redis client](quart_session/redis_trio) for use with the [Trio](https://trio.readthedocs.io/en/stable/) eventloop.
@@ -76,7 +91,7 @@ app.config['SESSION_TYPE'] = 'redis'
Session(app) Session(app)
``` ```
### Memcached support ### Memcached
via `aiomcache`. via `aiomcache`.
@@ -86,11 +101,23 @@ app.config['SESSION_TYPE'] = 'memcached'
Session(app) Session(app)
``` ```
### MongoDB
via `motor`.
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'mongodb'
app.config['SESSION_MONGODB_URI'] = 'mongodb://localhost:27017/my_database'
app.config['SESSION_MONGODB_COLLECTION'] = 'sessions'
Session(app)
```
### JSON serializer ### JSON serializer
[flask-session](https://pypi.org/project/Flask-Session/) uses `pickle` [flask-session](https://pypi.org/project/Flask-Session/) uses `pickle`
for session data, Quart-Session opts for a JSON serializer capable of for session data while Quart-Session uses [a JSON serializer](https://gitlab.com/pgjones/quart/blob/37e249b9b146824a8668eaa1daa12392aeb00256/src/quart/json/tag.py#L141)
(de)serializing the usual JSON types, as well as: `Tuple`, `Bytes`, capable of serializing the usual JSON types, as well as: `Tuple`, `Bytes`,
`Markup`, `UUID`, and `DateTime`. `Markup`, `UUID`, and `DateTime`.
JSON as session data allows for greater interoperability with other JSON as session data allows for greater interoperability with other
@@ -112,86 +139,70 @@ except ImportError:
app.session_interface.serialize = pickle app.session_interface.serialize = pickle
``` ```
### Session control ### Back-end usage
By default, [flask-session](https://pypi.org/project/Flask-Session/) sets a At any point you may interface with the session back-end directly:
```python3
from quart_session.sessions import SessionInterface
@app.route("/")
async def hello():
cache: SessionInterface = app.session_interface
await cache.set("random_key", "val", expiry=3600)
data = await cache.get("random_key")
```
The interface will have the `get`, `set`, and `delete` methods available (regardless of
back-end - similar to how [aiocache](https://github.com/argaen/aiocache) works).
### Performance
[flask-session](https://pypi.org/project/Flask-Session/) sets a
session for each incoming request, including static files. From experience, session for each incoming request, including static files. From experience,
this approach can put unneeded load on underlying session infrastructure, this often puts unneeded load on underlying session infrastructure,
especially in high-traffic environments. especially in high-traffic environments.
Quart-Session offers control over the session creation. For example, often you'll only need to create a session when Quart-Session only contacts the back-end when a session changed (or created). In addition,
a user successfully logs in. static file serves never emit a `Set-Cookie` header. If you'd like to enable
this though, set `SESSION_STATIC_FILE` to `True`.
To enable this behaviour, set `SESSION_EXPLICIT` to `True`.
### Session pinning
Associates an user's session to his/her IP address. This mitigates cookie stealing via XSS etc, and is handy
for web applications that require extra security.
```python3 ```python3
app = Quart(__name__) app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis' app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_EXPLICIT'] = True app.config['SESSION_PROTECTION'] = True
Session(app)
@app.route('/')
async def root():
if session.get('authenticated'):
return "Welcome back!"
return "Welcome anonymous!"
@app.route('/login')
async def login():
session["authenticated"] = True
session.dirty() # mark session for saving
return 'Logged in!'
app.run()
```
To re-gain the old behaviour of always emitting a `Set-Cookie` header on static file serves,
set `SESSION_STATIC_FILE` to `True`.
### Session hijack prevention
(Optionally) pins an user's session to his/her IP address. This mitigates cookie stealing via XSS etc, and is handy
for paranoid web applications.
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_HIJACK_PROTECTION'] = True
Session(app) Session(app)
``` ```
With this option, session reuse from a different IP will result in the Session reuse from a different IP will now result in the creation of a new session, and the deletion of the old.
creation of a new session, and the deletion of the old.
**Important:** If your application is behind a reverse proxy, it most **Important:** If your application is behind a reverse proxy, it most
likely provides the `X-Forwarded-For` header which you **must** make use of likely provides the `X-Forwarded-For` header which you **must** make use of
by explicitly setting `SESSION_HIJACK_REVERSE_PROXY` to `True`. by explicitly setting `SESSION_REVERSE_PROXY` to `True`.
## Future development ## Future development
The following session interfaces would be nice to have:
- `MongoDBSessionInterface`
- `FileSystemSessionInterface` - `FileSystemSessionInterface`
- `GoogleCloudDatastoreSessionInterface` - `GoogleCloudDatastoreSessionInterface`
- Pytest
Other to-do's: ## Flask-Session
- Unit testing
- Documentation (Sphinx)
## Migrating from Flask
This library works very similarly to [flask-session](https://pypi.org/project/Flask-Session/). This library works very similarly to [flask-session](https://pypi.org/project/Flask-Session/).
The `quart_session.sessions` APIs are not 100% the same, but unless you The changes are specified below:
are embedded in Flask-Session's internals, a migration should be fairly
straightforward. The distinct changes are specified below:
- Quart-Session does not `Set-Cookie` on (static) files by default. - Quart-Session does not emit a `Set-Cookie` on every request.
- Quart-Session might not have all the back-end interfaces implemented (yet), such as "filesystem". - Quart-Session does not emit a `Set-Cookie` on static file serves.
- Quart-Session uses a different serializer: `quart.json.tag.TaggedJSONSerializer` instead of `pickle`. - Quart-Session uses a different serializer: `quart.json.tag.TaggedJSONSerializer` instead of `pickle`.
- Quart-Session disallows the client to supply their own made up `sid` cookie value. - Quart-Session disallows the client to supply their own made up `sid` cookie value.
- Quart-Session can do session protection.
- Quart-Session might not have all the back-end interfaces implemented (yet), such as "filesystem".
## Help ## Help
@@ -199,4 +210,4 @@ Find the Quart folk on [gitter](https://gitter.im/python-quart/lobby) or open an
## License ## License
BSD BSD
+3 -3
View File
@@ -5,7 +5,7 @@
Quart-Session demo. Quart-Session demo.
:copyright: (c) 2020 by dsc. :copyright: (c) 2020 by Kroket Ltd.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
from quart import Quart, session from quart import Quart, session
@@ -21,13 +21,13 @@ Session(app)
@app.route('/set/') @app.route('/set/')
def set(): async def set():
session['key'] = 'value' session['key'] = 'value'
return 'ok' return 'ok'
@app.route('/get/') @app.route('/get/')
def get(): async def get():
return session.get('key', 'not set') return session.get('key', 'not set')
+7
View File
@@ -0,0 +1,7 @@
[project]
name = "quart-session"
version = "3.0.1"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = []
+42 -8
View File
@@ -6,19 +6,23 @@
Adds server session support to your application. Adds server session support to your application.
:copyright: (c) 2014 by Shipeng Feng. :copyright: (c) 2014 by Shipeng Feng.
:copyright: (c) 2020 by dsc. :copyright: (c) 2020 by Kroket Ltd.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
__version__ = '0.0.1' __version__ = '3.0.0'
import os import os
import sniffio
from typing import Optional
from quart import Quart from quart import Quart
from .sessions import RedisSessionInterface, RedisTrioSessionInterface, MemcachedSessionInterface, NullSessionInterface from .sessions import (
RedisSessionInterface,
RedisTrioSessionInterface,
MemcachedSessionInterface,
MongoDBSessionInterface,
NullSessionInterface
)
class Session(object): class Session(object):
@@ -80,8 +84,8 @@ class Session(object):
config.setdefault('SESSION_PERMANENT', True) config.setdefault('SESSION_PERMANENT', True)
config.setdefault('SESSION_USE_SIGNER', False) config.setdefault('SESSION_USE_SIGNER', False)
config.setdefault('SESSION_KEY_PREFIX', 'session:') config.setdefault('SESSION_KEY_PREFIX', 'session:')
config.setdefault('SESSION_HIJACK_PROTECTION', False) config.setdefault('SESSION_PROTECTION', False)
config.setdefault('SESSION_HIJACK_REVERSE_PROXY', False) config.setdefault('SESSION_REVERSE_PROXY', False)
config.setdefault('SESSION_STATIC_FILE', False) config.setdefault('SESSION_STATIC_FILE', False)
config.setdefault('SESSION_EXPLICIT', False) config.setdefault('SESSION_EXPLICIT', False)
config.setdefault('SESSION_REDIS', None) config.setdefault('SESSION_REDIS', None)
@@ -92,6 +96,18 @@ class Session(object):
config.setdefault('SESSION_FILE_MODE', 384) config.setdefault('SESSION_FILE_MODE', 384)
config = {k: v for k, v in config.items() if k.startswith('SESSION_')} config = {k: v for k, v in config.items() if k.startswith('SESSION_')}
if isinstance(config.get("SESSION_HIJACK_PROTECTION"), bool):
app.logger.warning("Deprecation: `SESSION_HIJACK_PROTECTION` "
"has been renamed to `SESSION_PROTECTION`")
if isinstance(config.get("SESSION_HIJACK_REVERSE_PROXY"), str):
app.logger.warning("Deprecation: `SESSION_HIJACK_REVERSE_PROXY` "
"has been renamed to `SESSION_REVERSE_PROXY`")
backend_warning = f"Please specify a session backend. " \
f"Available interfaces: redis, redis+trio, " \
f"memcached, null. e.g: app.config['SESSION_TYPE'] = 'redis'"
if config['SESSION_TYPE'] == 'redis': if config['SESSION_TYPE'] == 'redis':
options = { options = {
"redis": config['SESSION_REDIS'], "redis": config['SESSION_REDIS'],
@@ -123,7 +139,25 @@ class Session(object):
use_signer=config['SESSION_USE_SIGNER'], use_signer=config['SESSION_USE_SIGNER'],
permanent=config['SESSION_PERMANENT'], permanent=config['SESSION_PERMANENT'],
**config) **config)
elif config['SESSION_TYPE'] == 'mongodb':
session_interface = MongoDBSessionInterface(
mongodb_uri=config['SESSION_MONGODB_URI'],
collection=config['SESSION_MONGODB_COLLECTION'],
client_kwargs=config.get('SESSION_MONGODB_CLIENT_KWARGS', {}),
set_callback=config.get('SESSION_MONGODB_SET_CALLBACK'),
key_prefix=config['SESSION_KEY_PREFIX'],
use_signer=config['SESSION_USE_SIGNER'],
permanent=config['SESSION_PERMANENT'],
**config)
elif config['SESSION_TYPE'] == 'null':
app.logger.warning(f"{backend_warning}. Currently using: null")
session_interface = NullSessionInterface(
key_prefix=config['SESSION_KEY_PREFIX'],
use_signer=config['SESSION_USE_SIGNER'],
permanent=config['SESSION_PERMANENT'],
**config)
else: else:
session_interface = NullSessionInterface() raise NotImplementedError(f"No such session interface "
f"\"{config['SESSION_TYPE']}\". {backend_warning}")
return session_interface return session_interface
+210 -103
View File
@@ -6,15 +6,16 @@
Server-side Sessions and SessionInterfaces. Server-side Sessions and SessionInterfaces.
:copyright: (c) 2014 by Shipeng Feng. :copyright: (c) 2014 by Shipeng Feng.
:copyright: (c) 2020 by dsc. :copyright: (c) 2020 by Kroket Ltd.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
import time import time
from typing import Any, Callable, Optional, TYPE_CHECKING from typing import Optional
from uuid import uuid4 from uuid import uuid4, UUID
import asyncio import asyncio
import functools
from quart import Quart from quart import Quart, current_app
from quart.wrappers import BaseRequestWebsocket, Response from quart.wrappers import BaseRequestWebsocket, Response
from quart.wrappers.response import FileBody from quart.wrappers.response import FileBody
from quart.sessions import SessionInterface as QuartSessionInterface, SecureCookieSession from quart.sessions import SessionInterface as QuartSessionInterface, SecureCookieSession
@@ -30,13 +31,18 @@ class ServerSideSession(SecureCookieSession):
"""Baseclass for server-side based sessions.""" """Baseclass for server-side based sessions."""
def __init__(self, initial=None, sid=None, permanent=None, addr=None): def __init__(self, initial=None, sid=None, permanent=None, addr=None):
super(ServerSideSession, self).__init__(**initial or {}) super(ServerSideSession, self).__init__(initial or {})
self.sid = sid self.sid = sid
if permanent: if permanent:
self.permanent = permanent self.permanent = permanent
if addr: if addr:
self.addr = addr self.addr = addr
self._dirty = False self.modified = False
def dirty(self):
current_app.logger.warning("Deprecation: `dirty()` has "
"been made obsolete. Will be "
"removed soon^tm.")
@property @property
def addr(self) -> str: def addr(self) -> str:
@@ -46,28 +52,6 @@ class ServerSideSession(SecureCookieSession):
def addr(self, value: str) -> None: def addr(self, value: str) -> None:
self['_addr'] = value # type: ignore self['_addr'] = value # type: ignore
def dirty(self):
"""Marks the session to be written/saved.
.. note::
This feature only works if you have set ``SESSION_EXPLICIT``
to ``True``, at which point you'll have to explicitly mark
each session before they'll get processed and saved.
Example::
app.config['SESSION_EXPLICIT'] = True
Session(app)
@app.route('/')
def root():
session['foo'] = 'bar'
session.dirty()
return "Hello World!"
"""
self._dirty = True
class RedisSession(ServerSideSession): class RedisSession(ServerSideSession):
pass pass
@@ -77,6 +61,10 @@ class MemcachedSession(ServerSideSession):
pass pass
class MongoDBSession(ServerSideSession):
pass
class NullSession(ServerSideSession): class NullSession(ServerSideSession):
pass pass
@@ -104,9 +92,17 @@ class SessionInterface(QuartSessionInterface):
app: Quart, app: Quart,
request: BaseRequestWebsocket request: BaseRequestWebsocket
) -> Optional[SecureCookieSession]: ) -> Optional[SecureCookieSession]:
sid = request.cookies.get(app.session_cookie_name) cname = app.config.get('SESSION_COOKIE_NAME', 'session')
addr = request.headers.get('X-Forwarded-For', request.remote_addr) if \ sid = request.cookies.get(cname)
self._config['SESSION_HIJACK_PROTECTION'] else None if self._config['SESSION_REVERSE_PROXY'] is True:
# and no, you cannot define your own incoming
# header, stick to standards :-)
addr = request.headers.get('X-Forwarded-For')
if not addr:
app.logger.error("Could not grab IP from reverse proxy, "
"session protection is DISABLED!")
else:
addr = request.remote_addr
options = {"sid": sid, "permanent": self.permanent, "addr": addr} options = {"sid": sid, "permanent": self.permanent, "addr": addr}
if not sid: if not sid:
@@ -125,30 +121,30 @@ class SessionInterface(QuartSessionInterface):
options['sid'] = self._generate_sid() options['sid'] = self._generate_sid()
return self.session_class(**options) return self.session_class(**options)
val = await self._backend_get(app, self.key_prefix + sid) val = await self.get(key=self.key_prefix + sid, app=app)
if val is None: if val is None:
options['sid'] = self._generate_sid() options['sid'] = self._generate_sid()
return self.session_class(**options) return self.session_class(**options)
try: if self.serializer is None:
data = self.serializer.loads(val) data = val
except: else:
app.logger.warning(f"Failed to deserialize session " try:
f"data for sid: {sid}. Generating new sid.") data = self.serializer.loads(val)
options['sid'] = self._generate_sid() except:
return self.session_class(**options) app.logger.warning(f"Failed to deserialize session "
f"data for sid: {sid}. Generating new sid.")
prevent_hijack = self._config['SESSION_HIJACK_PROTECTION'] app.logger.debug(f"data: {val}")
if prevent_hijack is True:
if self._config['SESSION_HIJACK_REVERSE_PROXY'] is True:
addr = request.headers.get('X-Forwarded-For', request.remote_addr)
else:
addr = request.remote_addr
if data.get('_addr', addr) != addr:
await self._backend_delete(app, self.key_prefix + sid)
options['sid'] = self._generate_sid() options['sid'] = self._generate_sid()
return self.session_class(**options) return self.session_class(**options)
protection = self._config['SESSION_PROTECTION']
if protection is True and addr is not None and \
data.get('_addr', addr) != addr:
await self.delete(key=self.key_prefix + sid, app=app)
options['sid'] = self._generate_sid()
return self.session_class(**options)
res = self.session_class(data, sid) res = self.session_class(data, sid)
return res return res
@@ -158,9 +154,8 @@ class SessionInterface(QuartSessionInterface):
session: SecureCookieSession, session: SecureCookieSession,
response: Response response: Response
) -> None: ) -> None:
# prevent set-cookie # prevent set-cookie on unmodified session objects
if self._config['SESSION_EXPLICIT'] is True and \ if not session.modified:
not session._dirty:
return return
# prevent set-cookie on (static) file responses # prevent set-cookie on (static) file responses
@@ -168,39 +163,47 @@ class SessionInterface(QuartSessionInterface):
if self._config['SESSION_STATIC_FILE'] is False and \ if self._config['SESSION_STATIC_FILE'] is False and \
isinstance(response.response, FileBody): isinstance(response.response, FileBody):
return return
cname = app.config.get('SESSION_COOKIE_NAME', 'session')
session_key = self.key_prefix + session.sid session_key = self.key_prefix + session.sid
domain = self.get_cookie_domain(app) domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app) path = self.get_cookie_path(app)
if not session: if not session:
if session.modified: if session.modified:
await self._backend_delete(app=app, key=session_key) await self.delete(key=session_key, app=app)
response.delete_cookie(app.session_cookie_name, response.delete_cookie(cname,
domain=domain, path=path) domain=domain, path=path)
return return
httponly = self.get_cookie_httponly(app) httponly = self.get_cookie_httponly(app)
samesite = self.get_cookie_samesite(app)
secure = self.get_cookie_secure(app) secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session) expires = self.get_expiration_time(app, session)
val = self.serializer.dumps(dict(session)) if self.serializer is None:
await self._backend_set(app=app, key=session_key, value=val) val = dict(session)
else:
val = self.serializer.dumps(dict(session))
await self.set(key=session_key, value=val, app=app)
if self.use_signer: if self.use_signer:
session_id = self._get_signer(app).sign(want_bytes(session.sid)) session_id = self._get_signer(app).sign(want_bytes(session.sid))
else: else:
session_id = session.sid session_id = session.sid
response.set_cookie(app.session_cookie_name, session_id, response.set_cookie(cname, session_id.decode('utf-8'),
expires=expires, httponly=httponly, expires=expires, httponly=httponly,
domain=domain, path=path, secure=secure) domain=domain, path=path, secure=secure, samesite=samesite)
async def create(self, app: Quart): async def create(self, app: Quart):
raise NotImplementedError() raise NotImplementedError()
async def _backend_get(self, app: Quart, key: str): async def get(self, key: str, app: Quart = None):
raise NotImplementedError() raise NotImplementedError()
async def _backend_set(self, app: Quart, key: str, value): async def set(self, key: str, value, expiry: int = None,
app: Quart = None):
raise NotImplementedError() raise NotImplementedError()
async def _backend_delete(self, app: Quart, key: str): async def delete(self, key: str, app: Quart = None):
raise NotImplementedError() raise NotImplementedError()
def _generate_sid(self) -> str: def _generate_sid(self) -> str:
@@ -227,7 +230,7 @@ class RedisSessionInterface(SessionInterface):
def __init__(self, redis, **kwargs): def __init__(self, redis, **kwargs):
super(RedisSessionInterface, self).__init__(**kwargs) super(RedisSessionInterface, self).__init__(**kwargs)
self.redis = redis self.backend = redis
async def create(self, app: Quart) -> None: async def create(self, app: Quart) -> None:
"""Creates ``aioredis.Redis`` instance. """Creates ``aioredis.Redis`` instance.
@@ -235,22 +238,29 @@ class RedisSessionInterface(SessionInterface):
.. note:: .. note::
Creates a single Redis connection, you might prefer Creates a single Redis connection, you might prefer
pooling instead (see ``aioredis.Redis.create_redis_pool``) pooling instead (see the `aioredis` documentation
for connection pool examples).
""" """
if self.redis is None: if self.backend is None:
import aioredis from redis import asyncio as aioredis
self.redis = await aioredis.create_redis("redis://localhost") uri = self._config.get('SESSION_URI', 'redis://localhost')
self.backend = await aioredis.from_url(
uri, encoding="utf-8", decode_responses=True
)
async def _backend_get(self, app: Quart, key: str): async def get(self, key: str, app: Quart = None):
return await self.redis.get(key) return await self.backend.get(key)
async def _backend_set(self, app: Quart, key: str, value): async def set(self, key: str, value, expiry: int = None,
return await self.redis.setex( app: Quart = None):
key=key, value=value, if app and not expiry:
seconds=total_seconds(app.permanent_session_lifetime)) expiry = total_seconds(app.permanent_session_lifetime)
return await self.backend.setex(
name=key, value=value,
time=expiry)
async def _backend_delete(self, app: Quart, key: str): async def delete(self, key: str, app: Quart = None):
return await self.redis.delete(key) return await self.backend.delete(key)
class RedisTrioSessionInterface(SessionInterface): class RedisTrioSessionInterface(SessionInterface):
@@ -267,7 +277,7 @@ class RedisTrioSessionInterface(SessionInterface):
def __init__(self, redis, **kwargs): def __init__(self, redis, **kwargs):
super(RedisTrioSessionInterface, self).__init__(**kwargs) super(RedisTrioSessionInterface, self).__init__(**kwargs)
self.redis_trio = redis self.backend = redis
async def create(self, app: Quart) -> None: async def create(self, app: Quart) -> None:
"""Creates ``aioredis.Redis`` instance. """Creates ``aioredis.Redis`` instance.
@@ -277,23 +287,26 @@ class RedisTrioSessionInterface(SessionInterface):
Creates a single Redis connection. Pooling not Creates a single Redis connection. Pooling not
supported yet for ``RedisTrio``. supported yet for ``RedisTrio``.
""" """
if self.redis_trio is None: if self.backend is None:
from quart_session.redis_trio import RedisTrio from quart_session.redis_trio import RedisTrio
self.redis_trio = RedisTrio() self.backend = RedisTrio()
await self.redis_trio.connect() await self.backend.connect()
async def _backend_get(self, app: Quart, key: str): async def get(self, key: str, app: Quart = None):
data = await self.redis_trio.get(key) data = await self.backend.get(key)
if data: if data:
return data.decode() return data.decode()
async def _backend_set(self, app: Quart, key: str, value): async def set(self, key: str, value, expiry: int = None,
return await self.redis_trio.setex( app: Quart = None):
if app and not expiry:
expiry = total_seconds(app.permanent_session_lifetime)
return await self.backend.setex(
key=key, value=value, key=key, value=value,
seconds=total_seconds(app.permanent_session_lifetime)) seconds=expiry)
async def _backend_delete(self, app: Quart, key: str): async def delete(self, key: str, app: Quart = None):
return await self.redis_trio.delete(key) return await self.backend.delete(key)
class MemcachedSessionInterface(SessionInterface): class MemcachedSessionInterface(SessionInterface):
@@ -314,14 +327,14 @@ class MemcachedSessionInterface(SessionInterface):
super(MemcachedSessionInterface, self).__init__( super(MemcachedSessionInterface, self).__init__(
key_prefix=key_prefix, use_signer=use_signer, key_prefix=key_prefix, use_signer=use_signer,
permanent=permanent, **kwargs) permanent=permanent, **kwargs)
self.memcached = memcached self.backend = memcached
@asyncio.coroutine async def create(self, app: Quart) -> None:
def create(self, app: Quart) -> None: if self.backend is None:
if self.memcached is None:
import aiomcache import aiomcache
loop = asyncio.get_running_loop() # self.backend = aiomcache.Client("127.0.0.1", 11211)
self.memcached = aiomcache.Client("127.0.0.1", 11211, loop=loop) self.backend = aiomcache.Client(self._config.get('SESSION_MEMCACHED_HOST', '127.0.0.1'),
self._config.get('SESSION_MEMCACHED_PORT', 11211))
def _get_memcache_timeout(self, timeout): def _get_memcache_timeout(self, timeout):
""" """
@@ -338,26 +351,120 @@ class MemcachedSessionInterface(SessionInterface):
timeout += int(time.time()) timeout += int(time.time())
return timeout return timeout
async def _backend_get(self, app: Quart, key: str): async def get(self, key: str, app: Quart = None):
key = key.encode() key = key.encode()
return await self.memcached.get(key) return await self.backend.get(key)
async def set(self, key: str, value, expiry: int = None,
app: Quart = None):
if app and not expiry:
expiry = self._get_memcache_timeout(
total_seconds(app.permanent_session_lifetime))
async def _backend_set(self, app: Quart, key: str, value):
key = key.encode() key = key.encode()
value = value.encode() value = value.encode()
expiry = self._get_memcache_timeout(total_seconds( return await self.backend.set(key=key, value=value,
app.permanent_session_lifetime)) exptime=expiry)
return await self.memcached.set(key=key, value=value,
exptime=expiry)
async def _backend_delete(self, app: Quart, key: str): async def delete(self, key: str, app: Quart = None):
key = key.encode() key = key.encode()
return await self.memcached.delete(key) return await self.backend.delete(key)
def _convert_key_to_uuid(func):
"""
convert the session UUID to a UUID object for mongodb
example:
"session:b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8" ->
UUID("b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8")
"""
@functools.wraps(func)
async def wrapper(*args, **kwargs):
if 'key' in kwargs:
key = kwargs['key']
try:
if key.startswith('session:'):
_, _uuid = tuple(key.split(':'))
kwargs['key'] = UUID(_uuid)
except Exception as e:
current_app.logger.warning(
f"session could not be converted to a uuid object: {key}"
)
return await func(*args, **kwargs)
return wrapper
class MongoDBSessionInterface(SessionInterface):
# mongodb does not a serializer as many object types are properly handled by the connector
serializer = None
session_class = MongoDBSession
def __init__(self, mongodb_uri, collection, client_kwargs={}, set_callback=None, **kwargs):
from motor.motor_asyncio import AsyncIOMotorClient
super().__init__(**kwargs)
self.mongodb_uri = mongodb_uri
self.client_kwargs = client_kwargs
self.set_callback = set_callback
self._collection = collection
self._client = AsyncIOMotorClient(self.mongodb_uri, uuidRepresentation='standard', **self.client_kwargs)
self._database = self._client.get_database()
async def create(self, app: Quart) -> None:
pass
@_convert_key_to_uuid
async def get(self, key, app):
value = await self.collection.find_one({'_id': key}, {'data': True})
if value:
return value.get('data', {})
else:
return None
@_convert_key_to_uuid
async def set(self, key, value, expiry=None, app=None):
doc = {
'data': value,
}
# allows the document to be modified prior upsert
if callable(self.set_callback):
self.set_callback(doc)
await self.collection.update_one({
'_id': key
}, {
'$set': doc
},
upsert=True
)
@property
def collection(self):
return self._database.get_collection(self._collection)
class NullSessionInterface(SessionInterface): class NullSessionInterface(SessionInterface):
"""Used to open a :class:`quart.sessions.NullSession` instance. """This class does absolutely nothing"""
""" session_class = NullSession
def open_session(self, app: Quart, request: BaseRequestWebsocket): def __init__(
return None self, key_prefix: str, use_signer: bool = False,
permanent: bool = True, **kwargs):
super(NullSessionInterface, self).__init__(
key_prefix=key_prefix, use_signer=use_signer,
permanent=permanent, **kwargs)
self.backend = None
async def create(self, app: Quart) -> None:
pass
async def get(self, key: str, app: Quart = None) -> None:
pass
async def set(self, key: str, value, expiry: int = None,
app: Quart = None) -> None:
pass
+16 -8
View File
@@ -9,32 +9,40 @@ Links
````` `````
* `Github * `Github
<https://github.com/xmrdsc/quart-session>`_ <https://github.com/kroketio/quart-session>`_
""" """
from setuptools import setup from setuptools import setup
with open('README.md') as f:
long_description = f.read()
INSTALL_REQUIRES = [ INSTALL_REQUIRES = [
"Quart>=0.10.0" "Quart>=0.19.0"
] ]
setup( setup(
name='Quart-Session', name='Quart-Session',
version='0.0.1', version='3.0.1',
url='https://github.com/xmrdsc/quart-session', url='https://github.com/kroketio/quart-session',
license='BSD', license='BSD',
author='dsc', author='Kroket Ltd.',
author_email='dsc@xmr.pm', author_email='code@kroket.io',
description='Adds server-side session support to your Quart application', description='Adds server-side session support to your Quart application',
long_description=__doc__, long_description=long_description,
long_description_content_type='text/markdown',
packages=['quart_session'], packages=['quart_session'],
zip_safe=False, zip_safe=False,
include_package_data=True, include_package_data=True,
platforms='any', platforms='any',
install_requires=INSTALL_REQUIRES, install_requires=INSTALL_REQUIRES,
tests_require=INSTALL_REQUIRES + ["asynctest", "hypothesis", "pytest", "pytest-asyncio"], tests_require=INSTALL_REQUIRES + ["asynctest", "hypothesis", "pytest", "pytest-asyncio"],
extras_require={"dotenv": ["python-dotenv"]}, extras_require={
"dotenv": ["python-dotenv"],
"mongodb": ["motor>=2.5.1"],
"redis": ["redis>=4.4.0"]
},
classifiers=[ classifiers=[
'Environment :: Web Environment', 'Environment :: Web Environment',
'Intended Audience :: Developers', 'Intended Audience :: Developers',