Add OIDC back-channel logout with session indexes
CI / Build and publish packages (push) Failing after 14s
CI / Build and publish packages (push) Failing after 14s
This commit is contained in:
20 files changed
+1594
-32
No files matched your search
@@ -30,11 +30,21 @@ Sessions are stored under keys with the prefix `kaya:session:` (configurable
|
||||
via the `prefix` argument). Server-side expiry uses Redis key TTLs and slides
|
||||
on each access when the session mixin passes a `max_age`.
|
||||
|
||||
## Session indexes
|
||||
|
||||
`RedisSessionStore` supports `Session.add_index()`,
|
||||
`SessionStore.find_sessions()`, and `delete_by_index()`. Reverse memberships
|
||||
are Redis sets updated in the same transactional pipeline as session saves.
|
||||
They are removed on deletion, and stale members are pruned during lookups.
|
||||
Bucket keys use a SHA-256 digest of the index name and value.
|
||||
|
||||
## Serialization
|
||||
|
||||
Session data is serialized with `pickle` by default, so arbitrary Python
|
||||
objects can be stored. A different serializer can be plugged in via the
|
||||
`dumps`/`loads` arguments:
|
||||
objects can be stored. The serialized value is an envelope containing the
|
||||
session dictionary and index metadata; legacy plain-dict values are still
|
||||
accepted. A different serializer can be plugged in via the `dumps`/`loads`
|
||||
arguments:
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import hashlib
|
||||
import pickle
|
||||
from typing import Any, Callable, Optional, cast
|
||||
from typing import Any, Callable, Optional, Sequence, cast
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from kaya.session import Session, SessionStore
|
||||
from kaya.session import IndexKey, Session, SessionStore
|
||||
|
||||
|
||||
class RedisSessionStore(SessionStore):
|
||||
@@ -20,8 +21,14 @@ class RedisSessionStore(SessionStore):
|
||||
Python objects can be stored. Custom serializers can be plugged in via
|
||||
the ``dumps``/``loads`` arguments. Only connect this store to a trusted
|
||||
Redis server, as pickle deserialization of untrusted data is unsafe.
|
||||
|
||||
Reverse session indexes are stored as Redis sets. They are updated in the
|
||||
same pipeline as session saves, cleaned on deletion, and pruned when stale
|
||||
memberships are encountered during a lookup.
|
||||
"""
|
||||
|
||||
supports_indexes = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Redis,
|
||||
@@ -37,6 +44,10 @@ class RedisSessionStore(SessionStore):
|
||||
def _key(self, session_id: str) -> str:
|
||||
return f"{self._prefix}{session_id}"
|
||||
|
||||
def _index_key(self, name: str, value: str) -> str:
|
||||
digest = hashlib.sha256(f"{name}\0{value}".encode()).hexdigest()
|
||||
return f"{self._prefix}idx:{digest}"
|
||||
|
||||
async def load(self, session_id: str, max_age: Optional[int] = None) -> Optional[Session]:
|
||||
key = self._key(session_id)
|
||||
payload = await self._client.get(key)
|
||||
@@ -47,11 +58,65 @@ class RedisSessionStore(SessionStore):
|
||||
await self._client.expire(key, max_age)
|
||||
|
||||
data = self._loads(cast(bytes, payload))
|
||||
return Session(session_id, data)
|
||||
return Session.from_payload(session_id, data)
|
||||
|
||||
async def save(self, session_id: str, session: Session, max_age: Optional[int] = None) -> None:
|
||||
payload = self._dumps(dict(session))
|
||||
await self._client.set(self._key(session_id), payload, ex=max_age)
|
||||
payload = self._dumps(session.to_payload())
|
||||
pipeline = self._client.pipeline(transaction=True)
|
||||
pipeline.set(self._key(session_id), payload, ex=max_age)
|
||||
for name, value in session.indexes:
|
||||
index_key = self._index_key(name, value)
|
||||
pipeline.sadd(index_key, session_id)
|
||||
if max_age is None:
|
||||
pipeline.persist(index_key)
|
||||
else:
|
||||
pipeline.expire(index_key, max_age + 60)
|
||||
for name, value in session.removed_indexes:
|
||||
pipeline.srem(self._index_key(name, value), session_id)
|
||||
await pipeline.execute()
|
||||
|
||||
async def delete(self, session_id: str) -> None:
|
||||
await self._client.delete(self._key(session_id))
|
||||
key = self._key(session_id)
|
||||
payload = await self._client.get(key)
|
||||
indexes: set[IndexKey] = set()
|
||||
if payload is not None:
|
||||
try:
|
||||
indexes = set(Session.from_payload(
|
||||
session_id,
|
||||
self._loads(cast(bytes, payload)),
|
||||
).indexes)
|
||||
except Exception:
|
||||
# A corrupt payload must not prevent session deletion.
|
||||
pass
|
||||
|
||||
pipeline = self._client.pipeline(transaction=True)
|
||||
pipeline.delete(key)
|
||||
for name, value in indexes:
|
||||
pipeline.srem(self._index_key(name, value), session_id)
|
||||
await pipeline.execute()
|
||||
|
||||
async def find_sessions(self, name: str, value: str) -> Sequence[str]:
|
||||
index_key = self._index_key(name, value)
|
||||
members = await self._client.smembers(index_key)
|
||||
session_ids = [
|
||||
member.decode() if isinstance(member, bytes) else str(member)
|
||||
for member in members
|
||||
]
|
||||
if not session_ids:
|
||||
return []
|
||||
|
||||
pipeline = self._client.pipeline(transaction=False)
|
||||
for session_id in session_ids:
|
||||
pipeline.exists(self._key(session_id))
|
||||
exists = await pipeline.execute()
|
||||
|
||||
live: list[str] = []
|
||||
stale: list[str] = []
|
||||
for session_id, found in zip(session_ids, exists):
|
||||
if found:
|
||||
live.append(session_id)
|
||||
else:
|
||||
stale.append(session_id)
|
||||
if stale:
|
||||
await self._client.srem(index_key, *stale)
|
||||
return live
|
||||
@@ -0,0 +1,94 @@
|
||||
import pickle
|
||||
import unittest
|
||||
|
||||
import fakeredis.aioredis
|
||||
from pwo import async_test
|
||||
|
||||
from kaya.session import Session
|
||||
from kaya.session.redis import RedisSessionStore
|
||||
|
||||
|
||||
class RedisSessionIndexTest(unittest.TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.client = fakeredis.aioredis.FakeRedis()
|
||||
self.store = RedisSessionStore(self.client)
|
||||
|
||||
@async_test
|
||||
async def test_save_load_and_find(self) -> None:
|
||||
self.assertTrue(self.store.supports_indexes)
|
||||
await self.store.save('abc', Session('abc', {'user': 'alice'}, indexes={('sub', 'alice')}))
|
||||
loaded = await self.store.load('abc')
|
||||
assert loaded is not None
|
||||
self.assertEqual({'user': 'alice'}, dict(loaded))
|
||||
self.assertEqual(frozenset({('sub', 'alice')}), loaded.indexes)
|
||||
self.assertEqual(['abc'], await self.store.find_sessions('sub', 'alice'))
|
||||
|
||||
@async_test
|
||||
async def test_rotation_preserves_index(self) -> None:
|
||||
await self.store.save('old', Session('old', indexes={('sub', 'alice')}))
|
||||
loaded = await self.store.load('old')
|
||||
assert loaded is not None
|
||||
loaded.set_id('new')
|
||||
await self.store.delete('old')
|
||||
await self.store.save('new', loaded)
|
||||
self.assertEqual(['new'], await self.store.find_sessions('sub', 'alice'))
|
||||
|
||||
@async_test
|
||||
async def test_delete_cleans_reverse_index(self) -> None:
|
||||
await self.store.save('abc', Session('abc', indexes={('sub', 'alice')}))
|
||||
index_key = self.store._index_key('sub', 'alice')
|
||||
self.assertEqual({b'abc'}, await self.client.smembers(index_key))
|
||||
await self.store.delete('abc')
|
||||
self.assertEqual(set(), await self.client.smembers(index_key))
|
||||
|
||||
@async_test
|
||||
async def test_delete_by_index_deletes_all_sessions(self) -> None:
|
||||
await self.store.save('a', Session('a', indexes={('sub', 'alice')}))
|
||||
await self.store.save('b', Session('b', indexes={('sub', 'alice')}))
|
||||
await self.store.save('c', Session('c', indexes={('sub', 'bob')}))
|
||||
self.assertEqual(2, await self.store.delete_by_index('sub', 'alice'))
|
||||
self.assertIsNone(await self.store.load('a'))
|
||||
self.assertIsNone(await self.store.load('b'))
|
||||
self.assertIsNotNone(await self.store.load('c'))
|
||||
|
||||
@async_test
|
||||
async def test_stale_member_is_pruned(self) -> None:
|
||||
await self.store.save('abc', Session('abc', indexes={('sub', 'alice')}))
|
||||
await self.client.delete('kaya:session:abc')
|
||||
self.assertEqual([], await self.store.find_sessions('sub', 'alice'))
|
||||
index_key = self.store._index_key('sub', 'alice')
|
||||
self.assertEqual(set(), await self.client.smembers(index_key))
|
||||
|
||||
@async_test
|
||||
async def test_index_ttl_tracks_session_max_age(self) -> None:
|
||||
await self.store.save(
|
||||
'abc',
|
||||
Session('abc', indexes={('sub', 'alice')}),
|
||||
max_age=60,
|
||||
)
|
||||
index_key = self.store._index_key('sub', 'alice')
|
||||
ttl = await self.client.ttl(index_key)
|
||||
self.assertGreater(ttl, 60)
|
||||
self.assertLessEqual(ttl, 120)
|
||||
|
||||
@async_test
|
||||
async def test_legacy_payload_is_upgraded_on_save(self) -> None:
|
||||
await self.client.set('kaya:session:abc', pickle.dumps({'foo': 'bar'}))
|
||||
loaded = await self.store.load('abc')
|
||||
assert loaded is not None
|
||||
self.assertEqual({'foo': 'bar'}, dict(loaded))
|
||||
self.assertEqual(frozenset(), loaded.indexes)
|
||||
|
||||
loaded.add_index('sub', 'alice')
|
||||
await self.store.save('abc', loaded)
|
||||
raw = await self.client.get('kaya:session:abc')
|
||||
assert raw is not None
|
||||
payload = pickle.loads(raw)
|
||||
self.assertEqual(1, payload['__kaya_session_v1__'])
|
||||
self.assertEqual({'foo': 'bar'}, payload['data'])
|
||||
self.assertEqual(['abc'], await self.store.find_sessions('sub', 'alice'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -89,7 +89,13 @@ class RedisSessionStoreTest(unittest.TestCase):
|
||||
loads=lambda b: json.loads(b.decode('utf-8')),
|
||||
)
|
||||
await store.save('abc', Session('abc', {'foo': 'bar'}))
|
||||
self.assertEqual(b'{"foo": "bar"}', await self.client.get('kaya:session:abc'))
|
||||
raw = await self.client.get('kaya:session:abc')
|
||||
assert raw is not None
|
||||
self.assertEqual({
|
||||
'__kaya_session_v1__': 1,
|
||||
'data': {'foo': 'bar'},
|
||||
'indexes': [],
|
||||
}, json.loads(raw))
|
||||
loaded = await store.load('abc')
|
||||
self.assertIsNotNone(loaded)
|
||||
assert loaded is not None
|
||||
|
||||
Reference in new issue
Block a user