Files
GTI_GERENCIADOR_FISCAL/app/routers/sso.py
T
GustavoandClaude Opus 5.5 161da90e02 feat: bind the GTI Fiscal SSO assertion to the browser (state/nonce)
/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>
2026-09-23 12:21:27 -03:00

78 lines
3.6 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, state: 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)
# Sem state o fluxo não começou no Fiscal (ex.: clique na sidebar): manda
# para o login do Fiscal, que cria o state e devolve para cá.
if not sso.estado_valido(state):
return RedirectResponse(sso.url_login_fiscal(destino), status_code=303)
parametros = urlencode({"state": state, "next": destino})
if request.url.scheme != "https":
publica = os.environ.get("SSO_GERENCIADOR_URL_PUBLICA", "").rstrip("/")
if publica.startswith("https://"):
return RedirectResponse(f"{publica}/sso/fiscal?{parametros}", 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:
return RedirectResponse(f"/login?{urlencode({'next': f'/sso/fiscal?{parametros}'})}", 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, nonce=state)
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