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.
This commit is contained in:
Sander
2020-01-15 09:22:11 +01:00
committed by sander
parent 45b8147b0a
commit 51668878df
5 changed files with 131 additions and 127 deletions
+13
View File
@@ -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.
+33 -50
View File
@@ -9,8 +9,7 @@ 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
@@ -91,8 +90,8 @@ 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
@@ -114,52 +113,42 @@ 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:
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 ```python3
app = Quart(__name__) @app.route("/")
app.config['SESSION_TYPE'] = 'redis' async def hello():
app.config['SESSION_EXPLICIT'] = True cache = app.session_interface
Session(app) await cache.set("random_key", "val", expiry=3600)
data = await cache.get("random_key")
@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, The interface will have the `get`, `set`, and `delete` methods available (regardless of
set `SESSION_STATIC_FILE` to `True`. 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 ### Session pinning
Associates an user's session to his/her IP address. This mitigates cookie stealing via XSS etc, and is handy 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 ```python3
app = Quart(__name__) app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis' app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_HIJACK_PROTECTION'] = True app.config['SESSION_PROTECTION'] = True
Session(app) 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 **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` - `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
+11 -4
View File
@@ -10,10 +10,9 @@
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
__version__ = '0.0.1' __version__ = '1.0.0'
import os import os
from typing import Optional
from quart import Quart from quart import Quart
@@ -79,8 +78,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)
@@ -91,6 +90,14 @@ 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`")
if config['SESSION_TYPE'] == 'redis': if config['SESSION_TYPE'] == 'redis':
options = { options = {
"redis": config['SESSION_REDIS'], "redis": config['SESSION_REDIS'],
+72 -71
View File
@@ -10,11 +10,11 @@
: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
import asyncio import asyncio
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
@@ -36,7 +36,12 @@ class ServerSideSession(SecureCookieSession):
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 +51,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
@@ -105,8 +88,13 @@ class SessionInterface(QuartSessionInterface):
request: BaseRequestWebsocket request: BaseRequestWebsocket
) -> Optional[SecureCookieSession]: ) -> Optional[SecureCookieSession]:
sid = request.cookies.get(app.session_cookie_name) sid = request.cookies.get(app.session_cookie_name)
if self._config['SESSION_HIJACK_REVERSE_PROXY'] is True: if self._config['SESSION_REVERSE_PROXY'] is True:
addr = request.headers.get('X-Forwarded-For', request.remote_addr) # 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: else:
addr = request.remote_addr addr = request.remote_addr
options = {"sid": sid, "permanent": self.permanent, "addr": addr} options = {"sid": sid, "permanent": self.permanent, "addr": addr}
@@ -127,7 +115,7 @@ 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)
@@ -137,12 +125,14 @@ class SessionInterface(QuartSessionInterface):
except: except:
app.logger.warning(f"Failed to deserialize session " app.logger.warning(f"Failed to deserialize session "
f"data for sid: {sid}. Generating new sid.") f"data for sid: {sid}. Generating new sid.")
app.logger.debug(f"data: {val}")
options['sid'] = self._generate_sid() options['sid'] = self._generate_sid()
return self.session_class(**options) return self.session_class(**options)
prevent_hijack = self._config['SESSION_HIJACK_PROTECTION'] protection = self._config['SESSION_PROTECTION']
if prevent_hijack is True and data.get('_addr', addr) != addr: if protection is True and addr is not None and \
await self._backend_delete(app, self.key_prefix + sid) data.get('_addr', addr) != addr:
await self.delete(key=self.key_prefix + sid, app=app)
options['sid'] = self._generate_sid() options['sid'] = self._generate_sid()
return self.session_class(**options) return self.session_class(**options)
@@ -155,9 +145,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
@@ -165,12 +154,13 @@ 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
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(app.session_cookie_name,
domain=domain, path=path) domain=domain, path=path)
return return
@@ -179,7 +169,7 @@ class SessionInterface(QuartSessionInterface):
expires = self.get_expiration_time(app, session) expires = self.get_expiration_time(app, session)
val = self.serializer.dumps(dict(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: 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:
@@ -191,13 +181,14 @@ class SessionInterface(QuartSessionInterface):
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, app: Quart, key: str):
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:
@@ -224,7 +215,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.
@@ -234,20 +225,24 @@ class RedisSessionInterface(SessionInterface):
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 ``aioredis.Redis.create_redis_pool``)
""" """
if self.redis is None: if self.backend is None:
import aioredis 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): 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):
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.delete(key) return await self.backend.delete(key)
class RedisTrioSessionInterface(SessionInterface): class RedisTrioSessionInterface(SessionInterface):
@@ -264,7 +259,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.
@@ -274,23 +269,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):
@@ -311,14 +309,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 @asyncio.coroutine
def create(self, app: Quart) -> None: def create(self, app: Quart) -> None:
if self.memcached is None: if self.backend is None:
import aiomcache import aiomcache
loop = asyncio.get_running_loop() 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): def _get_memcache_timeout(self, timeout):
""" """
@@ -335,21 +333,24 @@ 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))
return await self.memcached.set(key=key, value=value,
exptime=expiry) 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)
class NullSessionInterface(SessionInterface): class NullSessionInterface(SessionInterface):
+1 -1
View File
@@ -24,7 +24,7 @@ INSTALL_REQUIRES = [
setup( setup(
name='Quart-Session', name='Quart-Session',
version='0.0.1', version='1.0.0',
url='https://github.com/sferdi0/quart-session', url='https://github.com/sferdi0/quart-session',
license='BSD', license='BSD',
author='Sander', author='Sander',