From 9139a1f6f301c7e3691c2fa4abb3d1bfc3b09ca3 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 23 Sep 2026 08:35:35 -0300 Subject: [PATCH] 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: (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 --- app/main.py | 24 +++++--- app/routers/sso.py | 72 ++++++++++++++++++++++ app/sso.py | 87 ++++++++++++++++++++++++++ app/templates/base.html | 2 +- app/templates/sso_fiscal.html | 24 ++++++++ requirements.txt | 3 + tests/test_sso.py | 112 ++++++++++++++++++++++++++++++++++ 7 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 app/routers/sso.py create mode 100644 app/sso.py create mode 100644 app/templates/sso_fiscal.html create mode 100644 tests/test_sso.py 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… + + + +
+ + +

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