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:
co-authored by
Claude Opus 5.5
parent
4f9bea7799
commit
9139a1f6f3
@@ -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
|
||||
Reference in New Issue
Block a user