Files
GTI_GERENCIADOR_FISCAL/app/routers/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

73 lines
3.3 KiB
Python

"""
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