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>
113 lines
5.5 KiB
Python
113 lines
5.5 KiB
Python
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"]
|