feat: bind the GTI Fiscal SSO assertion to the browser (state/nonce)
/sso/fiscal now requires the state that GTI Fiscal creates when the flow starts and signs it as the "nonce" claim; GTI Fiscal checks it against its own one-time cookie. Without a valid state the panel sends the browser to GTI Fiscal's /login, so the flow always starts there. This prevents someone from pushing an assertion for their own account into another person's browser (login CSRF). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9139a1f6f3
commit
161da90e02
+17
-3
@@ -7,7 +7,8 @@ funcionário e entrega ao Fiscal uma asserção assinada, de vida curta.
|
||||
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.
|
||||
iat/nbf/exp (60 s), jti único (o Fiscal grava para recusar replay) e nonce
|
||||
= state do Fiscal (amarra a asserção ao navegador que iniciou o fluxo).
|
||||
- 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
|
||||
@@ -18,9 +19,10 @@ nem nenhuma outra credencial.
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
@@ -50,6 +52,17 @@ def origem_callback() -> str:
|
||||
return f"{partes.scheme}://{partes.netloc}"
|
||||
|
||||
|
||||
def estado_valido(valor: str | None) -> bool:
|
||||
"""state gerado pelo Fiscal (cookie do navegador que iniciou o fluxo); volta
|
||||
assinado como "nonce", e o Fiscal confere contra o cookie — assim ninguém
|
||||
consegue empurrar a própria asserção para o navegador de outra pessoa."""
|
||||
return bool(valor) and bool(re.fullmatch(r"[A-Za-z0-9_\-]{16,128}", valor))
|
||||
|
||||
|
||||
def url_login_fiscal(proximo: str) -> str:
|
||||
return f"{origem_callback()}/login?{urlencode({'next': proximo})}"
|
||||
|
||||
|
||||
def proximo_seguro(valor: str | None) -> str:
|
||||
"""Só caminho relativo dentro do Fiscal. Qualquer outra coisa vira "/"."""
|
||||
if (
|
||||
@@ -70,7 +83,7 @@ def _chave_privada() -> Ed25519PrivateKey:
|
||||
return Ed25519PrivateKey.from_private_bytes(semente)
|
||||
|
||||
|
||||
def emitir_assercao(funcionario: dict, agora: float | None = None) -> str:
|
||||
def emitir_assercao(funcionario: dict, nonce: str, agora: float | None = None) -> str:
|
||||
momento = int(agora if agora is not None else time.time())
|
||||
claims = {
|
||||
"iss": EMISSOR,
|
||||
@@ -80,6 +93,7 @@ def emitir_assercao(funcionario: dict, agora: float | None = None) -> str:
|
||||
"nbf": momento,
|
||||
"exp": momento + VALIDADE_SEGUNDOS,
|
||||
"jti": str(uuid.uuid4()),
|
||||
"nonce": nonce,
|
||||
"login": funcionario["LOGIN"],
|
||||
"name": funcionario["NOME"],
|
||||
"perfil": funcionario["PERFIL"],
|
||||
|
||||
Reference in New Issue
Block a user