From 51668878df4952b68faee1ebe9b577613b2a8359 Mon Sep 17 00:00:00 2001 From: Sander Date: Wed, 15 Jan 2020 09:22:11 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 13 ++++ README.md | 83 +++++++++------------- quart_session/__init__.py | 15 ++-- quart_session/sessions.py | 145 +++++++++++++++++++------------------- setup.py | 2 +- 5 files changed, 131 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..fca2c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +### 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. \ No newline at end of file diff --git a/README.md b/README.md index d0eba49..ba27d24 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,7 @@ Based on [flask-session](https://pypi.org/project/Flask-Session/). ## Quick start -Quart-Session can be installed via pipenv or -pip, +Quart-Session can be installed via pipenv or pip, ```bash $ pipenv install quart-session @@ -91,8 +90,8 @@ 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`, +for session data while Quart-Session uses [a JSON serializer](https://gitlab.com/pgjones/quart/blob/37e249b9b146824a8668eaa1daa12392aeb00256/src/quart/json/tag.py#L141) +capable of serializing the usual JSON types, as well as: `Tuple`, `Bytes`, `Markup`, `UUID`, and `DateTime`. JSON as session data allows for greater interoperability with other @@ -114,52 +113,42 @@ except ImportError: app.session_interface.serialize = pickle ``` -### Session control +### Back-end usage -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`. +At any point you may interface with the session back-end directly: ```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() +@app.route("/") +async def hello(): + cache = app.session_interface + await cache.set("random_key", "val", expiry=3600) + data = await cache.get("random_key") ``` -To re-gain the old behaviour of always emitting a `Set-Cookie` header on static file serves, -set `SESSION_STATIC_FILE` to `True`. +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, +this often puts unneeded load on underlying session infrastructure, +especially in high-traffic environments. + +Quart-Session only contacts the back-end when a session changed (or created). In addition, +static file serves never emit a `Set-Cookie` header. If you'd like to enable +this though, set `SESSION_STATIC_FILE` 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 paranoid web applications. +for web applications that require extra security. ```python3 app = Quart(__name__) app.config['SESSION_TYPE'] = 'redis' -app.config['SESSION_HIJACK_PROTECTION'] = True +app.config['SESSION_PROTECTION'] = True Session(app) ``` @@ -167,32 +156,26 @@ Session reuse from a different IP will now result in the creation of a new sessi **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`. +by explicitly setting `SESSION_REVERSE_PROXY` to `True`. ## Future development -The following session interfaces would be nice to have: - - `MongoDBSessionInterface` - `FileSystemSessionInterface` - `GoogleCloudDatastoreSessionInterface` +- Pytest -Other to-do's: - -- Unit testing -- Documentation (Sphinx) - -## Migrating from Flask +## 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 -are embedded in Flask-Session's internals, a migration should be fairly -straightforward. The distinct changes are specified below: +The 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 does not emit a `Set-Cookie` on every request. +- 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 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 diff --git a/quart_session/__init__.py b/quart_session/__init__.py index a65e903..2fab284 100644 --- a/quart_session/__init__.py +++ b/quart_session/__init__.py @@ -10,10 +10,9 @@ :license: BSD, see LICENSE for more details. """ -__version__ = '0.0.1' +__version__ = '1.0.0' import os -from typing import Optional from quart import Quart @@ -79,8 +78,8 @@ class Session(object): 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_PROTECTION', False) + config.setdefault('SESSION_REVERSE_PROXY', False) config.setdefault('SESSION_STATIC_FILE', False) config.setdefault('SESSION_EXPLICIT', False) config.setdefault('SESSION_REDIS', None) @@ -91,6 +90,14 @@ class Session(object): config.setdefault('SESSION_FILE_MODE', 384) 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`") + if config['SESSION_TYPE'] == 'redis': options = { "redis": config['SESSION_REDIS'], diff --git a/quart_session/sessions.py b/quart_session/sessions.py index 2267824..db09e32 100644 --- a/quart_session/sessions.py +++ b/quart_session/sessions.py @@ -10,11 +10,11 @@ :license: BSD, see LICENSE for more details. """ import time -from typing import Any, Callable, Optional, TYPE_CHECKING +from typing import Optional from uuid import uuid4 import asyncio -from quart import Quart +from quart import Quart, current_app from quart.wrappers import BaseRequestWebsocket, Response from quart.wrappers.response import FileBody from quart.sessions import SessionInterface as QuartSessionInterface, SecureCookieSession @@ -36,7 +36,12 @@ class ServerSideSession(SecureCookieSession): self.permanent = permanent if 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 def addr(self) -> str: @@ -46,28 +51,6 @@ class ServerSideSession(SecureCookieSession): 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 @@ -105,8 +88,13 @@ class SessionInterface(QuartSessionInterface): request: BaseRequestWebsocket ) -> Optional[SecureCookieSession]: sid = request.cookies.get(app.session_cookie_name) - if self._config['SESSION_HIJACK_REVERSE_PROXY'] is True: - addr = request.headers.get('X-Forwarded-For', request.remote_addr) + 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} @@ -127,7 +115,7 @@ class SessionInterface(QuartSessionInterface): options['sid'] = self._generate_sid() 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: options['sid'] = self._generate_sid() return self.session_class(**options) @@ -137,12 +125,14 @@ class SessionInterface(QuartSessionInterface): except: app.logger.warning(f"Failed to deserialize session " f"data for sid: {sid}. Generating new sid.") + app.logger.debug(f"data: {val}") options['sid'] = self._generate_sid() return self.session_class(**options) - prevent_hijack = self._config['SESSION_HIJACK_PROTECTION'] - if prevent_hijack is True and data.get('_addr', addr) != addr: - await self._backend_delete(app, self.key_prefix + sid) + 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) @@ -155,9 +145,8 @@ class SessionInterface(QuartSessionInterface): session: SecureCookieSession, response: Response ) -> None: - # prevent set-cookie - if self._config['SESSION_EXPLICIT'] is True and \ - not session._dirty: + # prevent set-cookie on unmodified session objects + if not session.modified: return # prevent set-cookie on (static) file responses @@ -165,12 +154,13 @@ class SessionInterface(QuartSessionInterface): 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) + await self.delete(key=session_key, app=app) response.delete_cookie(app.session_cookie_name, domain=domain, path=path) return @@ -179,7 +169,7 @@ class SessionInterface(QuartSessionInterface): expires = self.get_expiration_time(app, session) val = self.serializer.dumps(dict(session)) - await self._backend_set(app=app, key=session_key, value=val) + await self.set(key=session_key, value=val, app=app) if self.use_signer: session_id = self._get_signer(app).sign(want_bytes(session.sid)) else: @@ -191,13 +181,14 @@ class SessionInterface(QuartSessionInterface): async def create(self, app: Quart): raise NotImplementedError() - async def _backend_get(self, app: Quart, key: str): + async def get(self, app: Quart, key: str): 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() - async def _backend_delete(self, app: Quart, key: str): + async def delete(self, key: str, app: Quart = None): raise NotImplementedError() def _generate_sid(self) -> str: @@ -224,7 +215,7 @@ class RedisSessionInterface(SessionInterface): def __init__(self, redis, **kwargs): super(RedisSessionInterface, self).__init__(**kwargs) - self.redis = redis + self.backend = redis async def create(self, app: Quart) -> None: """Creates ``aioredis.Redis`` instance. @@ -234,20 +225,24 @@ class RedisSessionInterface(SessionInterface): Creates a single Redis connection, you might prefer pooling instead (see ``aioredis.Redis.create_redis_pool``) """ - if self.redis is None: + if self.backend is None: import aioredis - self.redis = await aioredis.create_redis("redis://localhost") + self.backend = await aioredis.create_redis( + "redis://localhost") - async def _backend_get(self, app: Quart, key: str): - return await self.redis.get(key) + async def get(self, key: str, app: Quart = None): + return await self.backend.get(key) - async def _backend_set(self, app: Quart, key: str, value): - return await self.redis.setex( + async def set(self, key: str, value, expiry: int = None, + app: Quart = None): + if app and not expiry: + expiry = total_seconds(app.permanent_session_lifetime) + return await self.backend.setex( key=key, value=value, - seconds=total_seconds(app.permanent_session_lifetime)) + seconds=expiry) - async def _backend_delete(self, app: Quart, key: str): - return await self.redis.delete(key) + async def delete(self, key: str, app: Quart = None): + return await self.backend.delete(key) class RedisTrioSessionInterface(SessionInterface): @@ -264,7 +259,7 @@ class RedisTrioSessionInterface(SessionInterface): def __init__(self, redis, **kwargs): super(RedisTrioSessionInterface, self).__init__(**kwargs) - self.redis_trio = redis + self.backend = redis async def create(self, app: Quart) -> None: """Creates ``aioredis.Redis`` instance. @@ -274,23 +269,26 @@ class RedisTrioSessionInterface(SessionInterface): Creates a single Redis connection. Pooling not supported yet for ``RedisTrio``. """ - if self.redis_trio is None: + if self.backend is None: from quart_session.redis_trio import RedisTrio - self.redis_trio = RedisTrio() - await self.redis_trio.connect() + self.backend = RedisTrio() + await self.backend.connect() - async def _backend_get(self, app: Quart, key: str): - data = await self.redis_trio.get(key) + async def get(self, key: str, app: Quart = None): + data = await self.backend.get(key) if data: return data.decode() - async def _backend_set(self, app: Quart, key: str, value): - return await self.redis_trio.setex( + async def set(self, key: str, value, expiry: int = None, + app: Quart = None): + if app and not expiry: + expiry = total_seconds(app.permanent_session_lifetime) + return await self.backend.setex( key=key, value=value, - seconds=total_seconds(app.permanent_session_lifetime)) + seconds=expiry) - async def _backend_delete(self, app: Quart, key: str): - return await self.redis_trio.delete(key) + async def delete(self, key: str, app: Quart = None): + return await self.backend.delete(key) class MemcachedSessionInterface(SessionInterface): @@ -311,14 +309,14 @@ class MemcachedSessionInterface(SessionInterface): super(MemcachedSessionInterface, self).__init__( key_prefix=key_prefix, use_signer=use_signer, permanent=permanent, **kwargs) - self.memcached = memcached + self.backend = memcached @asyncio.coroutine def create(self, app: Quart) -> None: - if self.memcached is None: + if self.backend is None: import aiomcache loop = asyncio.get_running_loop() - self.memcached = aiomcache.Client("127.0.0.1", 11211, loop=loop) + self.backend = aiomcache.Client("127.0.0.1", 11211, loop=loop) def _get_memcache_timeout(self, timeout): """ @@ -335,21 +333,24 @@ class MemcachedSessionInterface(SessionInterface): timeout += int(time.time()) return timeout - async def _backend_get(self, app: Quart, key: str): + async def get(self, key: str, app: Quart = None): 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() 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) + return await self.backend.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() - return await self.memcached.delete(key) + return await self.backend.delete(key) class NullSessionInterface(SessionInterface): diff --git a/setup.py b/setup.py index 0e06019..48dbbd9 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ INSTALL_REQUIRES = [ setup( name='Quart-Session', - version='0.0.1', + version='1.0.0', url='https://github.com/sferdi0/quart-session', license='BSD', author='Sander',