Enforce server-side session idle expiry with sliding TTL
This commit is contained in:
@@ -24,6 +24,21 @@ async def home(ctx: HttpContext):
|
|||||||
Sessions are created lazily: a cookie is only set when the handler modifies the
|
Sessions are created lazily: a cookie is only set when the handler modifies the
|
||||||
session.
|
session.
|
||||||
|
|
||||||
|
## Session expiry
|
||||||
|
|
||||||
|
The cookie sent to the browser has a `Max-Age` (default 14 days), but that is
|
||||||
|
only a client-side hint. The real boundary is the store's server-side TTL,
|
||||||
|
which the middleware keeps in sync with the cookie `Max-Age`.
|
||||||
|
|
||||||
|
For `InMemorySessionStore`, a session expires if it is idle for longer than
|
||||||
|
`max_age`. Active sessions have their expiry slid forward on every access, so
|
||||||
|
a user that keeps visiting stays logged in. If the client ignores the cookie's
|
||||||
|
`Max-Age` and replays an old cookie value, the store rejects the expired
|
||||||
|
session and creates a fresh empty one.
|
||||||
|
|
||||||
|
Set `max_age=None` to disable server-side expiry (and the `Max-Age` cookie
|
||||||
|
attribute) entirely.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- `Session`: dict-like session object with modification tracking
|
- `Session`: dict-like session object with modification tracking
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class SessionMiddleware:
|
|||||||
session_id = self._extract_session_id(scope)
|
session_id = self._extract_session_id(scope)
|
||||||
session: Session
|
session: Session
|
||||||
if session_id is not None:
|
if session_id is not None:
|
||||||
loaded = await self._store.load(session_id)
|
loaded = await self._store.load(session_id, self._max_age)
|
||||||
session = loaded if loaded is not None else Session()
|
session = loaded if loaded is not None else Session()
|
||||||
else:
|
else:
|
||||||
session = Session()
|
session = Session()
|
||||||
@@ -175,4 +175,4 @@ class SessionMiddleware:
|
|||||||
session.set_id(self._store.new_session_id())
|
session.set_id(self._store.new_session_id())
|
||||||
|
|
||||||
if session.id is not None:
|
if session.id is not None:
|
||||||
await self._store.save(session.id, session)
|
await self._store.save(session.id, session, self._max_age)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from secrets import token_urlsafe
|
from secrets import token_urlsafe
|
||||||
from typing import Any, Optional
|
from time import monotonic
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from ._session import Session
|
from ._session import Session
|
||||||
|
|
||||||
@@ -9,13 +10,22 @@ class SessionStore(ABC):
|
|||||||
"""Pluggable backend for session persistence."""
|
"""Pluggable backend for session persistence."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def load(self, session_id: str) -> Optional[Session]:
|
async def load(self, session_id: str, max_age: Optional[int] = None) -> Optional[Session]:
|
||||||
"""Load an existing session, or return ``None`` if unknown/expired."""
|
"""Load an existing session, or return ``None`` if unknown or expired.
|
||||||
|
|
||||||
|
``max_age`` is the idle timeout in seconds. If provided, the store may
|
||||||
|
use it to enforce a server-side expiry and to slide the expiry window
|
||||||
|
on each access.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def save(self, session_id: str, session: Session) -> None:
|
async def save(self, session_id: str, session: Session, max_age: Optional[int] = None) -> None:
|
||||||
"""Persist the session data under ``session_id``."""
|
"""Persist the session data under ``session_id``.
|
||||||
|
|
||||||
|
``max_age`` is the idle timeout in seconds. If provided, the store
|
||||||
|
should record the expiry time as ``now + max_age``.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -36,19 +46,40 @@ class InMemorySessionStore(SessionStore):
|
|||||||
|
|
||||||
Suitable for development and single-process deployments. Session data is
|
Suitable for development and single-process deployments. Session data is
|
||||||
lost when the process exits and is not shared between processes.
|
lost when the process exits and is not shared between processes.
|
||||||
|
|
||||||
|
Sessions can optionally expire server-side after ``max_age`` seconds of
|
||||||
|
inactivity. Active sessions slide the expiry window on each access.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, clock: Callable[[], float] = monotonic) -> None:
|
||||||
self._sessions: dict[str, dict[str, Any]] = {}
|
self._clock = clock
|
||||||
|
self._data: dict[str, dict[str, Any]] = {}
|
||||||
|
self._expires: dict[str, float] = {}
|
||||||
|
|
||||||
async def load(self, session_id: str) -> Optional[Session]:
|
async def load(self, session_id: str, max_age: Optional[int] = None) -> Optional[Session]:
|
||||||
data = self._sessions.get(session_id)
|
data = self._data.get(session_id)
|
||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
expires = self._expires.get(session_id)
|
||||||
|
now = self._clock()
|
||||||
|
if expires is not None and now > expires:
|
||||||
|
self._data.pop(session_id, None)
|
||||||
|
self._expires.pop(session_id, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if max_age is not None and expires is not None:
|
||||||
|
self._expires[session_id] = now + max_age
|
||||||
|
|
||||||
return Session(session_id, data)
|
return Session(session_id, data)
|
||||||
|
|
||||||
async def save(self, session_id: str, session: Session) -> None:
|
async def save(self, session_id: str, session: Session, max_age: Optional[int] = None) -> None:
|
||||||
self._sessions[session_id] = dict(session)
|
self._data[session_id] = dict(session)
|
||||||
|
if max_age is not None:
|
||||||
|
self._expires[session_id] = self._clock() + max_age
|
||||||
|
else:
|
||||||
|
self._expires.pop(session_id, None)
|
||||||
|
|
||||||
async def delete(self, session_id: str) -> None:
|
async def delete(self, session_id: str) -> None:
|
||||||
self._sessions.pop(session_id, None)
|
self._data.pop(session_id, None)
|
||||||
|
self._expires.pop(session_id, None)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -9,6 +10,17 @@ from kaya.session import InMemorySessionStore, Session, SessionMiddleware, Sessi
|
|||||||
from kaya.session._cookie import format_set_cookie, parse_cookie_value
|
from kaya.session._cookie import format_set_cookie, parse_cookie_value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self, start: float = 0.0) -> None:
|
||||||
|
self._now = start
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self._now
|
||||||
|
|
||||||
|
def advance(self, seconds: float) -> None:
|
||||||
|
self._now += seconds
|
||||||
|
|
||||||
|
|
||||||
class SessionTest(unittest.TestCase):
|
class SessionTest(unittest.TestCase):
|
||||||
app: KayaApp
|
app: KayaApp
|
||||||
store: InMemorySessionStore
|
store: InMemorySessionStore
|
||||||
@@ -123,7 +135,7 @@ class SessionTest(unittest.TestCase):
|
|||||||
cookies_before = {c.name: c.value for c in client.cookies.jar}
|
cookies_before = {c.name: c.value for c in client.cookies.jar}
|
||||||
old_id = cookies_before.get('session_id')
|
old_id = cookies_before.get('session_id')
|
||||||
self.assertIsNotNone(old_id)
|
self.assertIsNotNone(old_id)
|
||||||
self.assertIn(old_id, self.store._sessions)
|
self.assertIn(old_id, self.store._data)
|
||||||
|
|
||||||
r = await client.get('/rotate')
|
r = await client.get('/rotate')
|
||||||
self.assertEqual(200, r.status_code)
|
self.assertEqual(200, r.status_code)
|
||||||
@@ -133,8 +145,8 @@ class SessionTest(unittest.TestCase):
|
|||||||
new_id = cookies_after.get('session_id')
|
new_id = cookies_after.get('session_id')
|
||||||
self.assertIsNotNone(new_id)
|
self.assertIsNotNone(new_id)
|
||||||
self.assertNotEqual(old_id, new_id)
|
self.assertNotEqual(old_id, new_id)
|
||||||
self.assertNotIn(old_id, self.store._sessions)
|
self.assertNotIn(old_id, self.store._data)
|
||||||
self.assertIn(new_id, self.store._sessions)
|
self.assertIn(new_id, self.store._data)
|
||||||
|
|
||||||
r = await client.get('/read')
|
r = await client.get('/read')
|
||||||
self.assertEqual('visits: 1', r.text)
|
self.assertEqual('visits: 1', r.text)
|
||||||
@@ -148,6 +160,37 @@ class SessionTest(unittest.TestCase):
|
|||||||
self.assertEqual('visits: 1', r.text)
|
self.assertEqual('visits: 1', r.text)
|
||||||
self.assertIn('Set-Cookie', r.headers)
|
self.assertIn('Set-Cookie', r.headers)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_stale_cookie_cannot_access_old_data(self) -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
store = InMemorySessionStore(clock=clock)
|
||||||
|
app = KayaApp()
|
||||||
|
session_app = SessionMiddleware(app, store, max_age=60)
|
||||||
|
|
||||||
|
@session_app.GET('/')
|
||||||
|
async def home(ctx: HttpContext) -> None:
|
||||||
|
ctx.session['secret'] = 'super-sensitive'
|
||||||
|
await ctx.send_str(200, 'ok')
|
||||||
|
|
||||||
|
@session_app.GET('/read')
|
||||||
|
async def read(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_str(200, ctx.session.get('secret', 'none'))
|
||||||
|
|
||||||
|
transport = httpx.ASGITransport(app=session_app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||||
|
r = await client.get('/')
|
||||||
|
self.assertEqual('ok', r.text)
|
||||||
|
old_id = client.cookies['session_id']
|
||||||
|
self.assertIn(old_id, store._data)
|
||||||
|
self.assertIn(old_id, store._expires)
|
||||||
|
|
||||||
|
clock.advance(61)
|
||||||
|
|
||||||
|
r = await client.get('/read')
|
||||||
|
self.assertEqual('none', r.text)
|
||||||
|
self.assertNotIn(old_id, store._data)
|
||||||
|
self.assertNotIn(old_id, store._expires)
|
||||||
|
|
||||||
|
|
||||||
class SessionUnitTest(unittest.TestCase):
|
class SessionUnitTest(unittest.TestCase):
|
||||||
|
|
||||||
@@ -202,9 +245,8 @@ class SessionUnitTest(unittest.TestCase):
|
|||||||
store = InMemorySessionStore()
|
store = InMemorySessionStore()
|
||||||
session_id = store.new_session_id()
|
session_id = store.new_session_id()
|
||||||
session = Session(session_id, {'a': 1})
|
session = Session(session_id, {'a': 1})
|
||||||
self.assertIsNone(store._sessions.get(session_id))
|
self.assertIsNone(store._data.get(session_id))
|
||||||
|
|
||||||
import asyncio
|
|
||||||
asyncio.run(store.save(session_id, session))
|
asyncio.run(store.save(session_id, session))
|
||||||
|
|
||||||
loaded = asyncio.run(store.load(session_id))
|
loaded = asyncio.run(store.load(session_id))
|
||||||
@@ -215,6 +257,53 @@ class SessionUnitTest(unittest.TestCase):
|
|||||||
asyncio.run(store.delete(session_id))
|
asyncio.run(store.delete(session_id))
|
||||||
self.assertIsNone(asyncio.run(store.load(session_id)))
|
self.assertIsNone(asyncio.run(store.load(session_id)))
|
||||||
|
|
||||||
|
def test_in_memory_store_expires_after_max_age(self) -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
store = InMemorySessionStore(clock=clock)
|
||||||
|
session_id = store.new_session_id()
|
||||||
|
asyncio.run(store.save(session_id, Session(session_id, {'a': 1}), max_age=60))
|
||||||
|
clock.advance(61)
|
||||||
|
self.assertIsNone(asyncio.run(store.load(session_id, max_age=60)))
|
||||||
|
self.assertIsNone(store._data.get(session_id))
|
||||||
|
self.assertIsNone(store._expires.get(session_id))
|
||||||
|
|
||||||
|
def test_in_memory_store_slides_expiry_on_load(self) -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
store = InMemorySessionStore(clock=clock)
|
||||||
|
session_id = store.new_session_id()
|
||||||
|
asyncio.run(store.save(session_id, Session(session_id, {'a': 1}), max_age=60))
|
||||||
|
clock.advance(30)
|
||||||
|
loaded = asyncio.run(store.load(session_id, max_age=60))
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
assert loaded is not None
|
||||||
|
self.assertEqual(1, loaded['a'])
|
||||||
|
# Without sliding, the session would expire at t=60. With sliding it is now valid until t=90.
|
||||||
|
clock.advance(35)
|
||||||
|
loaded = asyncio.run(store.load(session_id, max_age=60))
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
assert loaded is not None
|
||||||
|
self.assertEqual(1, loaded['a'])
|
||||||
|
|
||||||
|
def test_in_memory_store_no_expiry_without_max_age(self) -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
store = InMemorySessionStore(clock=clock)
|
||||||
|
session_id = store.new_session_id()
|
||||||
|
asyncio.run(store.save(session_id, Session(session_id, {'a': 1})))
|
||||||
|
clock.advance(1000000)
|
||||||
|
loaded = asyncio.run(store.load(session_id))
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
assert loaded is not None
|
||||||
|
self.assertEqual(1, loaded['a'])
|
||||||
|
|
||||||
|
def test_in_memory_store_invalidate_removes_expiry(self) -> None:
|
||||||
|
clock = FakeClock()
|
||||||
|
store = InMemorySessionStore(clock=clock)
|
||||||
|
session_id = store.new_session_id()
|
||||||
|
asyncio.run(store.save(session_id, Session(session_id, {'a': 1}), max_age=60))
|
||||||
|
asyncio.run(store.delete(session_id))
|
||||||
|
self.assertIsNone(store._data.get(session_id))
|
||||||
|
self.assertIsNone(store._expires.get(session_id))
|
||||||
|
|
||||||
|
|
||||||
class CookieUtilTest(unittest.TestCase):
|
class CookieUtilTest(unittest.TestCase):
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user