feat: token de sessao assinado (app/session.py)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-08-13 20:25:18 -03:00
co-authored by Claude Sonnet 5
parent 361c2ca770
commit 95b1da7c10
2 changed files with 115 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
"""
Token de sessão de login — cookie "painel_session" (ver app/auth.py e
app/routers/auth.py). Stateless: nada gravado no banco, a validade inteira
depende da assinatura HMAC-SHA256 com SESSION_SECRET (app/main.py define a
variável de ambiente; se ausente, um valor aleatório é gerado em memória no
start do processo — reiniciar sem SESSION_SECRET fixo derruba as sessões
ativas, forçando login de novo, efeito colateral aceito).
Formato do token: "<payload em base64url>.<assinatura HMAC em hex>"
Payload (antes de codificar): JSON com login, nome, perfil e validade
("exp", timestamp Unix).
Nunca lança exceção para entrada inválida — ler_token() sempre devolve
None nesse caso, para o chamador (app/auth.py) tratar como "não logado".
"""
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
SESSION_COOKIE_NAME = "painel_session"
VALIDADE_SEGUNDOS = 12 * 60 * 60 # 12 horas
_SECRET_EM_MEMORIA = secrets.token_bytes(32)
def _chave_secreta() -> bytes:
configurada = os.environ.get("SESSION_SECRET", "")
if configurada:
return configurada.encode("utf-8")
return _SECRET_EM_MEMORIA
def _assinar(payload_b64: bytes) -> str:
return hmac.new(_chave_secreta(), payload_b64, hashlib.sha256).hexdigest()
def criar_token(login: str, nome: str, perfil: str, agora: float | None = None) -> str:
momento = agora if agora is not None else time.time()
payload = {"login": login, "nome": nome, "perfil": perfil, "exp": momento + VALIDADE_SEGUNDOS}
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
assinatura = _assinar(payload_b64)
return payload_b64.decode("ascii") + "." + assinatura
def ler_token(token: str | None, agora: float | None = None) -> dict | None:
if not token or "." not in token:
return None
payload_b64_str, assinatura_recebida = token.rsplit(".", 1)
payload_b64 = payload_b64_str.encode("ascii")
assinatura_esperada = _assinar(payload_b64)
if not hmac.compare_digest(assinatura_recebida, assinatura_esperada):
return None
try:
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
except (ValueError, UnicodeDecodeError):
return None
momento = agora if agora is not None else time.time()
if momento > payload.get("exp", 0):
return None
return {"login": payload["login"], "nome": payload["nome"], "perfil": payload["perfil"]}
+47
View File
@@ -0,0 +1,47 @@
import time
from app import session
def test_criar_e_ler_token_valido():
token = session.criar_token("fulano", "Fulano da Silva", "ADMIN")
dados = session.ler_token(token)
assert dados == {"login": "fulano", "nome": "Fulano da Silva", "perfil": "ADMIN"}
def test_ler_token_none_retorna_none():
assert session.ler_token(None) is None
def test_ler_token_vazio_retorna_none():
assert session.ler_token("") is None
def test_ler_token_sem_ponto_retorna_none():
assert session.ler_token("lixo-sem-formato") is None
def test_ler_token_assinatura_adulterada_retorna_none():
token = session.criar_token("fulano", "Fulano", "PADRAO")
payload, assinatura = token.rsplit(".", 1)
token_adulterado = payload + "." + ("0" if assinatura[0] != "0" else "1") + assinatura[1:]
assert session.ler_token(token_adulterado) is None
def test_ler_token_payload_adulterado_retorna_none():
token = session.criar_token("fulano", "Fulano", "PADRAO")
payload, assinatura = token.rsplit(".", 1)
token_adulterado = payload + "X." + assinatura
assert session.ler_token(token_adulterado) is None
def test_ler_token_expirado_retorna_none():
agora = time.time()
token = session.criar_token("fulano", "Fulano", "PADRAO", agora=agora - session.VALIDADE_SEGUNDOS - 1)
assert session.ler_token(token, agora=agora) is None
def test_ler_token_no_limite_da_validade_ainda_e_valido():
agora = time.time()
token = session.criar_token("fulano", "Fulano", "PADRAO", agora=agora - session.VALIDADE_SEGUNDOS + 5)
assert session.ler_token(token, agora=agora) is not None