From 499e46c93dadb866efdcfa7f481c7a5d89edfb72 Mon Sep 17 00:00:00 2001 From: dsc Date: Sat, 4 Jan 2020 23:31:28 +0100 Subject: [PATCH] Initial commit --- .gitignore | 54 +++++++ LICENSE | 32 ++++ README.md | 192 +++++++++++++++++++++++ examples/hello.py | 35 +++++ quart_session/__init__.py | 104 +++++++++++++ quart_session/sessions.py | 318 ++++++++++++++++++++++++++++++++++++++ setup.cfg | 2 + setup.py | 49 ++++++ 8 files changed, 786 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/hello.py create mode 100644 quart_session/__init__.py create mode 100644 quart_session/sessions.py create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51cbe85 --- /dev/null +++ b/.gitignore @@ -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/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50126f7 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..22c1f1d --- /dev/null +++ b/README.md @@ -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 \ No newline at end of file diff --git a/examples/hello.py b/examples/hello.py new file mode 100644 index 0000000..781d9ba --- /dev/null +++ b/examples/hello.py @@ -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) diff --git a/quart_session/__init__.py b/quart_session/__init__.py new file mode 100644 index 0000000..eb5f467 --- /dev/null +++ b/quart_session/__init__.py @@ -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 diff --git a/quart_session/sessions.py b/quart_session/sessions.py new file mode 100644 index 0000000..dfd3d6e --- /dev/null +++ b/quart_session/sessions.py @@ -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 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..5e40900 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[wheel] +universal = 1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f2bb3da --- /dev/null +++ b/setup.py @@ -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 + `_ + +""" +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' + ] +)