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:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
+72
-71
@@ -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,
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user