Initial commit

This commit is contained in:
dsc
2020-01-04 23:31:28 +01:00
commit 499e46c93d
8 changed files with 786 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
env/
bin/
build/
develop-eggs/
dist/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
# Rope
.ropeproject
# Django stuff:
*.log
*.pot
# Sphinx documentation
docs/_build/
+32
View File
@@ -0,0 +1,32 @@
Copyright (c) 2014 by Shipeng Feng.
Copyright (c) 2020 by dsc.
Some rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* The names of the contributors may not be used to endorse or
promote products derived from this software without specific
prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+192
View File
@@ -0,0 +1,192 @@
# Quart-session
Quart-Session is an extension for Quart that adds support for
server-side sessions to your application.
Based on [flask-session](https://pypi.org/project/Flask-Session/).
## Quick start
Quart-Session can be installed via pipenv or
pip,
```bash
$ pipenv install quart-session
$ pip install quart-session
```
and requires Python 3.7.0 or higher. A fairly minimal Quart-Session example is,
```python3
from quart import Quart, session
from quart_session import Session
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis'
Session(app)
@app.route('/')
async def hello():
session["foo"] = "bar"
return 'hello'
app.run()
```
## Features
### Redis support
via `aioredis` or `trio-redis` (when using [Trio](https://trio.readthedocs.io/en/stable/)).
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis'
Session(app)
```
If you already have a `aioredis.Client` instance and you'd like to share
it with the session interface,
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis'
@app.before_serving
async def setup():
cache = await aioredis.create_redis_pool({"address": "..."})
app.config['SESSION_REDIS'] = cache
Session(app)
```
#### Trio
Quart-Session comes with a Redis client for use with the [Trio](https://trio.readthedocs.io/en/stable/) eventloop.
### Memcached support
via `aiomcache`.
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'memcached'
Session(app)
```
### JSON serializer
[flask-session](https://pypi.org/project/Flask-Session/) uses `pickle`
for session data, Quart-Session opts for a JSON serializer capable of
(de)serializing the usual JSON types, as well as: `Tuple`, `Bytes`,
`Markup`, `UUID`, and `DateTime`.
JSON as session data allows for greater interoperability with other
programs/languages that might want to read session data straight
from a back-end. In addition, it is more secure.
If, for some unholy reason, you prefer `pickle` or your own serializer,
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis'
Session(app)
try:
import cPickle as pickle
except ImportError:
import pickle
app.session_interface.serialize = pickle
```
### Session control
By default, [flask-session](https://pypi.org/project/Flask-Session/) sets a
session for each incoming request, including static files. From experience,
this approach can put unneeded load on underlying session infrastructure,
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
a user successfully logs in.
To enable this behaviour, set `SESSION_EXPLICIT` to `True`.
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_EXPLICIT'] = 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)
```
With this option, session reuse from a different IP will result in the
creation of a new session, and the deletion of the old.
**Important:** If your application is behind a reverse proxy, it most
likely provides the `X-Forwarded-For` header which you **must** make use of
by explicitly setting `SESSION_HIJACK_REVERSE_PROXY` to `True`.
## Future development
The following session interfaces would be nice to have:
- `MongoDBSessionInterface`
- `FileSystemSessionInterface`
- `GoogleCloudDatastoreSessionInterface`
Other to-do's:
- Unit testing
- Documentation (Sphinx)
## Migrating from Flask
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
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 might not have all the back-end interfaces implemented (yet), such as "filesystem".
- Quart-Session uses a different serializer: `quart.json.tag.TaggedJSONSerializer` instead of `pickle`.
- Quart-Session uses asyncio ;-)
## Help
Find the Quart folk on [gitter](https://gitter.im/python-quart/lobby) or open an issue.
## License
BSD
+35
View File
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""
Hello
~~~~~
Quart-Session demo.
:copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details.
"""
from quart import Quart, session
from quart_session import Session
SESSION_TYPE = 'redis'
app = Quart(__name__)
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')
if __name__ == "__main__":
app.run(debug=True)
+104
View File
@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""
quart_session
~~~~~~~~~~~~~
Adds server session support to your application.
:copyright: (c) 2014 by Shipeng Feng.
:copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details.
"""
__version__ = '0.0.1'
import os
from typing import Optional
from quart import Quart
from .sessions import RedisSessionInterface, MemcachedSessionInterface, NullSessionInterface
class Session(object):
"""This class is used to add Server-side Session to one or more Quart
applications.
There are two usage modes. One is initialize the instance with a very
specific Quart application::
app = Quart(__name__)
Session(app)
The second possibility is to create the object once and configure the
application later::
sess = Session()
def create_app():
app = Quart(__name__)
sess.init_app(app)
return app
By default Quart-Session will use :class:`NullSessionInterface`, you
really should configure your app to use a different SessionInterface.
.. note::
You can not use ``Session`` instance directly, what ``Session`` does
is just change the :attr:`~quart.Quart.session_interface` attribute on
your Quart applications.
"""
def __init__(self, app: Quart = None) -> None:
self.app = app
if app is not None:
self.init_app(app)
def init_app(self, app: Quart) -> None:
"""This is used to set up session for your app object.
:param app: the Quart app object with proper configuration.
"""
app.session_interface = self._get_interface(app)
@app.before_serving
async def setup():
await app.session_interface.create(app)
def _get_interface(self, app: Quart):
config = app.config.copy()
config.setdefault('SESSION_TYPE', 'null')
config.setdefault('SESSION_PERMANENT', True)
config.setdefault('SESSION_USE_SIGNER', False)
config.setdefault('SESSION_KEY_PREFIX', 'session:')
config.setdefault('SESSION_HIJACK_PROTECTION', False)
config.setdefault('SESSION_HIJACK_REVERSE_PROXY', False)
config.setdefault('SESSION_STATIC_FILE', False)
config.setdefault('SESSION_EXPLICIT', False)
config.setdefault('SESSION_REDIS', None)
config.setdefault('SESSION_MEMCACHED', None)
config.setdefault('SESSION_FILE_DIR',
os.path.join(os.getcwd(), 'quart_session'))
config.setdefault('SESSION_FILE_THRESHOLD', 500)
config.setdefault('SESSION_FILE_MODE', 384)
config = {k: v for k, v in config.items() if k.startswith('SESSION_')}
if config['SESSION_TYPE'] == 'redis':
session_interface = RedisSessionInterface(
redis=config['SESSION_REDIS'],
key_prefix=config['SESSION_KEY_PREFIX'],
use_signer=config['SESSION_USE_SIGNER'],
permanent=config['SESSION_PERMANENT'],
**config)
elif config['SESSION_TYPE'] == 'memcached':
session_interface = MemcachedSessionInterface(
memcached=config['SESSION_MEMCACHED'],
key_prefix=config['SESSION_KEY_PREFIX'],
use_signer=config['SESSION_USE_SIGNER'],
permanent=config['SESSION_PERMANENT'],
**config)
else:
session_interface = NullSessionInterface()
return session_interface
+318
View File
@@ -0,0 +1,318 @@
# -*- coding: utf-8 -*-
"""
quart_session.sessions
~~~~~~~~~~~~~~~~~~~~~~
Server-side Sessions and SessionInterfaces.
:copyright: (c) 2014 by Shipeng Feng.
:copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details.
"""
import time
from typing import Any, Callable, Optional, TYPE_CHECKING
from uuid import uuid4
import asyncio
from quart import Quart
from quart.wrappers import BaseRequestWebsocket, Response
from quart.wrappers.response import FileBody
from quart.sessions import SessionInterface as QuartSessionInterface, SecureCookieSession
from quart.json.tag import TaggedJSONSerializer
from itsdangerous import Signer, BadSignature, want_bytes
def total_seconds(td):
return td.days * 60 * 60 * 24 + td.seconds
class ServerSideSession(SecureCookieSession):
"""Baseclass for server-side based sessions."""
def __init__(self, initial=None, sid=None, permanent=None, addr=None):
super(ServerSideSession, self).__init__(**initial or {})
self.sid = sid
if permanent:
self.permanent = permanent
if addr:
self.addr = addr
self._dirty = False
@property
def addr(self) -> str:
return self.get('_addr', False) # type: ignore
@addr.setter
def addr(self, value: str) -> None:
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):
pass
class MemcachedSession(ServerSideSession):
pass
class NullSession(ServerSideSession):
pass
class SessionInterface(QuartSessionInterface):
"""Baseclass for session interfaces"""
serializer = TaggedJSONSerializer()
session_class = None
def __init__(
self,
key_prefix: str,
use_signer: bool = False,
permanent: bool = True,
**kwargs
) -> None:
self.key_prefix = key_prefix
self.use_signer = use_signer
self.permanent = permanent
self._config = kwargs
async def open_session(
self, app: Quart, request: BaseRequestWebsocket
) -> Optional[SecureCookieSession]:
sid = request.cookies.get(app.session_cookie_name)
addr = request.headers.get('X-Forwarded-For', request.remote_addr) if \
self._config['SESSION_HIJACK_PROTECTION'] else None
options = {"sid": sid, "permanent": self.permanent, "addr": addr}
if not sid:
options['sid'] = self._generate_sid()
return self.session_class(**options)
if self.use_signer:
signer = self._get_signer(app)
if signer is None:
app.logger.warning("Failed to obtain a valid signer.")
return None
try:
sid_as_bytes = signer.unsign(sid)
sid = sid_as_bytes.decode()
except BadSignature:
app.logger.warning(f"Bad signature for sid: {sid}.")
options['sid'] = self._generate_sid()
return self.session_class(**options)
val = await self._backend_get(app, self.key_prefix + sid)
if val is None:
options['sid'] = self._generate_sid()
return self.session_class(**options)
try:
data = self.serializer.loads(val)
except:
app.logger.warning(f"Failed to deserialize session "
f"data for sid: {sid}. Generating new sid.")
options['sid'] = self._generate_sid()
return self.session_class(**options)
prevent_hijack = self._config['SESSION_HIJACK_PROTECTION']
if prevent_hijack is False:
pass
elif isinstance(prevent_hijack, bool) and \
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()
return self.session_class(**options)
res = self.session_class(data, sid)
return res
async def save_session( # type: ignore
self,
app: "Quart",
session: SecureCookieSession,
response: Response
) -> None:
# prevent set-cookie
# motivation: https://github.com/fengsp/flask-session/pull/70
if self._config['SESSION_EXPLICIT'] is True and \
not session._dirty:
return
# prevent set-cookie on (static) file responses
if self._config['SESSION_STATIC_FILE'] is False and \
isinstance(response.response, FileBody):
return
session_key = self.key_prefix + session.sid
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
if not session:
if session.modified:
await self._backend_delete(app=app, key=session_key)
response.delete_cookie(app.session_cookie_name,
domain=domain, path=path)
return
httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session)
val = self.serializer.dumps(dict(session))
await self._backend_set(app=app, key=session_key, value=val)
if self.use_signer:
session_id = self._get_signer(app).sign(want_bytes(session.sid))
else:
session_id = session.sid
response.set_cookie(app.session_cookie_name, session_id,
expires=expires, httponly=httponly,
domain=domain, path=path, secure=secure)
async def create(self, app: Quart):
raise NotImplementedError()
async def _backend_get(self, app: Quart, key: str):
raise NotImplementedError()
async def _backend_set(self, app: Quart, key: str, value):
raise NotImplementedError()
async def _backend_delete(self, app: Quart, key: str):
raise NotImplementedError()
def _generate_sid(self) -> str:
return str(uuid4())
def _get_signer(self, app) -> Optional[Signer]:
if not app.secret_key:
return None
return Signer(app.secret_key, salt='quart-session',
key_derivation='hmac')
class RedisSessionInterface(SessionInterface):
"""Uses the Redis key-value store as a session backend.
:param redis: ``aioredis.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 kwargs: Quart-session config, used internally.
"""
session_class = RedisSession
def __init__(
self, redis, key_prefix: str, use_signer: bool = False,
permanent: bool = True, **kwargs):
super(RedisSessionInterface, self).__init__(
key_prefix=key_prefix, use_signer=use_signer,
permanent=permanent, **kwargs)
self.redis = redis
async def create(self, app: Quart) -> None:
if self.redis is None:
import aioredis
self.redis = await aioredis.create_redis("redis://localhost")
async def _backend_get(self, app: Quart, key: str):
return await self.redis.get(key)
async def _backend_set(self, app: Quart, key: str, value):
return await self.redis.setex(
key=key, value=value,
seconds=total_seconds(app.permanent_session_lifetime))
async def _backend_delete(self, app: Quart, key: str):
return await self.redis.delete(key)
class MemcachedSessionInterface(SessionInterface):
"""Uses the Memcached key-value store as a session backend.
:param client: ``aiomcache.Client`` 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 kwargs: Quart-session config, used internally.
"""
session_class = MemcachedSession
def __init__(
self, memcached, key_prefix: str, use_signer: bool = False,
permanent: bool = True, **kwargs):
super(MemcachedSessionInterface, self).__init__(
key_prefix=key_prefix, use_signer=use_signer,
permanent=permanent, **kwargs)
self.memcached = memcached
@asyncio.coroutine
def create(self, app: Quart) -> None:
if self.memcached is None:
import aiomcache
loop = asyncio.get_running_loop()
self.memcached = aiomcache.Client("127.0.0.1", 11211, loop=loop)
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
# See http://code.google.com/p/memcached/wiki/FAQ
# "You can set expire times up to 30 days in the future. After that
# memcached interprets it as a date, and will expire the item after
# said date. This is a simple (but obscure) mechanic."
#
# This means that we have to switch to absolute timestamps.
timeout += int(time.time())
return timeout
async def _backend_get(self, app: Quart, key: str):
key = key.encode()
return await self.memcached.get(key)
async def _backend_set(self, app: Quart, key: str, value):
key = key.encode()
value = value.encode()
expiry = self._get_memcache_timeout(total_seconds(
app.permanent_session_lifetime))
return await self.memcached.set(key=key, value=value,
exptime=expiry)
async def _backend_delete(self, app: Quart, key: str):
key = key.encode()
return await self.memcached.delete(key)
class NullSessionInterface(SessionInterface):
"""Used to open a :class:`quart.sessions.NullSession` instance.
"""
def open_session(self, app: Quart, request: BaseRequestWebsocket):
return None
+2
View File
@@ -0,0 +1,2 @@
[wheel]
universal = 1
+49
View File
@@ -0,0 +1,49 @@
"""
Quart-Session
-------------
Quart-Session is an extension for Quart that adds support for
Server-side Session to your application.
Links
`````
* `Github
<https://github.com/xmrdsc/quart-session>`_
"""
from setuptools import setup
INSTALL_REQUIRES = [
"Quart>=0.10.0"
]
setup(
name='Quart-Session',
version='0.0.1',
url='https://github.com/xmrdsc/quart-session',
license='BSD',
author='dsc',
author_email='dsc@xmr.pm',
description='Adds server-side session support to your Quart application',
long_description=__doc__,
packages=['quart_session'],
zip_safe=False,
include_package_data=True,
platforms='any',
install_requires=INSTALL_REQUIRES,
tests_require=INSTALL_REQUIRES + ["asynctest", "hypothesis", "pytest", "pytest-asyncio"],
extras_require={"dotenv": ["python-dotenv"]},
classifiers=[
'Environment :: Web Environment',
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
'Topic :: Software Development :: Libraries :: Python Modules'
]
)