/sso/fiscal now requires the state that GTI Fiscal creates when the flow starts and signs it as the "nonce" claim; GTI Fiscal checks it against its own one-time cookie. Without a valid state the panel sends the browser to GTI Fiscal's /login, so the flow always starts there. This prevents someone from pushing an assertion for their own account into another person's browser (login CSRF). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
3.8 KiB
Python
102 lines
3.8 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), jti único (o Fiscal grava para recusar replay) e nonce
|
|
= state do Fiscal (amarra a asserção ao navegador que iniciou o fluxo).
|
|
- 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 re
|
|
import time
|
|
import uuid
|
|
from urllib.parse import urlencode, 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 estado_valido(valor: str | None) -> bool:
|
|
"""state gerado pelo Fiscal (cookie do navegador que iniciou o fluxo); volta
|
|
assinado como "nonce", e o Fiscal confere contra o cookie — assim ninguém
|
|
consegue empurrar a própria asserção para o navegador de outra pessoa."""
|
|
return bool(valor) and bool(re.fullmatch(r"[A-Za-z0-9_\-]{16,128}", valor))
|
|
|
|
|
|
def url_login_fiscal(proximo: str) -> str:
|
|
return f"{origem_callback()}/login?{urlencode({'next': proximo})}"
|
|
|
|
|
|
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, nonce: str, 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()),
|
|
"nonce": nonce,
|
|
"login": funcionario["LOGIN"],
|
|
"name": funcionario["NOME"],
|
|
"perfil": funcionario["PERFIL"],
|
|
}
|
|
return jwt.encode(claims, _chave_privada(), algorithm="EdDSA")
|