Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
4.3 KiB
Python
142 lines
4.3 KiB
Python
"""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 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))
|