"""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)) 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))