feat: SSO from GTI Gerenciador and the Gerenciador visual identity

Collaborators logged into GTI Gerenciador open GTI Fiscal without a
second password. The panel signs a 60 s JWT (Ed25519; the Hub holds only
the public key); the Hub checks signature, iss/aud/exp/nbf, maximum
lifetime, nonce against a one-time state cookie set when the flow starts
at GET /login (login-CSRF protection) and a single-use jti
(sso_used_assertions). Collaborators are provisioned in admin_users with
auth_source=GTI_GERENCIADOR and no password; a CHECK constraint makes a
passwordless local account or a federated account with a password
impossible (migration 0021). Sessions last FEDERATED_SESSION_SECONDS
(1 h) and are renewed silently through the panel.

The local admin stays as break-glass at /login/local, audited. Logout ends
only the Hub session. Every SSO success/rejection (stable reason code),
logout and local login is audited with the real client IP, never the
token. verify_password now fails closed on a missing hash instead of
raising.

Visual identity: gti-theme.css is a verbatim copy of the panel's
design system (tokens, sidebar, light/dark theme) plus its tema.js,
sidebar.js and logo; control-panel.css keeps only Hub components, all
colours from the panel tokens. Page structure and features unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-23 12:21:26 -03:00
co-authored by Claude Opus 5.5
parent 91a2dda969
commit 64d3fc56e2
21 changed files with 1290 additions and 122 deletions
+7
View File
@@ -38,6 +38,13 @@ class Settings:
web_max_event_context_bytes: int = int(os.getenv("WEB_MAX_EVENT_CONTEXT_BYTES", "12000"))
ui_timezone: str = os.getenv("UI_TIMEZONE", "America/Bahia")
allow_weak_dev_password: bool = os.getenv("ALLOW_WEAK_DEV_PASSWORD", "0") == "1"
# ---- SSO from GTI Gerenciador (central/app/sso.py) ----
# Ed25519 public key (raw 32 bytes, base64url) of the panel that signs the
# assertions; the private key never exists on the Hub. SSO is active only
# when both the key and the panel's start URL are set.
sso_public_key: str = os.getenv("SSO_GERENCIADOR_CHAVE_PUBLICA", "")
sso_start_url: str = os.getenv("SSO_START_URL", "")
federated_session_seconds: int = int(os.getenv("FEDERATED_SESSION_SECONDS", "3600"))
ncm_dataset_url: str = os.getenv("NCM_DATASET_URL", "https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json")
cest_dataset_url: str = os.getenv("CEST_DATASET_URL", "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18")
dataset_storage_path: str = os.getenv("DATASET_STORAGE_PATH", "./data/datasets")
+7 -3
View File
@@ -26,7 +26,10 @@ def hash_password(password: str) -> str:
return "pbkdf2_sha256$240000$%s$%s" % (base64.urlsafe_b64encode(salt).decode(), base64.urlsafe_b64encode(encoded).decode())
def verify_password(password: str, stored: str) -> bool:
def verify_password(password: str, stored: str | None) -> bool:
# Federated (SSO) accounts have no password: always refuse, never raise.
if not isinstance(stored, str) or not isinstance(password, str):
return False
try:
algorithm, rounds, salt, digest = stored.split("$", 3)
candidate = hashlib.pbkdf2_hmac(algorithm.removeprefix("pbkdf2_"), password.encode(), base64.urlsafe_b64decode(salt), int(rounds))
@@ -49,11 +52,12 @@ def create_admin(session, username: str, password: str):
session.add(admin); session.commit(); return admin
def create_session(session, admin: AdminUser, request: Request):
def create_session(session, admin: AdminUser, request: Request, seconds: int | None = None):
raw_token = secrets.token_urlsafe(32)
raw_csrf = secrets.token_urlsafe(32)
now = datetime.now(timezone.utc)
row = WebSession(admin_user_id=admin.id, token_hash=_digest(raw_token), csrf_hash=_digest(raw_csrf), expires_at=now + timedelta(seconds=settings.web_session_seconds), user_agent=request.headers.get("user-agent", "")[:512], remote_addr=request.client.host if request.client else None)
lifetime = seconds if seconds is not None else settings.web_session_seconds
row = WebSession(admin_user_id=admin.id, token_hash=_digest(raw_token), csrf_hash=_digest(raw_csrf), expires_at=now + timedelta(seconds=lifetime),user_agent=request.headers.get("user-agent", "")[:512], remote_addr=request.client.host if request.client else None)
session.add(row); admin.last_login_at = now; session.commit()
return raw_token, raw_csrf
+30 -2
View File
@@ -1,7 +1,7 @@
"""PostgreSQL records owned by the Fiscal Hub."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -303,13 +303,41 @@ class AgentEvent(Base):
class AdminUser(Base):
"""Human principal of the control panel.
LOCAL: break-glass account with a password (create_admin.py).
GTI_GERENCIADOR: collaborator provisioned by SSO (central/app/sso.py) — no
password at all; identified by the panel's immutable subject.
"""
__tablename__ = "admin_users"
__table_args__ = (
UniqueConstraint("external_subject", name="uq_admin_users_external_subject"),
CheckConstraint(
"(auth_source = 'LOCAL' AND password_hash IS NOT NULL AND external_subject IS NULL)"
" OR (auth_source = 'GTI_GERENCIADOR' AND password_hash IS NULL AND external_subject IS NOT NULL)",
name="ck_admin_users_auth_source",
),
)
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(128), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
auth_source: Mapped[str] = mapped_column(String(16), default="LOCAL", server_default="LOCAL")
external_subject: Mapped[str | None] = mapped_column(String(128), nullable=True)
display_name: Mapped[str | None] = mapped_column(String(160), nullable=True)
external_role: Mapped[str | None] = mapped_column(String(32), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class SsoUsedAssertion(Base):
"""jti of every accepted SSO assertion, kept past its expiry to reject replays."""
__tablename__ = "sso_used_assertions"
jti: Mapped[str] = mapped_column(String(64), primary_key=True)
subject: Mapped[str] = mapped_column(String(128))
used_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
class WebSession(Base):
+173
View File
@@ -0,0 +1,173 @@
"""SSO from GTI Gerenciador: the panel is the identity authority.
Flow (docs/PILOT_DEPLOYMENT.md, "Human access"):
GET /login -> Hub sets a one-time state cookie and redirects to the
panel's /sso/fiscal?state=...&next=...
panel -> for a logged-in, active employee signs a JWT (Ed25519,
60 s, unique jti, nonce=state) and auto-POSTs it to
POST /auth/sso/callback -> verify_assertion + check state + consume_jti +
provision -> Hub session of its own.
The Hub only holds the panel's *public* key, never the panel's cookies,
passwords or database. The state cookie binds the assertion to the browser
that started the flow, so nobody can push their own assertion into someone
else's browser (login CSRF).
"""
from __future__ import annotations
import base64
import hmac
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from urllib.parse import urlsplit
import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from . import config
from .models import AdminUser, SsoUsedAssertion
ISSUER = "gti-gerenciador"
AUDIENCE = "gti-fiscal"
AUTH_SOURCE = "GTI_GERENCIADOR"
REQUIRED_CLAIMS = ["iss", "aud", "sub", "iat", "nbf", "exp", "jti", "nonce"]
MAX_LIFETIME_SECONDS = 120 # the panel issues 60 s; anything longer is refused
CLOCK_LEEWAY_SECONDS = 10
STATE_COOKIE = "gti_sso_state"
STATE_COOKIE_PATH = "/auth/sso"
STATE_SECONDS = 300
MAX_NEXT_LENGTH = 512
_SUBJECT = re.compile(r"funcionario:[1-9][0-9]{0,18}")
_TOKEN_CHARS = re.compile(r"[A-Za-z0-9_\-]{16,128}")
class SsoRejected(Exception):
"""Reason codes are stable: they go to the audit log (never the token)."""
def __init__(self, reason: str, subject: str | None = None):
super().__init__(reason)
self.reason = reason
self.subject = subject
@dataclass(frozen=True)
class Identity:
subject: str
login: str
name: str
role: str | None
jti: str
expires_at: datetime
def enabled() -> bool:
return bool(config.settings.sso_public_key) and panel_home() is not None
def panel_home() -> str | None:
"""https origin of GTI Gerenciador, from SSO_START_URL (None if unset/not https)."""
parts = urlsplit(config.settings.sso_start_url or "")
if parts.scheme != "https" or not parts.hostname or parts.username or parts.password:
return None
return f"https://{parts.netloc}/"
def safe_next(value: str | None) -> str:
"""Only a relative path inside the Hub; anything else becomes "/"."""
if (
not value
or len(value) > MAX_NEXT_LENGTH
or not value.startswith("/")
or value.startswith("//")
or "\\" in value
or any(ord(char) < 32 or ord(char) == 127 for char in value)
):
return "/"
return value
def valid_state(value: str | None) -> bool:
return bool(value) and bool(_TOKEN_CHARS.fullmatch(value))
def _public_key() -> Ed25519PublicKey:
raw = config.settings.sso_public_key.strip()
return Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))
def verify_assertion(token: str | None, expected_state: str | None) -> Identity:
if not token or len(token) > 4096:
raise SsoRejected("MISSING_ASSERTION")
try:
claims = jwt.decode(
token, _public_key(), algorithms=["EdDSA"], audience=AUDIENCE, issuer=ISSUER,
leeway=CLOCK_LEEWAY_SECONDS, options={"require": REQUIRED_CLAIMS},
)
except jwt.ExpiredSignatureError as error:
raise SsoRejected("EXPIRED") from error
except jwt.ImmatureSignatureError as error:
raise SsoRejected("NOT_YET_VALID") from error
except jwt.InvalidAudienceError as error:
raise SsoRejected("WRONG_AUDIENCE") from error
except jwt.InvalidIssuerError as error:
raise SsoRejected("WRONG_ISSUER") from error
except jwt.InvalidAlgorithmError as error:
raise SsoRejected("INVALID_ALGORITHM") from error
except jwt.InvalidSignatureError as error:
raise SsoRejected("INVALID_SIGNATURE") from error
except jwt.MissingRequiredClaimError as error:
raise SsoRejected("MISSING_CLAIM") from error
except jwt.InvalidTokenError as error:
raise SsoRejected("MALFORMED") from error
subject = claims["sub"]
if not isinstance(subject, str) or not _SUBJECT.fullmatch(subject):
raise SsoRejected("INVALID_SUBJECT")
if not isinstance(claims["iat"], int) or not isinstance(claims["exp"], int) or claims["exp"] - claims["iat"] > MAX_LIFETIME_SECONDS:
raise SsoRejected("LIFETIME_TOO_LONG", subject)
if not isinstance(claims["jti"], str) or not _TOKEN_CHARS.fullmatch(claims["jti"]) or len(claims["jti"]) > 64:
raise SsoRejected("INVALID_JTI", subject)
nonce = claims["nonce"]
if not valid_state(expected_state) or not isinstance(nonce, str) or not hmac.compare_digest(nonce, expected_state):
raise SsoRejected("STATE_MISMATCH", subject)
login, name, role = claims.get("login"), claims.get("name"), claims.get("perfil")
if not isinstance(login, str) or not login.strip() or not isinstance(name, str):
raise SsoRejected("MISSING_PROFILE", subject)
return Identity(
subject=subject, login=login.strip()[:128], name=name.strip()[:160] or login.strip()[:160],
role=role[:32] if isinstance(role, str) else None, jti=claims["jti"],
expires_at=datetime.fromtimestamp(claims["exp"], tz=timezone.utc),
)
def consume_jti(session, identity: Identity) -> None:
"""Records the jti; a second use of the same assertion is a replay."""
now = datetime.now(timezone.utc)
session.execute(delete(SsoUsedAssertion).where(SsoUsedAssertion.expires_at < now - timedelta(days=1)))
session.add(SsoUsedAssertion(jti=identity.jti, subject=identity.subject, expires_at=identity.expires_at))
try:
session.flush()
except IntegrityError as error:
session.rollback()
raise SsoRejected("REPLAY", identity.subject) from error
def provision(session, identity: Identity) -> AdminUser:
"""Creates the collaborator on first access, refreshes name/role afterwards."""
user = session.scalar(select(AdminUser).where(AdminUser.external_subject == identity.subject))
if user is None:
user = AdminUser(
username=f"gerenciador:{identity.subject.split(':', 1)[1]}", password_hash=None, auth_source=AUTH_SOURCE,
external_subject=identity.subject, display_name=identity.name, external_role=identity.role, is_active=True,
)
session.add(user)
session.flush()
return user
if user.auth_source != AUTH_SOURCE:
raise SsoRejected("SUBJECT_BOUND_TO_LOCAL_ACCOUNT", identity.subject)
if not user.is_active:
raise SsoRejected("DISABLED_IN_FISCAL", identity.subject)
user.display_name, user.external_role = identity.name, identity.role
return user
+74 -12
View File
@@ -1,5 +1,6 @@
"""Server-rendered operator pages for the GTI Fiscal control panel."""
import secrets
from datetime import datetime, timezone
from urllib.parse import parse_qs, urlencode
@@ -8,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from . import config, sso
from .config import settings
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
from .context_keys import ALLOWED_KEYS, ENUMS
@@ -27,9 +29,17 @@ def router(session_factory):
return current_session(request, session)
def render(request, template, context=None, status_code=200):
values = {"request": request, "csrf_token": request.cookies.get("gti_csrf", ""), **(context or {})}
values = {"request": request, "csrf_token": request.cookies.get("gti_csrf", ""), "panel_home": sso.panel_home(), **(context or {})}
return templates.TemplateResponse(request=request, name=template, context=values, status_code=status_code)
def client_ip(request):
return request.client.host if request.client else None
def set_session_cookies(response, token, csrf, seconds):
cfg = config.settings
response.set_cookie(cfg.web_cookie_name, token, max_age=seconds, httponly=True, secure=cfg.web_cookie_secure, samesite="lax")
response.set_cookie("gti_csrf", csrf, max_age=seconds, httponly=False, secure=cfg.web_cookie_secure, samesite="lax")
def query_base(**values):
return urlencode({key: value for key, value in values.items() if value not in (None, "")})
@@ -45,39 +55,91 @@ def router(session_factory):
return None
@routes.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return render(request, "login.html", {"error": None})
def login_page(request: Request, next: str | None = None):
"""Collaborators never see a Hub login: with SSO configured this starts
the flow at GTI Gerenciador (which shows its own login if needed)."""
if not sso.enabled():
return render(request, "login.html", {"error": None, "emergency": False})
cfg = config.settings
state = secrets.token_urlsafe(24)
response = RedirectResponse(f"{cfg.sso_start_url}?{urlencode({'state': state, 'next': sso.safe_next(next)})}", status_code=303)
# Sent back on the cross-site POST from the panel, hence SameSite=None (+Secure).
response.set_cookie(sso.STATE_COOKIE, state, max_age=sso.STATE_SECONDS, path=sso.STATE_COOKIE_PATH, httponly=True,
secure=cfg.web_cookie_secure, samesite="none" if cfg.web_cookie_secure else "lax")
return response
@routes.get("/login/local", response_class=HTMLResponse)
def emergency_login_page(request: Request):
"""Break-glass only (local AdminUser with a password). Not linked from the UI."""
return render(request, "login.html", {"error": None, "emergency": True})
@routes.post("/login")
async def login(request: Request):
values = {key: items[0] for key, items in parse_qs((await request.body()).decode()).items() if items}
values = await form_values(request)
username = values.get("username", "").strip().lower()
with session_factory() as session:
admin = session.scalar(select(AdminUser).where(AdminUser.username == values.get("username", "").strip().lower()))
if not admin or not admin.is_active or not verify_password(values.get("password", ""), admin.password_hash):
return render(request, "login.html", {"error": "Credenciais inválidas"}, 401)
admin = session.scalar(select(AdminUser).where(AdminUser.username == username))
if not admin or not admin.is_active or admin.auth_source != "LOCAL" or not verify_password(values.get("password", ""), admin.password_hash):
record_audit(session, admin_user_id=None, action="LOGIN_LOCAL_FAILURE", target_type="admin_user", target_id=username[:128] or "-", context={"ip": client_ip(request)}, severity="WARNING")
session.commit()
return render(request, "login.html", {"error": "Credenciais inválidas", "emergency": True}, 401)
token, csrf = create_session(session, admin, request)
record_audit(session, admin_user_id=admin.id, action="LOGIN_LOCAL_SUCCESS", target_type="admin_user", target_id=admin.username, context={"ip": client_ip(request)}, severity="WARNING")
session.commit()
response = RedirectResponse("/", status_code=303)
response.set_cookie(settings.web_cookie_name, token, max_age=settings.web_session_seconds, httponly=True, secure=settings.web_cookie_secure, samesite="lax")
response.set_cookie("gti_csrf", csrf, max_age=settings.web_session_seconds, httponly=False, secure=settings.web_cookie_secure, samesite="lax")
set_session_cookies(response, token, csrf, config.settings.web_session_seconds)
return response
@routes.post("/auth/sso/callback")
async def sso_callback(request: Request):
values = await form_values(request)
with session_factory() as session:
try:
if not sso.enabled():
raise sso.SsoRejected("SSO_DISABLED")
identity = sso.verify_assertion(values.get("assertion"), request.cookies.get(sso.STATE_COOKIE))
sso.consume_jti(session, identity)
user = sso.provision(session, identity)
seconds = config.settings.federated_session_seconds
token, csrf = create_session(session, user, request, seconds=seconds)
record_audit(session, admin_user_id=user.id, action="SSO_LOGIN_SUCCESS", target_type="admin_user", target_id=identity.subject,
context={"ip": client_ip(request), "login": identity.login, "role": identity.role})
session.commit()
except sso.SsoRejected as rejected:
session.rollback()
record_audit(session, admin_user_id=None, action="SSO_LOGIN_REJECTED", target_type="sso", target_id=rejected.subject or "-",
context={"ip": client_ip(request), "reason": rejected.reason}, severity="WARNING")
session.commit()
response = render(request, "sso_error.html", {"reason": rejected.reason}, 401)
response.delete_cookie(sso.STATE_COOKIE, path=sso.STATE_COOKIE_PATH)
return response
response = RedirectResponse(sso.safe_next(values.get("next")), status_code=303)
set_session_cookies(response, token, csrf, seconds)
response.delete_cookie(sso.STATE_COOKIE, path=sso.STATE_COOKIE_PATH)
return response
@routes.post("/logout")
async def logout(request: Request):
"""Ends only the Hub session. Coming back through GTI Gerenciador signs in
again without a password (documented in docs/PILOT_DEPLOYMENT.md)."""
with session_factory() as session:
web_session, admin = current_session(request, session)
if web_session:
values = await form_values(request)
require_csrf(web_session, values.get("csrf_token"))
record_audit(session, admin_user_id=admin.id, action="LOGOUT", target_type="admin_user", target_id=admin.external_subject or admin.username, context={"ip": client_ip(request)})
session.delete(web_session); session.commit()
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(settings.web_cookie_name); response.delete_cookie("gti_csrf")
response = render(request, "logged_out.html", {}) if sso.enabled() else RedirectResponse("/login", status_code=303)
response.delete_cookie(config.settings.web_cookie_name); response.delete_cookie("gti_csrf")
return response
def page_auth(request):
session = session_factory()
web_session, admin = current_session(request, session)
if not admin:
session.close(); return None, None, RedirectResponse("/login", status_code=303)
session.close()
wanted = request.url.path + (f"?{request.url.query}" if request.url.query else "")
return None, None, RedirectResponse(f"/login?{urlencode({'next': wanted})}", status_code=303)
return session, (web_session, admin), None
async def form_values(request):