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>
This commit is contained in:
Gustavo
2026-09-23 08:35:35 -03:00
co-authored by Claude Opus 5.5
parent 4f9bea7799
commit 9139a1f6f3
7 changed files with 313 additions and 11 deletions
+14 -10
View File
@@ -12,7 +12,7 @@ from . import session
from .asset_versao import ASSET_VERSAO
from .auth import NaoAutenticado, UsuarioLogado, exigir_login
from .csrf import CSRF_COOKIE_NAME, gerar_token
from .routers import auth, chave_manual, clientes, contatos, funcionarios, log, minha_conta
from .routers import auth, chave_manual, clientes, contatos, funcionarios, log, minha_conta, sso
app = FastAPI(title="Painel de Clientes", docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
@@ -23,6 +23,7 @@ app.include_router(chave_manual.router)
app.include_router(funcionarios.router)
app.include_router(minha_conta.router)
app.include_router(log.router)
app.include_router(sso.router)
@app.exception_handler(NaoAutenticado)
@@ -76,15 +77,18 @@ async def security_headers_middleware(request: Request, call_next):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
# Uma rota pode definir a própria CSP, mais restrita (hoje só /sso/fiscal,
# que precisa de form-action para a origem do Fiscal); as demais recebem esta.
if "content-security-policy" not in response.headers:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
+72
View File
@@ -0,0 +1,72 @@
"""
GET /sso/fiscal — ponto de entrada do item "Fiscal" da sidebar. Com o
funcionário logado e ativo, emite a asserção (app/sso.py) e a entrega ao
callback do Fiscal num POST auto-submetido; sem sessão, manda para o login
normal e volta para cá depois.
Só roda por HTTPS (acesso pelo domínio público, atrás do NPM): por HTTP
redireciona para SSO_GERENCIADOR_URL_PUBLICA. A conta de bootstrap do .env
não tem linha em FUNCIONARIO_PAINEL, portanto nenhum ID estável para virar
identidade no Fiscal — é recusada (o Fiscal tem o próprio admin de emergência).
"""
import os
from urllib.parse import urlencode
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import sso
from ..auth import NaoAutenticado, exigir_login
from ..db import funcionarios as db_funcionarios
from ..db import log as db_log
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _pagina_erro(mensagem: str, status_code: int) -> HTMLResponse:
return HTMLResponse(
f'<!doctype html><meta charset="utf-8"><title>GTI Fiscal</title>'
f'<p style="font-family:system-ui;padding:24px">{mensagem} <a href="/">Voltar ao painel</a></p>',
status_code=status_code,
)
@router.get("/sso/fiscal")
def iniciar_sso_fiscal(request: Request, next: str | None = None):
destino = sso.proximo_seguro(next)
if not sso.configurado():
return _pagina_erro("O acesso ao GTI Fiscal não está configurado neste painel.", 503)
if request.url.scheme != "https":
publica = os.environ.get("SSO_GERENCIADOR_URL_PUBLICA", "").rstrip("/")
if publica.startswith("https://"):
return RedirectResponse(f"{publica}/sso/fiscal?{urlencode({'next': destino})}", status_code=303)
return _pagina_erro("O acesso ao GTI Fiscal só funciona pelo endereço HTTPS do painel.", 400)
try:
usuario = exigir_login(request)
except NaoAutenticado:
volta = f"/sso/fiscal?{urlencode({'next': destino})}"
return RedirectResponse(f"/login?{urlencode({'next': volta})}", status_code=303)
funcionario = db_funcionarios.obter_por_login(usuario.login)
if funcionario is None:
db_log.registrar(str(usuario), "sso_fiscal", "funcionario", usuario.login, False, "conta de bootstrap não usa SSO")
return _pagina_erro("A conta de bootstrap não acessa o GTI Fiscal — entre com seu usuário de funcionário.", 403)
assercao = sso.emitir_assercao(funcionario)
db_log.registrar(str(usuario), "sso_fiscal", "funcionario", str(funcionario["ID"]), True)
resposta = templates.TemplateResponse(
request, "sso_fiscal.html", {"acao": sso.url_callback(), "assercao": assercao, "proximo": destino}
)
# A CSP padrão (main.py) só permite form-action 'self'; esta página precisa
# postar exatamente para a origem do Fiscal e para nenhum outro lugar.
resposta.headers["Content-Security-Policy"] = (
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
f"form-action {sso.origem_callback()}; frame-ancestors 'none'; base-uri 'none'"
)
resposta.headers["Cache-Control"] = "no-store"
resposta.headers["Referrer-Policy"] = "no-referrer"
return resposta
+87
View File
@@ -0,0 +1,87 @@
"""
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")
+1 -1
View File
@@ -43,7 +43,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4"/><path d="M9 15h6M9 11h10M9 7h4"/></svg>
<span>Log</span>
</a>
<a href="https://gtifiscal.gtigrupo.com.br" target="_blank" rel="noopener">
<a href="/sso/fiscal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span>Fiscal</span>
</a>
+24
View File
@@ -0,0 +1,24 @@
<!-- app/templates/sso_fiscal.html — entrega a asserção ao Fiscal por POST (nunca na URL) -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Abrindo o GTI Fiscal…</title>
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #121214; color: #f4f4f5; }
p { color: #9a9aa2; }
button { padding: 10px 16px; border-radius: 8px; border: 0; background: #F5821F; color: #17171a; font-weight: 700; cursor: pointer; }
</style>
</head>
<body>
<form method="post" action="{{ acao }}" id="sso-fiscal">
<input type="hidden" name="assertion" value="{{ assercao }}">
<input type="hidden" name="next" value="{{ proximo }}">
<p>Abrindo o GTI Fiscal…</p>
<noscript><button type="submit">Continuar</button></noscript>
</form>
<script>document.getElementById("sso-fiscal").submit();</script>
</body>
</html>