Initial commit: webmail client (M0/M1)
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>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user