Sessões server-side¶
Desde v0.34.0 o SDK fornece o ciclo completo de autenticação baseada em sessões server-side — alternativa ao fluxo JWT do UserAuthService. O cookie carrega apenas um id opaco; estado real (user_id, TTL, metadata do cliente, payload da app) vive num SessionStore plugável (Memory pra dev/testes, Redis pra produção).
JWT vs sessões server-side¶
| Aspecto | JWT (UserAuthService) |
Sessions (SessionAuth) |
|---|---|---|
| Estado | stateless (no cliente) | stateful (no Redis/Memory) |
| Cookie size | ~500 B – 1 KB (JWT) | 64 B (opaque id) |
| Revogação | espera token expirar (~1h típico) | instantânea (delete da row) |
| Logout global | precisa de blocklist ou rotacionar JWT_SECRET | revoke_all(user_id) num call |
| CSRF | precisa de header bearer custom | cookie HttpOnly + double-submit token nativo |
| Multi-device UI ("logado em 3 lugares") | sem state → impossível direto | list_sessions(user_id) trivial |
| Multi-replica | trivial (verify-only) | exige Redis (ou sticky) |
| Latência por request | nenhuma DB (decode CPU) | 1 hit Redis (~0.5ms LAN) |
Use sessions quando: SaaS B2C, painel admin, fluxo SSR (HTMX/Django-like), revogação instantânea é requisito, UI de "dispositivos ativos" é feature.
Use JWT quando: APIs públicas consumidas por mobile/SPA, microservices stateless, escala alta sem dependência de Redis.
Conteúdo da receita¶
- Setup mínimo — wire de 4 objetos (
SessionStore,SessionAuth,SessionMiddleware,make_session_router). - Endpoints bundled — login / logout / me / list / revoke.
- Settings (
SessionSettings) — flags + defaults. - Stores —
MemorySessionStorevsRedisSessionStore. - Como o middleware injeta a sessão —
request.state.session+ dependency. - Segurança — anti-fixation rotation, hash-at-rest, anti-enumeração, CSRF.
- Trade-offs e quando NÃO usar — multi-replica, mobile, edge.
Setup mínimo¶
Quatro objetos compõem o fluxo. Mount uma vez no app.py:
# src/api/app.py
from fastapi import FastAPI
from redis.asyncio import Redis
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
RedisSessionStore,
SessionAuth,
SessionMiddleware,
SessionSettings,
make_session_router,
register_exception_handlers,
)
from src.core.settings import settings
from src.db.models import UserModel
db = AsyncDatabaseManager(settings.DATABASE_URL)
session_settings = SessionSettings()
session_store = RedisSessionStore(
Redis.from_url(settings.REDIS_URL, decode_responses=True),
prefix=f"{settings.APP_NAME}:",
)
session_auth = SessionAuth(
user_model=UserModel,
store=session_store,
settings=session_settings,
)
def create_app() -> FastAPI:
app = FastAPI(title="my-app")
register_exception_handlers(app)
# Order matters: middleware ANTES dos routers.
app.add_middleware(
SessionMiddleware,
session_auth=session_auth,
settings=session_settings,
)
app.include_router(
make_session_router(
session_auth,
session_factory=db.session_dependency,
)
)
return app
app = create_app()
Por que Redis.from_url aqui, e não AsyncRedisManager?
Este client alimenta um middleware (SessionMiddleware), montado no
create_app (síncrono), antes de qualquer lifespan async rodar.
Redis.from_url() é lazy — constrói sem abrir conexão, então serve nesse
ponto.
Se o serviço já tem um AsyncRedisManager, prefira cache.client_proxy
(v0.256.0) a abrir um client solto: é um handle estável, construível antes
do connect() e válido através de reconexão, e mantém o disconnect() e o
health_check() do manager. O que não serve aqui é cache.client, que
levanta RuntimeError antes do lifespan. Todos precisam do extra [cache]
(o pacote redis).
Pronto. O usuário faz POST /auth/session/login com email+senha; o SDK seta o cookie HttpOnly+Secure; toda request subsequente que carrega o cookie tem request.state.session populado.
O que cada objeto faz¶
SessionStore(RedisSessionStore/MemorySessionStore) — a camada de persistência. Guarda o estado real da sessão indexado pelo hash SHA-256 do id opaco. É o único objeto que fala com o Redis.SessionAuth— a camada de lógica. Verifica credenciais contra oUserModel, cria (mint), rotaciona e revoga sessões via ostore. Não sabe nada de HTTP.SessionMiddleware— a ponte HTTP → sessão. A cada request lê o cookie, resolve viaSessionAuth/storee popularequest.state.sessionantes de qualquer router rodar. Sem ele,request.state.sessionnunca existe e as dependencies levantamAttributeError.make_session_router— expõe os cinco endpoints bundled (login/logout/me/list/{id}). Recebe o mesmosession_authe umasession_factorypra abrir a sessão de DB no login.
Ordem importa: add_middleware ANTES de include_router
O SessionMiddleware precisa rodar em toda request pra popular request.state.session. Registre-o com app.add_middleware(...) antes de montar os routers via app.include_router(...). Se inverter, os handlers que dependem de request.state.session (ou de make_session_dependency) encontram o atributo ausente e quebram. Mantenha o wire na ordem exata do exemplo acima.
Endpoints¶
Cinco endpoints bundled cobrindo o ciclo todo:
| Método | Path | Body / Output | Comportamento |
|---|---|---|---|
| POST | /auth/session/login |
SessionLoginSchema → SessionResponseSchema |
Verifica bcrypt. Mint nova sessão. Seta Set-Cookie: tempest_session=<id>; HttpOnly; Secure; SameSite=Lax. Se já havia cookie, rotaciona (anti-fixation). |
| POST | /auth/session/logout |
— → 204 No Content |
Revoga a sessão atual + limpa cookie. Idempotente. |
| GET | /auth/session/me |
— → Session |
Retorna a sessão atual (user_id, timestamps, ip, user_agent, data). 401 quando sem cookie. |
| GET | /auth/session/list |
— → list[SessionSummarySchema] |
Lista todas as sessões ativas do usuário (UI "dispositivos ativos"). Marca a atual com is_current=True. |
| DELETE | /auth/session/{id} |
— → 204 No Content |
Revoga uma sessão específica pelo public id (32 chars do hash). Se for a própria, limpa o cookie. |
Settings¶
Mixe SessionSettings na sua Settings:
from tempest_fastapi_sdk import BaseAppSettings, SessionSettings
class Settings(SessionSettings, BaseAppSettings):
pass
# .env
SESSION_TTL_SECONDS=86400 # 24h (default)
SESSION_SLIDING=true # refresh expires_at a cada hit (default)
SESSION_COOKIE_NAME=tempest_session
SESSION_COOKIE_DOMAIN= # None = exato host
SESSION_COOKIE_PATH=/
SESSION_COOKIE_SECURE=true # HTTPS only — false só pra dev HTTP
SESSION_COOKIE_HTTPONLY=true # JavaScript não lê — sempre true
SESSION_COOKIE_SAMESITE=lax # lax / strict / none
SESSION_ROTATE_ON_LOGIN=true # anti-fixation
SESSION_COOKIE_SECURE=false é só pra dev HTTP
O default é true: o browser só envia o cookie sobre HTTPS. Setar false faz o cookie de sessão trafegar em texto claro sobre HTTP — qualquer intermediário na rede captura o id e sequestra a sessão. Use false exclusivamente em localdev sem TLS; nunca em staging ou produção. O mesmo vale pra deixar SESSION_COOKIE_HTTPONLY=true (default) — desligar expõe o cookie a XSS.
Stores¶
MemorySessionStore — dev/testes¶
State no dict do processo. Não escala — restart do uvicorn limpa tudo, uma réplica não vê sessões da outra. Use em testes e localdev.
MemorySessionStore não sobrevive a restart nem escala horizontalmente
O estado vive num dict em memória do processo. Todo restart/redeploy do uvicorn desloga todo mundo, e com mais de uma réplica cada worker enxerga só as próprias sessões (o cookie emitido por uma bate 401 na outra). É estritamente pra testes e localdev — em produção use sempre RedisSessionStore.
RedisSessionStore — produção¶
from redis.asyncio import Redis
from tempest_fastapi_sdk import RedisSessionStore
from src.core.settings import settings
session_store = RedisSessionStore(
Redis.from_url(settings.REDIS_URL, decode_responses=True),
prefix="myapp:",
)
Schema interno:
myapp:sess:<sha256-hex>— JSON daSession, TTL =expires_at - nowmyapp:user:<user-uuid>— Redis SET de hashes das sessões do user (índice pralist_by_user/delete_by_user)
TTL é gerenciado pelo Redis automaticamente — sem janitor process.
RedisSessionStore exige o extra [cache]
O RedisSessionStore depende do client async redis, que só é instalado com o extra [cache]. Como ele alimenta um middleware, receba um Redis.from_url(...) (lazy) ou o AsyncRedisManager.client_proxy — nunca o cache.client, que levanta antes do lifespan. Instale com uv add "tempest-fastapi-sdk[cache]" (some auth etc. conforme o serviço). O MemorySessionStore não precisa de extra nenhum.
Customizado¶
Qualquer classe que implemente o protocol SessionStore (5 métodos async) plugga out-of-the-box — DynamoDB, Postgres table, Memcached, etc.
Middleware¶
SessionMiddleware roda antes dos routers, lê o cookie, resolve via store, popula request.state.session:
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import Session, make_session_dependency
router = APIRouter()
@router.get("/profile")
async def profile(session: Session = Depends(make_session_dependency(required=True))):
return {"user_id": str(session.user_id), "data": session.data}
required=True (default): sem cookie → UnauthorizedException → resposta 401 no envelope SDK.
required=False: handler aceita ambos — session é Session | None. Use em endpoints públicos que adaptam conteúdo pra logged-in users.
Acesso direto (sem dependency):
from fastapi import APIRouter, Request
from tempest_fastapi_sdk import Session
router = APIRouter()
@router.get("/anything")
async def handler(request: Request) -> dict:
s: Session | None = request.state.session
return {"authenticated": s is not None}
Segurança¶
- Hash at rest: cookie carrega plaintext de 32 bytes URL-safe; store guarda só SHA-256. Vazamento da tabela
sessionsnão dá login. - Session-fixation prevention:
SESSION_ROTATE_ON_LOGIN=True(default) — login bem-sucedido sempre mint id novo, mesmo que o browser já tivesse um. Fecha o vetor "atacante planta cookie conhecido antes do login". - CSRF nativo via SameSite:
SESSION_COOKIE_SAMESITE=lax(default) bloqueia POST cross-site. Combine comCSRFMiddlewarepra GET-state-changing endpoints e form-submission. - HttpOnly + Secure:
SESSION_COOKIE_HTTPONLY=True+SESSION_COOKIE_SECURE=Truepor default. JavaScript não lê (anti-XSS); browser não envia em HTTP. - Sliding TTL com floor:
SESSION_SLIDING=True(default) refresh a cada hit, mascreated_atpermanece — você pode forçar logout absoluto após N dias via job que limpa rows comcreated_at < now - 30d. - Anti-enumeração:
/auth/session/loginrejeita email-errado e senha-errada com o mesmoUnauthorizedException+ mesmo timing approximado (bcrypt sempre roda). - Revogação instantânea:
revoke_all(user_id)no password-change / suspeita de compromisso → logout em todos os dispositivos no próximo request.
Trade-offs¶
Quando NÃO usar:
- API pública pra mobile — apps nativos não dão atenção a cookies; bearer JWT no header
Authorizationcontinua melhor. - Microservices stateless — cada réplica decode JWT sem hit em DB. Sessions exige Redis compartilhado.
- Edge/CDN auth — Cloudflare Workers etc. validam JWT no edge sem chegar no origin. Session exige roundtrip ao backend.
Quando combinar JWT + Session:
Possível. SPA web usa cookie de sessão; mobile do mesmo backend usa UserAuthService.login → JWT. Os dois flows coexistem sem conflito — UserAuthService e SessionAuth falam com o mesmo UserModel, diferem só no pós-verify (mint JWT vs mint Session).
Recap¶
- Sessão server-side é a alternativa ao JWT quando revogação instantânea é requisito: o cookie carrega só um id opaco, e o estado vive no store.
- Quatro objetos compõem o fluxo —
SessionStore,SessionAuth,SessionMiddlewareemake_session_router—, montados uma vez noapp.py. - Cinco endpoints bundled cobrem o ciclo inteiro, e o middleware popula
request.state.sessionantes de qualquer router. - O cookie leva plaintext; o store guarda só SHA-256. Vazar a tabela de sessões não dá login em ninguém.
MemorySessionStoreserve dev e teste; troque o store, não o resto do wiring, para ir a Redis ou banco.
Próximos passos¶
- Auth flow » — fluxo JWT bundled (signup / activate / reset). Sessions cobre só login/logout.
- Segurança » —
CSRFMiddlewarepra blindar POST contra ataques cross-site mesmo com SameSite=lax. - Cache » —
AsyncRedisManagere oclient_proxy, que é o handle certo para store de middleware como oRedisSessionStore.