Files
GTI_GERENCIADOR_FISCAL/app/sso.py
T
GustavoandClaude Opus 5.5 9139a1f6f3 feat: single sign-on from the panel into GTI Fiscal
The sidebar "Fiscal" entry now goes to /sso/fiscal. For a logged-in,
active employee the panel issues a JWT signed with Ed25519 (the private
key exists only here; GTI Fiscal only holds the public key): iss/aud
fixed, sub = funcionario:<ID> (LOGIN is editable), 60 s lifetime, unique
jti that GTI Fiscal records to reject replays. No password, hash or panel
cookie leaves the panel.

The assertion is delivered in an auto-submitted POST, never in the URL,
so it stays out of proxy logs, browser history and Referer. That page
carries its own CSP allowing form-action only to the configured callback
origin; the security-headers middleware now keeps a route-defined CSP.

Without a session the employee goes through the normal login and comes
back; plain HTTP is redirected to the public HTTPS address; `next` only
accepts a relative path; the .env bootstrap account (no stable ID) is
refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 08:35:35 -03:00

88 lines
3.2 KiB
Python

"""
SSO do painel para o GTI Fiscal: o painel é a autoridade da identidade do
funcionário e entrega ao Fiscal uma asserção assinada, de vida curta.
- Assinatura Ed25519 (JWT "EdDSA"). A chave privada existe só aqui
(SSO_FISCAL_CHAVE_PRIVADA, semente de 32 bytes em base64url); o Fiscal só
tem a chave pública, então não consegue emitir asserções.
- Claims: iss/aud fixos, sub = "funcionario:<ID>" (o ID de
FUNCIONARIO_PAINEL — o LOGIN é editável, então não serve de identificador),
iat/nbf/exp (60 s) e jti único, que o Fiscal grava para recusar replay.
- Nenhuma senha, hash ou cookie deste painel sai daqui: só nome, login e
perfil, que o Fiscal usa para exibir e para autorização futura.
- A asserção vai no corpo de um POST auto-submetido (routers/sso.py), nunca
na URL: não aparece em log de proxy, histórico do navegador nem Referer.
SSO_FISCAL_CHAVE_PRIVADA é exclusiva do SSO — não reaproveitar SESSION_SECRET
nem nenhuma outra credencial.
"""
import base64
import os
import time
import uuid
from urllib.parse import urlsplit
import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
EMISSOR = "gti-gerenciador"
AUDIENCIA = "gti-fiscal"
VALIDADE_SEGUNDOS = 60
TAMANHO_MAXIMO_NEXT = 512
def configurado() -> bool:
return bool(os.environ.get("SSO_FISCAL_CHAVE_PRIVADA")) and bool(os.environ.get("SSO_FISCAL_CALLBACK_URL"))
def url_callback() -> str:
"""URL do callback do Fiscal, vinda só de configuração (nunca da requisição).
Recusa qualquer coisa que não seja https://host/caminho, sem credenciais."""
url = os.environ["SSO_FISCAL_CALLBACK_URL"]
partes = urlsplit(url)
if partes.scheme != "https" or not partes.hostname or partes.username or partes.password or partes.query or partes.fragment:
raise RuntimeError("SSO_FISCAL_CALLBACK_URL precisa ser https://host/caminho")
return url
def origem_callback() -> str:
partes = urlsplit(url_callback())
return f"{partes.scheme}://{partes.netloc}"
def proximo_seguro(valor: str | None) -> str:
"""Só caminho relativo dentro do Fiscal. Qualquer outra coisa vira "/"."""
if (
not valor
or len(valor) > TAMANHO_MAXIMO_NEXT
or not valor.startswith("/")
or valor.startswith("//")
or "\\" in valor
or any(ord(c) < 32 or ord(c) == 127 for c in valor)
):
return "/"
return valor
def _chave_privada() -> Ed25519PrivateKey:
bruto = os.environ["SSO_FISCAL_CHAVE_PRIVADA"].strip()
semente = base64.urlsafe_b64decode(bruto + "=" * (-len(bruto) % 4))
return Ed25519PrivateKey.from_private_bytes(semente)
def emitir_assercao(funcionario: dict, agora: float | None = None) -> str:
momento = int(agora if agora is not None else time.time())
claims = {
"iss": EMISSOR,
"aud": AUDIENCIA,
"sub": f"funcionario:{funcionario['ID']}",
"iat": momento,
"nbf": momento,
"exp": momento + VALIDADE_SEGUNDOS,
"jti": str(uuid.uuid4()),
"login": funcionario["LOGIN"],
"name": funcionario["NOME"],
"perfil": funcionario["PERFIL"],
}
return jwt.encode(claims, _chave_privada(), algorithm="EdDSA")