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
+14
-10
@@ -12,7 +12,7 @@ from . import session
|
|||||||
from .asset_versao import ASSET_VERSAO
|
from .asset_versao import ASSET_VERSAO
|
||||||
from .auth import NaoAutenticado, UsuarioLogado, exigir_login
|
from .auth import NaoAutenticado, UsuarioLogado, exigir_login
|
||||||
from .csrf import CSRF_COOKIE_NAME, gerar_token
|
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 = FastAPI(title="Painel de Clientes", docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
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(funcionarios.router)
|
||||||
app.include_router(minha_conta.router)
|
app.include_router(minha_conta.router)
|
||||||
app.include_router(log.router)
|
app.include_router(log.router)
|
||||||
|
app.include_router(sso.router)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(NaoAutenticado)
|
@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-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
response.headers["Referrer-Policy"] = "same-origin"
|
response.headers["Referrer-Policy"] = "same-origin"
|
||||||
response.headers["Content-Security-Policy"] = (
|
# Uma rota pode definir a própria CSP, mais restrita (hoje só /sso/fiscal,
|
||||||
"default-src 'self'; "
|
# que precisa de form-action para a origem do Fiscal); as demais recebem esta.
|
||||||
"script-src 'self' 'unsafe-inline'; "
|
if "content-security-policy" not in response.headers:
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
response.headers["Content-Security-Policy"] = (
|
||||||
"img-src 'self' data:; "
|
"default-src 'self'; "
|
||||||
"frame-ancestors 'none'; "
|
"script-src 'self' 'unsafe-inline'; "
|
||||||
"base-uri 'self'; "
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
"form-action 'self'"
|
"img-src 'self' data:; "
|
||||||
)
|
"frame-ancestors 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'"
|
||||||
|
)
|
||||||
if request.url.scheme == "https":
|
if request.url.scheme == "https":
|
||||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -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
|
||||||
+87
@@ -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:<ID>" (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")
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4"/><path d="M9 15h6M9 11h10M9 7h4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4"/><path d="M9 15h6M9 11h10M9 7h4"/></svg>
|
||||||
<span>Log</span>
|
<span>Log</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gtifiscal.gtigrupo.com.br" target="_blank" rel="noopener">
|
<a href="/sso/fiscal">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||||
<span>Fiscal</span>
|
<span>Fiscal</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- app/templates/sso_fiscal.html — entrega a asserção ao Fiscal por POST (nunca na URL) -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>Abrindo o GTI Fiscal…</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #121214; color: #f4f4f5; }
|
||||||
|
p { color: #9a9aa2; }
|
||||||
|
button { padding: 10px 16px; border-radius: 8px; border: 0; background: #F5821F; color: #17171a; font-weight: 700; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form method="post" action="{{ acao }}" id="sso-fiscal">
|
||||||
|
<input type="hidden" name="assertion" value="{{ assercao }}">
|
||||||
|
<input type="hidden" name="next" value="{{ proximo }}">
|
||||||
|
<p>Abrindo o GTI Fiscal…</p>
|
||||||
|
<noscript><button type="submit">Continuar</button></noscript>
|
||||||
|
</form>
|
||||||
|
<script>document.getElementById("sso-fiscal").submit();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -17,3 +17,6 @@ python-multipart==0.0.20
|
|||||||
PyMySQL==1.1.1
|
PyMySQL==1.1.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
httpx==0.27.2
|
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
|
||||||
|
|||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user