diff --git a/app/session.py b/app/session.py new file mode 100644 index 0000000..bdf542c --- /dev/null +++ b/app/session.py @@ -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 (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"]} diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..3075f9b --- /dev/null +++ b/tests/test_session.py @@ -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