Files
webmail-client/backend/app/identity/sessions.py
T

149 lines
4.6 KiB
Python
Raw Normal View History

2026-07-17 23:20:34 +02:00
"""Server-side sessions in Redis (SPEC §4 "Sessions", §9.1 session management).
``sess:{token}`` -> JSON ``{user_id, sid, created, ua}`` with a sliding TTL of
``SESSION_TTL_DAYS`` days. A per-user index ``usess:{user_id}`` (hash sid->token)
lets us list and revoke a user's sessions without exposing the raw token, which is
the session credential itself. Tokens are opaque and high-entropy.
"""
from __future__ import annotations
import json
import secrets
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from app.config import get_settings
from app.redis_client import aw, get_redis
_PREFIX = "sess:"
_INDEX = "usess:"
_TOKEN_BYTES = 32
@dataclass(frozen=True)
class Session:
token: str
user_id: uuid.UUID
sid: str # public session id (safe to expose; not the token)
created: datetime
ua: str
@dataclass(frozen=True)
class SessionInfo:
sid: str
created: datetime
ua: str
current: bool
def _ttl_seconds() -> int:
return get_settings().session_ttl_days * 24 * 3600
def _index_key(user_id: uuid.UUID | str) -> str:
return f"{_INDEX}{user_id}"
async def create_session(user_id: uuid.UUID, user_agent: str) -> str:
"""Create a session for ``user_id`` and return its opaque token."""
token = secrets.token_urlsafe(_TOKEN_BYTES)
sid = secrets.token_hex(8)
payload = {
"user_id": str(user_id),
"sid": sid,
"created": datetime.now(UTC).isoformat(),
"ua": user_agent[:256],
}
redis = get_redis()
ttl = _ttl_seconds()
await redis.set(_PREFIX + token, json.dumps(payload), ex=ttl)
await aw(redis.hset(_index_key(user_id), sid, token))
await redis.expire(_index_key(user_id), ttl)
return token
async def resolve_session(token: str) -> Session | None:
"""Resolve ``token`` to a :class:`Session`, refreshing its sliding TTL."""
if not token:
return None
redis = get_redis()
raw = await redis.get(_PREFIX + token)
if raw is None:
return None
ttl = _ttl_seconds()
data = json.loads(raw)
await redis.expire(_PREFIX + token, ttl) # sliding
await redis.expire(_index_key(data["user_id"]), ttl)
return Session(
token=token,
user_id=uuid.UUID(data["user_id"]),
sid=data.get("sid", ""),
created=datetime.fromisoformat(data["created"]),
ua=data.get("ua", ""),
)
async def destroy_session(token: str) -> None:
"""Delete a session (logout)."""
if not token:
return
redis = get_redis()
raw = await redis.get(_PREFIX + token)
await redis.delete(_PREFIX + token)
if raw is not None:
data = json.loads(raw)
await aw(redis.hdel(_index_key(data["user_id"]), data.get("sid", "")))
async def list_sessions(user_id: uuid.UUID, current_token: str) -> list[SessionInfo]:
"""List a user's active sessions, pruning any whose token has expired."""
redis = get_redis()
mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id)))
out: list[SessionInfo] = []
for sid, token in mapping.items():
raw = await redis.get(_PREFIX + token)
if raw is None:
await aw(redis.hdel(_index_key(user_id), sid)) # prune stale index entry
continue
data = json.loads(raw)
out.append(
SessionInfo(
sid=sid,
created=datetime.fromisoformat(data["created"]),
ua=data.get("ua", ""),
current=(token == current_token),
)
)
out.sort(key=lambda s: s.created, reverse=True)
return out
async def session_alive(user_id: uuid.UUID | str, sid: str) -> bool:
"""Whether the session with public id ``sid`` is still signed in."""
redis = get_redis()
token = await aw(redis.hget(_index_key(user_id), sid))
return token is not None and bool(await redis.exists(_PREFIX + token))
2026-07-17 23:20:34 +02:00
async def revoke_session(user_id: uuid.UUID, sid: str) -> bool:
"""Revoke a specific session by its public sid. Returns False if not found."""
redis = get_redis()
token = await aw(redis.hget(_index_key(user_id), sid))
if token is None:
return False
await redis.delete(_PREFIX + token)
await aw(redis.hdel(_index_key(user_id), sid))
return True
async def destroy_all_sessions(user_id: uuid.UUID) -> None:
"""Revoke every session for a user (e.g. after a password reset)."""
redis = get_redis()
mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id)))
for token in mapping.values():
await redis.delete(_PREFIX + token)
await redis.delete(_index_key(user_id))