diff --git a/app/main.py b/app/main.py
index bd09810..be28942 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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
diff --git a/app/routers/sso.py b/app/routers/sso.py
new file mode 100644
index 0000000..ffe317f
--- /dev/null
+++ b/app/routers/sso.py
@@ -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'
GTI Fiscal'
+ f'{mensagem} Voltar ao painel
',
+ 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
diff --git a/app/sso.py b/app/sso.py
new file mode 100644
index 0000000..413d78c
--- /dev/null
+++ b/app/sso.py
@@ -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:" (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")
diff --git a/app/templates/base.html b/app/templates/base.html
index 0f06c34..e20a537 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -43,7 +43,7 @@
Log
-
+
Fiscal
diff --git a/app/templates/sso_fiscal.html b/app/templates/sso_fiscal.html
new file mode 100644
index 0000000..2df738b
--- /dev/null
+++ b/app/templates/sso_fiscal.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+ Abrindo o GTI Fiscal…
+
+
+
+
+
+
+
diff --git a/requirements.txt b/requirements.txt
index a0d80af..1aae0b4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -17,3 +17,6 @@ python-multipart==0.0.20
PyMySQL==1.1.1
python-dotenv==1.0.1
httpx==0.27.2
+# SSO para o GTI Fiscal (app/sso.py): asserção JWT assinada com Ed25519.
+PyJWT==2.14.0
+cryptography==50.0.1
diff --git a/tests/test_sso.py b/tests/test_sso.py
new file mode 100644
index 0000000..c9c82ee
--- /dev/null
+++ b/tests/test_sso.py
@@ -0,0 +1,112 @@
+import base64
+import re
+from urllib.parse import parse_qs, urlsplit
+
+import jwt
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat
+from fastapi.testclient import TestClient
+
+from app import session
+from app.db import funcionarios as db_funcionarios
+from app.db import log as db_log
+from app.main import app
+
+CALLBACK = "https://gtifiscal.gtigrupo.com.br/auth/sso/callback"
+ATIVO = {"ID": 7, "NOME": "Maria Teste", "LOGIN": "maria", "SENHA_HASH": "x", "PERFIL": "PADRAO", "ATIVO": 1}
+
+
+@pytest.fixture
+def chave(monkeypatch):
+ privada = Ed25519PrivateKey.generate()
+ semente = privada.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
+ monkeypatch.setenv("SSO_FISCAL_CHAVE_PRIVADA", base64.urlsafe_b64encode(semente).decode().rstrip("="))
+ monkeypatch.setenv("SSO_FISCAL_CALLBACK_URL", CALLBACK)
+ monkeypatch.setenv("SSO_GERENCIADOR_URL_PUBLICA", "https://gtigerenciador.gtigrupo.com.br")
+ monkeypatch.setattr(db_log, "registrar", lambda *a, **k: None)
+ return privada.public_key()
+
+
+def _cliente(funcionario=ATIVO, logado=True, https=True, monkeypatch=None):
+ monkeypatch.setattr(db_funcionarios, "obter_por_login", lambda login: funcionario if funcionario and login == funcionario["LOGIN"] else None)
+ cliente = TestClient(app, base_url="https://testserver" if https else "http://testserver")
+ if logado:
+ login = funcionario["LOGIN"] if funcionario else "admin"
+ cliente.cookies.set(session.SESSION_COOKIE_NAME, session.criar_token(login, "Maria Teste", "PADRAO"))
+ return cliente
+
+
+def _assercao(resposta):
+ return re.search(r'name="assertion" value="([^"]+)"', resposta.text).group(1)
+
+
+def test_funcionario_ativo_recebe_formulario_com_assercao_valida(chave, monkeypatch):
+ resposta = _cliente(monkeypatch=monkeypatch).get("/sso/fiscal?next=/agents")
+ assert resposta.status_code == 200
+ assert f'action="{CALLBACK}"' in resposta.text and 'name="next" value="/agents"' in resposta.text
+ claims = jwt.decode(_assercao(resposta), chave, algorithms=["EdDSA"], audience="gti-fiscal", issuer="gti-gerenciador", options={"require": ["exp", "iat", "nbf", "iss", "aud", "sub", "jti"]})
+ assert claims["sub"] == "funcionario:7" and claims["login"] == "maria" and claims["perfil"] == "PADRAO"
+ assert claims["exp"] - claims["iat"] == 60
+ assert "password" not in claims and "SENHA_HASH" not in str(claims)
+ csp = resposta.headers["content-security-policy"]
+ assert "form-action https://gtifiscal.gtigrupo.com.br;" in csp and "'self'" not in csp.split("form-action")[1].split(";")[0]
+ assert resposta.headers["cache-control"] == "no-store"
+ # A asserção nunca aparece na URL
+ assert "assertion" not in str(resposta.url)
+
+
+def test_cada_emissao_tem_jti_diferente(chave, monkeypatch):
+ cliente = _cliente(monkeypatch=monkeypatch)
+ a, b = (jwt.decode(_assercao(cliente.get("/sso/fiscal")), chave, algorithms=["EdDSA"], audience="gti-fiscal") for _ in range(2))
+ assert a["jti"] != b["jti"]
+
+
+def test_sem_sessao_vai_para_login_e_volta_ao_sso(chave, monkeypatch):
+ resposta = _cliente(logado=False, monkeypatch=monkeypatch).get("/sso/fiscal?next=/jobs", follow_redirects=False)
+ assert resposta.status_code == 303
+ destino = urlsplit(resposta.headers["location"])
+ assert destino.path == "/login"
+ assert parse_qs(destino.query)["next"] == ["/sso/fiscal?next=%2Fjobs"]
+
+
+def test_funcionario_inativo_nao_recebe_assercao(chave, monkeypatch):
+ resposta = _cliente(funcionario={**ATIVO, "ATIVO": 0}, monkeypatch=monkeypatch).get("/sso/fiscal", follow_redirects=False)
+ assert resposta.status_code == 303 and resposta.headers["location"].startswith("/login")
+ assert "assertion" not in resposta.text
+
+
+def test_conta_de_bootstrap_nao_usa_sso(chave, monkeypatch):
+ monkeypatch.setenv("ADMIN_USER", "admin")
+ resposta = _cliente(funcionario=None, monkeypatch=monkeypatch).get("/sso/fiscal", follow_redirects=False)
+ assert resposta.status_code == 403 and "assertion" not in resposta.text
+
+
+def test_http_redireciona_para_endereco_https_publico(chave, monkeypatch):
+ resposta = _cliente(https=False, monkeypatch=monkeypatch).get("/sso/fiscal?next=/blockers", follow_redirects=False)
+ assert resposta.status_code == 303
+ assert resposta.headers["location"] == "https://gtigerenciador.gtigrupo.com.br/sso/fiscal?next=%2Fblockers"
+
+
+@pytest.mark.parametrize("malicioso", ["//evil.com/x", "https://evil.com", "javascript:alert(1)", "/\\evil.com", "data:text/html,x", "agents", "/a\nb"])
+def test_next_malicioso_vira_raiz(chave, monkeypatch, malicioso):
+ resposta = _cliente(monkeypatch=monkeypatch).get("/sso/fiscal", params={"next": malicioso})
+ assert 'name="next" value="/"' in resposta.text
+
+
+def test_callback_so_https_configurado(chave, monkeypatch):
+ monkeypatch.setenv("SSO_FISCAL_CALLBACK_URL", "http://gtifiscal.gtigrupo.com.br/auth/sso/callback")
+ with pytest.raises(RuntimeError):
+ _cliente(monkeypatch=monkeypatch).get("/sso/fiscal")
+
+
+def test_sem_configuracao_nao_emite(monkeypatch):
+ monkeypatch.delenv("SSO_FISCAL_CHAVE_PRIVADA", raising=False)
+ monkeypatch.setattr(db_log, "registrar", lambda *a, **k: None)
+ resposta = _cliente(monkeypatch=monkeypatch).get("/sso/fiscal")
+ assert resposta.status_code == 503
+
+
+def test_outras_paginas_mantem_csp_padrao(chave, monkeypatch):
+ resposta = _cliente(monkeypatch=monkeypatch).get("/login")
+ assert "form-action 'self'" in resposta.headers["content-security-policy"]