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
@@ -0,0 +1,59 @@
"""Federated identity for SSO from GTI Gerenciador.
admin_users stays the single human principal (web_sessions and audit_events
already point at it). Existing rows become auth_source='LOCAL' (the
break-glass admin keeps its password). Collaborators provisioned by SSO are
auth_source='GTI_GERENCIADOR': no password at all, identified by the panel's
immutable subject. The CHECK constraint makes a passwordless LOCAL account,
or a federated account with a password, impossible.
sso_used_assertions keeps every accepted assertion id (jti) so the same
assertion can never be used twice.
Revision ID: 0021_sso_federated_identity
Revises: 0020_not_null_timestamps
"""
from alembic import op
import sqlalchemy as sa
revision = "0021_sso_federated_identity"
down_revision = "0020_not_null_timestamps"
branch_labels = None
depends_on = None
AUTH_SOURCE_CHECK = (
"(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)"
)
def upgrade():
op.add_column("admin_users", sa.Column("auth_source", sa.String(16), nullable=False, server_default="LOCAL"))
op.add_column("admin_users", sa.Column("external_subject", sa.String(128), nullable=True))
op.add_column("admin_users", sa.Column("display_name", sa.String(160), nullable=True))
op.add_column("admin_users", sa.Column("external_role", sa.String(32), nullable=True))
op.add_column("admin_users", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False))
op.alter_column("admin_users", "password_hash", existing_type=sa.String(255), nullable=True)
op.create_unique_constraint("uq_admin_users_external_subject", "admin_users", ["external_subject"])
op.create_check_constraint("ck_admin_users_auth_source", "admin_users", AUTH_SOURCE_CHECK)
op.create_table(
"sso_used_assertions",
sa.Column("jti", sa.String(64), primary_key=True),
sa.Column("subject", sa.String(128), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_sso_used_assertions_expires_at", "sso_used_assertions", ["expires_at"])
def downgrade():
bind = op.get_bind()
if bind.execute(sa.text("SELECT count(*) FROM admin_users WHERE auth_source <> 'LOCAL'")).scalar():
raise RuntimeError("0021 downgrade refused: federated users exist (their sessions/audit rows reference them)")
op.drop_index("ix_sso_used_assertions_expires_at", table_name="sso_used_assertions")
op.drop_table("sso_used_assertions")
op.drop_constraint("ck_admin_users_auth_source", "admin_users", type_="check")
op.drop_constraint("uq_admin_users_external_subject", "admin_users", type_="unique")
op.alter_column("admin_users", "password_hash", existing_type=sa.String(255), nullable=False)
for column in ("updated_at", "external_role", "display_name", "external_subject", "auth_source"):
op.drop_column("admin_users", column)
+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):
+3
View File
@@ -13,4 +13,7 @@ dependencies = [
"openpyxl>=3.1,<4",
"pypdf>=4.0,<6",
"pdfplumber>=0.11,<1",
# SSO from GTI Gerenciador (central/app/sso.py): Ed25519-signed JWT assertions.
"pyjwt>=2.14,<3",
"cryptography>=50,<51",
]
+112 -96
View File
@@ -1,114 +1,135 @@
/* GTI Fiscal — componentes específicos do Fiscal sobre o design system do
GTI Gerenciador (gti-theme.css, carregado antes). Nenhuma cor própria:
tudo vem dos tokens --cor-* do Gerenciador, inclusive no tema escuro. */
/* Aliases usados pelos templates do Fiscal -> tokens do Gerenciador */
:root {
--ink: #172126;
--muted: #5f7077;
--paper: #f5f7f6;
--card: #ffffff;
--line: #d5e1dd;
--accent: #0b6f67;
--accent-soft: #dff1ec;
--danger: #a83232;
--warn: #8b5a12;
--shadow: 0 10px 30px #183b3210;
--ink: var(--cor-texto);
--muted: var(--cor-texto-secundario);
--paper: var(--cor-fundo);
--card: var(--cor-superficie);
--line: var(--cor-borda);
--accent: var(--cor-acento-texto);
--accent-soft: var(--cor-acento-suave);
--danger: var(--cor-perigo);
--warn: var(--cor-aviso);
--shadow: var(--sombra-card);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--paper); color: var(--ink); font: 14px/1.5 Inter, ui-sans-serif, system-ui, sans-serif; }
a { color: var(--accent); }
.topbar { min-height: 68px; background: #112c2b; color: #f5fffb; display: flex; align-items: center; padding: 0 clamp(18px, 4vw, 64px); gap: 42px; }
.brand { font-weight: 900; letter-spacing: .1em; color: #fff; text-decoration: none; font-size: 17px; }
.brand span { color: #8ed6bd; }
.topbar nav { display: flex; flex-wrap: wrap; gap: 22px; flex: 1; }
.topbar nav a { color: #c2d9d2; text-decoration: none; }
.topbar nav a:hover, .topbar nav a:focus-visible { color: #fff; }
.ghost, button { cursor: pointer; }
.ghost { background: transparent; border: 1px solid #ffffff66; color: #fff; padding: 7px 13px; border-radius: 8px; }
.shell { max-width: 1440px; margin: 0 auto; padding: 38px clamp(18px, 4vw, 64px) 70px; }
.page-heading, .detail-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; margin-bottom: 26px; }
h1, h2, p { margin-top: 0; }
h1 { font-size: clamp(28px, 3vw, 42px); line-height: 1.05; margin-bottom: 8px; letter-spacing: -.04em; }
h2 { font-size: 17px; margin-bottom: 16px; }
.eyebrow { color: var(--accent); font-size: 11px; font-weight: 800; letter-spacing: .14em; margin-bottom: 9px; }
.muted, small { color: var(--muted); }
small { display: block; font-size: 12px; }
.live-dot { color: var(--accent); font-size: 12px; white-space: nowrap; }
/* ---------- layout: mesma sidebar do Gerenciador, conteúdo um pouco mais largo ---------- */
.conteudo-fiscal { max-width: 1280px; }
.sidebar-nav a.sidebar-voltar { color: var(--cor-texto-secundario); font-size: 0.9rem; }
.sidebar-nav a.sidebar-voltar:hover { color: var(--cor-texto); }
.sidebar-secao { padding: 14px 12px 4px; font-size: 0.72rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--cor-texto-secundario); }
.sidebar.recolhida .sidebar-secao { display: none; }
/* ---------- tipografia de página ---------- */
.page-heading, .detail-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; margin-bottom: 20px; }
.page-heading h1, .detail-head h1 { margin-bottom: 4px; }
p { margin-top: 0; }
.eyebrow { color: var(--cor-acento-texto); font-size: 0.72rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; margin: 0 0 6px; }
.muted, small { color: var(--cor-texto-secundario); }
small { display: block; font-size: 0.78rem; }
.live-dot { color: var(--cor-sucesso); font-size: 0.8rem; white-space: nowrap; }
/* ---------- cartões (mesmo visual de .cartao do Gerenciador) ---------- */
.metric, .panel { background: var(--cor-superficie); border: 1px solid var(--cor-borda); box-shadow: var(--sombra-card); border-radius: 12px; }
.panel { padding: 20px; margin-bottom: 20px; }
.panel h2 { font-size: 1.05rem; margin: 0 0 12px; }
.panel-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; }
.panel-title h2 { margin-bottom: 0; }
.panel-title a { font-size: 0.85rem; }
.cards { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 20px; }
.cards.compact { grid-template-columns: repeat(4, 1fr); }
.metric, .panel { background: var(--card); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: 14px; }
.metric { padding: 17px; }
.metric span { color: var(--muted); font-size: 12px; }
.metric strong { font-size: 29px; display: block; margin-top: 8px; letter-spacing: -.04em; }
.metric { padding: 16px; }
.metric span { color: var(--cor-texto-secundario); font-size: 0.8rem; }
.metric strong { font-size: 1.75rem; display: block; margin-top: 6px; }
.metric-inline strong { display: inline; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
.panel { padding: 22px; margin-bottom: 20px; }
.panel-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.panel-title h2 { margin-bottom: 0; }
.panel-title a { color: var(--accent); font-size: 12px; }
.panel-title a.secondary { color: #fff; font-size: inherit; }
.split .panel { margin-bottom: 0; }
.coverage { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
.coverage div { border-left: 3px solid var(--accent-soft); padding-left: 12px; }
.coverage strong { display: block; font-size: 23px; }
.coverage span { color: var(--muted); font-size: 12px; }
.coverage-status { margin: 16px 0 0; color: var(--accent); font-size: 12px; font-weight: 700; }
.registry-summary { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
.coverage div { border-left: 3px solid var(--cor-acento); padding-left: 12px; }
.coverage strong { display: block; font-size: 1.4rem; }
.coverage span { color: var(--cor-texto-secundario); font-size: 0.78rem; }
.coverage-status { margin: 16px 0 0; color: var(--cor-sucesso); font-size: 0.8rem; font-weight: 700; }
.registry-summary { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--cor-borda); color: var(--cor-texto-secundario); font-size: 0.8rem; }
.registry-summary a { text-decoration: none; }
.bar-row { display: flex; justify-content: space-between; border-bottom: 1px solid var(--line); padding: 8px 0; }
.bar-row { display: flex; justify-content: space-between; border-bottom: 1px solid var(--cor-borda); padding: 8px 0; }
.bar-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 24px; }
.list-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit; }
.list-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 8px; margin: 0 -8px; border-bottom: 1px solid var(--cor-borda); border-radius: 8px; text-decoration: none; color: inherit; transition: background var(--transicao); }
.list-row:last-child { border-bottom: 0; }
.list-row:hover { background: #f7fbf9; }
.empty-state { color: var(--muted); border: 1px dashed var(--line); padding: 18px; margin: 0; }
.badge, .severity { display: inline-flex; align-items: center; border: 1px solid currentColor; border-radius: 999px; padding: 3px 8px; font-size: 11px; font-style: normal; font-weight: 800; white-space: nowrap; }
.badge.large { padding: 7px 12px; }
.list-row:hover { background: var(--cor-superficie-alt); }
.empty-state { color: var(--cor-texto-secundario); border: 1px dashed var(--cor-borda); border-radius: 8px; padding: 18px; margin: 0; }
/* ---------- badges: mesma forma do .badge do Gerenciador, cores semânticas dos tokens ---------- */
.badge, .severity { display: inline-flex; align-items: center; border: 0; border-radius: 999px; padding: 3px 10px; font-size: 0.78rem; font-style: normal; font-weight: 600; white-space: nowrap; background: var(--cor-superficie-alt); color: var(--cor-texto-secundario); }
.badge.large { padding: 6px 12px; }
.badge-stack { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.online, .fiscal-complete, .status-completed { color: #176646; background: #e5f5eb; }
.degraded, .warning, .fiscal-incomplete, .status-pending, .status-running, .priority-high { color: var(--warn); background: #fff1d5; }
.offline, .unknown { color: #53656c; background: #e9eef0; }
.failed, .priority-critical, .status-failed { color: var(--danger); background: #fde7e7; }
.blocked, .priority-normal { color: #6d4c1c; background: #fff3d8; }
.priority-low { color: #53656c; background: #e9eef0; }
.fiscal-blocked { color: #7d3c13; background: #ffead6; }
.online, .fiscal-complete, .status-completed, .info { background: var(--cor-sucesso-fundo); color: var(--cor-sucesso); }
.degraded, .warning, .fiscal-incomplete, .status-pending, .status-running, .priority-high { background: var(--cor-aviso-fundo); color: var(--cor-aviso); }
.offline, .unknown, .priority-low { background: var(--cor-superficie-alt); color: var(--cor-texto-secundario); }
.failed, .error.badge, .priority-critical, .status-failed, .critical { background: var(--cor-perigo-fundo); color: var(--cor-perigo); }
.blocked, .priority-normal, .fiscal-blocked { background: var(--cor-acento-suave); color: var(--cor-acento-texto); }
/* ---------- formulários e botões (padrão do Gerenciador) ---------- */
.conteudo-fiscal button { margin-top: 0; }
.filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
.filters label { color: var(--muted); display: grid; gap: 4px; font-size: 12px; font-weight: 700; }
.filters input, .filters select, .filters textarea, input, select, textarea { border: 1px solid #b8c9c4; background: #fff; border-radius: 7px; color: var(--ink); font: inherit; padding: 8px 10px; min-height: 38px; }
.filters input, .filters select { min-width: 150px; }
.filters label { color: var(--cor-texto-secundario); display: grid; gap: 4px; font-size: 0.8rem; font-weight: 600; margin: 0; }
.filters input, .filters select { width: auto; min-width: 150px; }
.filters button { align-self: end; }
.secondary, button:not(.ghost) { border: 1px solid var(--accent); background: var(--accent); color: #fff; padding: 8px 13px; border-radius: 7px; text-decoration: none; font: inherit; }
.secondary:hover, button:not(.ghost):hover { background: #095a54; }
textarea { padding: 9px 10px; border-radius: 8px; border: 1px solid var(--cor-borda); background: var(--cor-fundo); color: var(--cor-texto); font: inherit; width: 100%; }
/* Como no Gerenciador: <button> é a ação principal (laranja, gti-theme.css);
link com cara de botão (a.secondary ~ a.botao) tem só contorno. */
a.secondary { display: inline-block; padding: 9px 14px; border-radius: 8px; border: 1px solid var(--cor-borda); background: transparent; color: var(--cor-texto); font-weight: 400; text-decoration: none; }
a.secondary:hover { border-color: var(--cor-acento); color: var(--cor-acento); }
.blocker-filters label { flex: 1 1 170px; }
.compact-filters { margin-top: 12px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 960px; }
caption { text-align: left; font-size: 17px; font-weight: 800; margin-bottom: 12px; }
th { color: var(--muted); font-size: 11px; letter-spacing: .04em; text-align: left; text-transform: uppercase; }
th, td { border-bottom: 1px solid var(--line); padding: 12px 10px; vertical-align: top; }
tbody tr:last-child td { border-bottom: 0; }
td a { text-decoration: none; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 18px; margin: 4px 0 22px; color: var(--muted); }
.pagination a { border: 1px solid var(--line); background: #fff; border-radius: 7px; padding: 6px 10px; text-decoration: none; }
.tabs { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid var(--line); }
.tabs a { padding: 9px 13px; text-decoration: none; color: var(--muted); }
.tabs a.active { color: var(--accent); border-bottom: 3px solid var(--accent); font-weight: 800; }
.facts { display: grid; grid-template-columns: minmax(130px, .7fr) 1fr; gap: 10px 18px; margin: 0; }
.facts dt { color: var(--muted); font-weight: 700; }
.facts dd { margin: 0; overflow-wrap: anywhere; }
.event-row { display: grid; grid-template-columns: 120px 1fr auto; align-items: start; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--line); }
.event-row:last-child { border-bottom: 0; }
.event-time { color: var(--muted); font-size: 12px; }
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--paper); }
.login-card { width: min(100%, 420px); display: grid; gap: 10px; padding: 30px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); }
.login-card .eyebrow, .login-card h1 { margin-bottom: 2px; }
.login-card label { margin-top: 6px; color: var(--muted); font-size: 13px; font-weight: 700; }
.login-card input { width: 100%; }
.login-card button { width: 100%; margin-top: 8px; }
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
.form-grid label { display: grid; gap: 5px; color: var(--muted); font-weight: 700; }
.form-grid label { display: grid; gap: 5px; color: var(--cor-texto-secundario); font-weight: 600; margin: 0; }
.form-grid .wide { grid-column: 1 / -1; }
.form-grid select, .form-grid input, .form-grid textarea { width: 100%; }
.form-grid textarea { min-height: 100px; resize: vertical; }
.error, .error-text { color: var(--danger); }
.evidence { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.4 ui-monospace, monospace; }
.error, .error-text { color: var(--cor-perigo); }
.alert.error { padding: 10px 14px; border-radius: 8px; background: var(--cor-perigo-fundo); color: var(--cor-perigo); }
:focus-visible { outline: 3px solid #f1ae28; outline-offset: 2px; }
/* ---------- tabelas (mesmo cartão de .tabela-wrap do Gerenciador) ---------- */
.table-wrap { overflow-x: auto; border: 1px solid var(--cor-borda); border-radius: 12px; background: var(--cor-superficie); box-shadow: var(--sombra-card); }
.panel .table-wrap { box-shadow: none; }
/* Tabelas do Fiscal são largas: mantêm a largura mínima (rolagem horizontal no
.table-wrap) e só quebram palavras que não cabem — o overflow-wrap:anywhere
do tema quebraria nomes letra por letra em colunas estreitas. */
.conteudo-fiscal table { width: 100%; min-width: 960px; }
.conteudo-fiscal td { vertical-align: top; overflow-wrap: break-word; }
.conteudo-fiscal td a.secondary, .conteudo-fiscal td .badge { white-space: nowrap; }
caption { text-align: left; font-size: 1rem; font-weight: 700; padding: 14px 14px 4px; }
td a { text-decoration: none; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin: 12px 0 20px; color: var(--cor-texto-secundario); }
.pagination a { border: 1px solid var(--cor-borda); background: transparent; color: var(--cor-texto); border-radius: 8px; padding: 6px 12px; text-decoration: none; }
.pagination a:hover { border-color: var(--cor-acento); color: var(--cor-acento); }
.tabs { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid var(--cor-borda); }
.tabs a { padding: 9px 13px; text-decoration: none; color: var(--cor-texto-secundario); }
.tabs a.active { color: var(--cor-acento-texto); border-bottom: 3px solid var(--cor-acento); font-weight: 700; }
.facts { display: grid; grid-template-columns: minmax(130px, .7fr) 1fr; gap: 10px 18px; margin: 0; }
.facts dt { color: var(--cor-texto-secundario); font-weight: 600; }
.facts dd { margin: 0; overflow-wrap: anywhere; }
.event-row { display: grid; grid-template-columns: 120px 1fr auto; align-items: start; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--cor-borda); }
.event-row:last-child { border-bottom: 0; }
.event-time { color: var(--cor-texto-secundario); font-size: 0.8rem; }
.evidence { white-space: pre-wrap; overflow-wrap: anywhere; font: 0.8rem/1.4 ui-monospace, monospace; }
/* ---------- páginas avulsas (acesso de emergência, erro de SSO, saída) — mesmo visual do login do Gerenciador ---------- */
.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; background: radial-gradient(circle at top, var(--cor-superficie-alt), var(--cor-fundo)); }
.login-card { width: 100%; max-width: 380px; text-align: center; margin: 0; }
.login-card form { text-align: left; }
.login-card form button[type="submit"] { width: 100%; }
.login-logo { width: 64px; height: 64px; border-radius: 16px; margin-bottom: 12px; }
.login-card h1 { font-size: 1.3rem; margin-bottom: 4px; }
.login-subtitle { color: var(--cor-texto-secundario); font-size: 0.9rem; margin: 0 0 20px; }
.login-actions { display: grid; gap: 10px; margin-top: 16px; }
.login-actions a.botao { margin: 0; }
@media (max-width: 1100px) {
.cards { grid-template-columns: repeat(3, 1fr); }
@@ -117,9 +138,6 @@ td a { text-decoration: none; }
.badge-stack { justify-content: flex-start; }
}
@media (max-width: 768px) {
.topbar { align-items: flex-start; flex-wrap: wrap; gap: 14px 24px; padding-block: 16px; }
.topbar nav { order: 3; flex-basis: 100%; }
.shell { padding-top: 26px; }
.cards, .cards.compact, .split { grid-template-columns: 1fr 1fr; }
.coverage { grid-template-columns: repeat(2, 1fr); }
.form-grid { grid-template-columns: 1fr; }
@@ -129,6 +147,4 @@ td a { text-decoration: none; }
@media (max-width: 520px) {
.cards, .cards.compact, .split, .bar-grid { grid-template-columns: 1fr; }
.filters label, .filters input, .filters select, .filters button { width: 100%; }
.filters button { margin-top: 2px; }
.login-card { padding: 22px; }
}
+320
View File
@@ -0,0 +1,320 @@
/* GTI design system — cópia fiel de painel-clientes-web/app/static/estilo.css (GTI Gerenciador @ b0c6d3e).
Não editar aqui: altere no Gerenciador e copie de novo. Ajustes específicos do Fiscal ficam em control-panel.css. */
/* app/static/estilo.css — tokens de cor e componentes compartilhados do painel */
:root {
--cor-fundo: #f7f7f9;
--cor-superficie: #ffffff;
--cor-superficie-alt: #f1f1f4;
--cor-texto: #1c1c1f;
--cor-texto-secundario: #6b6b74;
--cor-borda: #e4e4e9;
--cor-acento: #F5821F;
--cor-acento-contraste: #17171a;
--cor-acento-suave: #fff1e4;
--cor-acento-texto: #B3510C;
--cor-sucesso: #15803d;
--cor-sucesso-fundo: #dcfce7;
--cor-perigo: #b91c1c;
--cor-perigo-fundo: #fee2e2;
--cor-aviso: #b45309;
--cor-aviso-fundo: #fef3c7;
--sombra-card: 0 1px 2px rgba(0,0,0,0.05), 0 1px 8px rgba(0,0,0,0.04);
--transicao: 0.16s ease;
}
@media (prefers-color-scheme: dark) {
:root:not([data-tema="claro"]) {
--cor-fundo: #121214;
--cor-superficie: #1c1c1f;
--cor-superficie-alt: #232326;
--cor-texto: #f4f4f5;
--cor-texto-secundario: #9a9aa2;
--cor-borda: #2e2e33;
--cor-acento-contraste: #17171a;
--cor-acento-suave: rgba(245,130,31,0.16);
--cor-acento-texto: #F5821F;
--cor-sucesso: #4ade80;
--cor-sucesso-fundo: rgba(74,222,128,0.14);
--cor-perigo: #f87171;
--cor-perigo-fundo: rgba(248,113,113,0.14);
--cor-aviso: #fbbf24;
--cor-aviso-fundo: rgba(251,191,36,0.14);
--sombra-card: 0 1px 2px rgba(0,0,0,0.3), 0 1px 8px rgba(0,0,0,0.25);
}
}
:root[data-tema="escuro"] {
--cor-fundo: #121214;
--cor-superficie: #1c1c1f;
--cor-superficie-alt: #232326;
--cor-texto: #f4f4f5;
--cor-texto-secundario: #9a9aa2;
--cor-borda: #2e2e33;
--cor-acento-contraste: #17171a;
--cor-acento-suave: rgba(245,130,31,0.16);
--cor-acento-texto: #F5821F;
--cor-sucesso: #4ade80;
--cor-sucesso-fundo: rgba(74,222,128,0.14);
--cor-perigo: #f87171;
--cor-perigo-fundo: rgba(248,113,113,0.14);
--cor-aviso: #fbbf24;
--cor-aviso-fundo: rgba(251,191,36,0.14);
--sombra-card: 0 1px 2px rgba(0,0,0,0.3), 0 1px 8px rgba(0,0,0,0.25);
}
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
margin: 0;
background: var(--cor-fundo);
color: var(--cor-texto);
transition: background var(--transicao), color var(--transicao);
}
h1, h2, h3 { font-weight: 700; line-height: 1.25; }
h1 { font-size: 1.5rem; margin: 0 0 16px; }
h2 { font-size: 1.15rem; margin: 28px 0 12px; }
a { color: var(--cor-acento-texto); }
/* ---------- layout raiz: sidebar + área de conteúdo ---------- */
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 240px;
flex-shrink: 0;
background: var(--cor-superficie);
border-right: 1px solid var(--cor-borda);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
transition: width var(--transicao), transform var(--transicao);
}
.sidebar-topo {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 16px;
border-bottom: 1px solid var(--cor-borda);
}
.sidebar-logo { width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0; }
.sidebar-titulo { font-weight: 700; white-space: nowrap; overflow: hidden; }
.sidebar-nav { flex: 1; padding: 12px 8px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
.sidebar-nav a {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 8px;
color: var(--cor-texto); text-decoration: none; white-space: nowrap;
transition: background var(--transicao), color var(--transicao);
}
.sidebar-nav a:hover { background: var(--cor-superficie-alt); }
.sidebar-nav a.ativo { background: var(--cor-acento-suave); color: var(--cor-acento-texto); font-weight: 600; }
.sidebar-nav svg { flex-shrink: 0; width: 20px; height: 20px; }
.sidebar-nav span { overflow: hidden; text-overflow: ellipsis; }
.sidebar-rodape {
border-top: 1px solid var(--cor-borda);
padding: 12px 8px;
display: flex; flex-direction: column; gap: 8px;
}
.sidebar-usuario { padding: 6px 12px; font-size: 0.85rem; color: var(--cor-texto-secundario); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-rodape .form-logout { margin: 0; }
.botao-tema, .botao-recolher, .botao-sair, .topbar-menu-btn {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 10px 12px; border-radius: 8px; border: none;
background: transparent; color: var(--cor-texto); cursor: pointer;
font: inherit; font-weight: 400; margin: 0; text-align: left;
}
.botao-tema:hover, .botao-recolher:hover, .botao-sair:hover { background: var(--cor-superficie-alt); }
.botao-tema svg, .botao-recolher svg, .botao-sair svg { flex-shrink: 0; width: 20px; height: 20px; }
/* Sidebar recolhida (desktop): só ícones */
.sidebar.recolhida { width: 64px; }
.sidebar.recolhida .sidebar-titulo,
.sidebar.recolhida .sidebar-nav span,
.sidebar.recolhida .sidebar-usuario,
.sidebar.recolhida .botao-tema span,
.sidebar.recolhida .botao-recolher span,
.sidebar.recolhida .botao-sair span { display: none; }
.sidebar-backdrop {
display: none;
position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 40;
}
.conteudo-area { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar-mobile {
display: none;
align-items: center; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--cor-borda);
background: var(--cor-superficie);
}
.topbar-menu-btn { width: auto; padding: 8px; }
.topbar-titulo { font-weight: 700; }
.conteudo { padding: 24px; max-width: 1080px; width: 100%; margin: 0 auto; }
/* ---------- mobile: sidebar vira gaveta ---------- */
@media (max-width: 900px) {
.sidebar {
position: fixed; top: 0; bottom: 0; left: 0; z-index: 50;
transform: translateX(-100%);
}
.sidebar.aberta { transform: translateX(0); }
.sidebar.recolhida { width: 240px; } /* recolher não se aplica no mobile */
.sidebar.recolhida .sidebar-titulo,
.sidebar.recolhida .sidebar-nav span,
.sidebar.recolhida .sidebar-usuario,
.sidebar.recolhida .botao-tema span,
.sidebar.recolhida .botao-recolher span,
.sidebar.recolhida .botao-sair span { display: inline; }
.sidebar-backdrop.aberta { display: block; }
.topbar-mobile { display: flex; }
.conteudo { padding: 16px; }
}
/* ---------- componentes ---------- */
.cartao {
background: var(--cor-superficie);
border: 1px solid var(--cor-borda);
border-radius: 12px;
box-shadow: var(--sombra-card);
padding: 20px;
margin-bottom: 20px;
}
.tabela-wrap { overflow-x: auto; border: 1px solid var(--cor-borda); border-radius: 12px; background: var(--cor-superficie); box-shadow: var(--sombra-card); }
/*
* min-width (não width) faz a tabela continuar esticando pra preencher o
* cartão em telas largas (comportamento de sempre), mas permite crescer
* além disso quando o conteúdo não cabe — em telas estreitas (celular) o
* .tabela-wrap acima assume a rolagem horizontal, em vez do texto quebrar
* letra por letra pra caber à força numa coluna espremida.
*/
table { min-width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--cor-borda); }
th { white-space: nowrap; }
td { overflow-wrap: anywhere; }
tr:last-child td { border-bottom: none; }
th a { color: inherit; text-decoration: none; }
th a:hover { color: var(--cor-acento); }
input, select {
padding: 9px 10px; border-radius: 8px; border: 1px solid var(--cor-borda);
background: var(--cor-fundo); color: var(--cor-texto); width: 100%;
}
input:focus-visible, select:focus-visible, a:focus-visible, button:focus-visible {
outline: 2px solid var(--cor-acento); outline-offset: 2px;
}
label { display: block; margin-top: 10px; margin-bottom: 4px; font-size: 0.85rem; color: var(--cor-texto-secundario); }
button, a.botao {
display: inline-block; margin-top: 14px; padding: 10px 16px;
border: 1px solid transparent; border-radius: 8px;
background: var(--cor-acento); color: var(--cor-acento-contraste);
font-weight: 700; cursor: pointer; text-decoration: none; text-align: center;
white-space: nowrap;
}
a.botao { background: transparent; border-color: var(--cor-borda); color: var(--cor-texto); font-weight: 400; margin-right: 6px; }
a.botao:hover { border-color: var(--cor-acento); color: var(--cor-acento); }
.mensagem { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; }
.mensagem.success { background: var(--cor-sucesso-fundo); color: var(--cor-sucesso); }
.mensagem.danger { background: var(--cor-perigo-fundo); color: var(--cor-perigo); }
.mensagem.warning { background: var(--cor-aviso-fundo); color: var(--cor-aviso); }
.badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 0.78rem; font-weight: 600; }
.badge-acento { background: var(--cor-acento-suave); color: var(--cor-acento-texto); }
.badge-neutro { background: var(--cor-superficie-alt); color: var(--cor-texto-secundario); }
.badge-sucesso { background: var(--cor-sucesso-fundo); color: var(--cor-sucesso); }
.grid-form { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.grid-form-largura-total { grid-column: 1 / -1; }
@media (max-width: 640px) {
.grid-form { grid-template-columns: 1fr; }
}
.paginacao { display: flex; align-items: center; gap: 12px; justify-content: center; margin-top: 16px; }
.paginacao-rodape { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.paginacao-rodape .paginacao { margin-top: 0; }
.paginacao-por-pagina { display: flex; align-items: center; gap: 8px; }
.paginacao-por-pagina label { margin: 0; white-space: nowrap; }
.paginacao-atual { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 32px; padding: 0 6px; border-radius: 6px; background: var(--cor-acento); color: var(--cor-acento-texto); font-weight: 600; }
.paginacao-reticencias { padding: 0 4px; color: var(--cor-texto-secundario); }
.chave-feedback { margin-left: 6px; font-size: 0.85rem; color: var(--cor-sucesso); }
.chave-feedback-erro { color: var(--cor-perigo); }
.botao-gerar-chave { margin-left: 6px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.status-dot { display: inline-block; width: 12px; height: 12px; padding: 0; margin: 0; border: none; border-radius: 50%; cursor: pointer; vertical-align: middle; }
.status-dot-ativo { background: var(--cor-sucesso); }
.status-dot-inativo { background: var(--cor-perigo); }
.secao-log { margin-bottom: 20px; }
.secao-log summary {
display: flex; align-items: center; gap: 10px; cursor: pointer;
list-style: none; padding: 10px 0; user-select: none;
}
.secao-log summary::-webkit-details-marker { display: none; }
.secao-log summary::before {
content: "▸"; display: inline-block; color: var(--cor-texto-secundario);
transition: transform 0.15s ease;
}
.secao-log[open] summary::before { transform: rotate(90deg); }
.secao-log-titulo { font-size: 1.17em; font-weight: 700; }
.botao-toggle-log {
margin-top: 0; padding: 6px 12px; font-size: 0.85rem; font-weight: 400;
background: transparent; border: 1px solid var(--cor-borda); color: var(--cor-texto);
}
.botao-toggle-log:hover { border-color: var(--cor-acento); color: var(--cor-acento); }
.secao-log-conteudo { padding-top: 4px; }
/*
* table-layout:fixed + min-width faz as colunas respeitarem exatamente as
* larguras abaixo (necessário pro Abrir/Gerar chave não quebrar linha —
* ver commit da tarefa de UI da tabela). Em telas estreitas (celular), o
* min-width impede a tabela de ser espremida a ponto de zerar a largura
* das colunas percentuais — o .tabela-wrap (overflow-x: auto) assume a
* rolagem horizontal nesse caso, em vez do texto quebrar letra por letra.
* Nas telas largas (desktop) o min-width nunca entra em jogo, então o
* visual de lá continua idêntico.
*/
.table-clientes { table-layout: fixed; min-width: 990px; }
.table-clientes th.col-codigo, .table-clientes td.col-codigo,
.table-clientes th.col-cnpj, .table-clientes td.col-cnpj,
.table-clientes th.col-status, .table-clientes td.col-status {
text-align: center;
}
.table-clientes th.col-acoes, .table-clientes td.col-acoes { text-align: right; }
.table-clientes td.col-codigo, .table-clientes td.col-cnpj {
font-variant-numeric: tabular-nums;
}
.table-clientes td.col-cnpj { white-space: nowrap; }
.table-clientes col.col-codigo { width: 90px; }
.table-clientes col.col-razao, .table-clientes col.col-fantasia { width: 220px; }
.table-clientes col.col-cnpj { width: 160px; }
.table-clientes col.col-status { width: 70px; }
.table-clientes col.col-acoes { width: 230px; }
.table-clientes th, .table-clientes td { padding: 7px 14px; }
.botao.botao-tabela { margin-top: 0; padding: 7px 12px; font-size: 0.875rem; vertical-align: middle; }
.table-clientes td.col-acoes { white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+39
View File
@@ -0,0 +1,39 @@
// app/static/sidebar.js — abre/fecha a gaveta da sidebar no mobile e
// recolhe/expande no desktop, persistindo a preferência de recolher.
(function () {
var sidebar = document.getElementById("sidebar");
var backdrop = document.getElementById("sidebar-backdrop");
var btnMenuMobile = document.getElementById("btn-menu-mobile");
var btnRecolher = document.getElementById("botao-recolher");
if (!sidebar) return;
function abrirGaveta() {
sidebar.classList.add("aberta");
if (backdrop) backdrop.classList.add("aberta");
}
function fecharGaveta() {
sidebar.classList.remove("aberta");
if (backdrop) backdrop.classList.remove("aberta");
}
if (btnMenuMobile) {
btnMenuMobile.addEventListener("click", function () {
if (sidebar.classList.contains("aberta")) fecharGaveta();
else abrirGaveta();
});
}
if (backdrop) backdrop.addEventListener("click", fecharGaveta);
sidebar.querySelectorAll("a").forEach(function (link) {
link.addEventListener("click", fecharGaveta);
});
if (btnRecolher) {
if (localStorage.getItem("sidebarRecolhida") === "1") {
sidebar.classList.add("recolhida");
}
btnRecolher.addEventListener("click", function () {
var recolhida = sidebar.classList.toggle("recolhida");
localStorage.setItem("sidebarRecolhida", recolhida ? "1" : "0");
});
}
})();
+17
View File
@@ -0,0 +1,17 @@
// app/static/tema.js — alterna e persiste o tema claro/escuro do painel.
(function () {
var botao = document.getElementById("botao-tema");
if (!botao) return;
function temaAtual() {
var salvo = localStorage.getItem("tema");
if (salvo === "claro" || salvo === "escuro") return salvo;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "escuro" : "claro";
}
botao.addEventListener("click", function () {
var novo = temaAtual() === "escuro" ? "claro" : "escuro";
document.documentElement.setAttribute("data-tema", novo);
localStorage.setItem("tema", novo);
});
})();
+80 -7
View File
@@ -3,16 +3,89 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}GTI Fiscal Control Panel{% endblock %}</title>
<title>{% block title %}GTI Fiscal{% endblock %}</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<!-- Mesma identidade visual do GTI Gerenciador: tokens/layout em gti-theme.css (cópia fiel), ajustes do Fiscal em control-panel.css -->
<link rel="stylesheet" href="/static/gti-theme.css">
<link rel="stylesheet" href="/static/control-panel.css">
<script>
(function () {
var tema = localStorage.getItem("tema");
if (tema === "claro" || tema === "escuro") {
document.documentElement.setAttribute("data-tema", tema);
}
})();
</script>
</head>
<body>
<header class="topbar">
<a class="brand" href="/">GTI <span>FISCAL</span></a>
<nav aria-label="Navegação principal"><a href="/">Resumo</a><a href="/agents">Agents</a><a href="/blockers">Blockers</a><a href="/jobs">Jobs</a></nav>
{% if auth %}<form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost" type="submit">Sair</button></form>{% endif %}
</header>
<main class="shell">{% block content %}{% endblock %}</main>
{% set path = request.url.path %}
<div class="layout">
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-topo">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="sidebar-logo">
<span class="sidebar-titulo">GTI Fiscal</span>
</div>
<nav class="sidebar-nav" aria-label="Navegação principal">
{% if panel_home %}
<a href="{{ panel_home }}" class="sidebar-voltar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
<span>Painel de Clientes</span>
</a>
<div class="sidebar-secao"><span>Fiscal</span></div>
{% endif %}
<a href="/" class="{{ 'ativo' if path == '/' else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
<span>Resumo</span>
</a>
<a href="/agents" class="{{ 'ativo' if path.startswith('/agents') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>
<span>Agents / Servidores</span>
</a>
<a href="/blockers" class="{{ 'ativo' if path.startswith('/blockers') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4Z"/><path d="M12 8v5M12 16h.01"/></svg>
<span>Blockers</span>
</a>
<a href="/jobs" class="{{ 'ativo' if path.startswith('/jobs') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
<span>Jobs</span>
</a>
</nav>
<div class="sidebar-rodape">
{% if auth %}
<div class="sidebar-usuario">{{ auth[1].display_name or auth[1].username }}</div>
{% endif %}
<button type="button" class="botao-tema" id="botao-tema" aria-label="Alternar tema claro/escuro">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<span>Tema claro/escuro</span>
</button>
{% if auth %}
<form method="post" action="/logout" class="form-logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="botao-sair">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>
<span>Sair do Fiscal</span>
</button>
</form>
{% endif %}
<button type="button" class="botao-recolher" id="botao-recolher" aria-label="Recolher menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19l-7-7 7-7M18 19l-7-7 7-7"/></svg>
<span>Recolher</span>
</button>
</div>
</aside>
<div class="conteudo-area">
<header class="topbar-mobile">
<button type="button" class="topbar-menu-btn" id="btn-menu-mobile" aria-label="Abrir menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:22px;height:22px"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
<span class="topbar-titulo">GTI Fiscal</span>
</header>
<main class="conteudo conteudo-fiscal">{% block content %}{% endblock %}</main>
</div>
</div>
<script src="/static/sidebar.js" defer></script>
<script src="/static/tema.js" defer></script>
<script src="/static/control-panel.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="pt-BR" data-tema="escuro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Você saiu | GTI Fiscal</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/gti-theme.css">
<link rel="stylesheet" href="/static/control-panel.css">
</head>
<body class="login-page">
<div class="cartao login-card">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="login-logo">
<h1>Você saiu do GTI Fiscal</h1>
<p class="login-subtitle">Sua sessão no Painel de Clientes continua ativa.</p>
<div class="login-actions">
{% if panel_home %}<a class="botao" href="{{ panel_home }}">Voltar ao Painel de Clientes</a>{% endif %}
<a class="botao" href="/login">Entrar novamente no Fiscal</a>
</div>
</div>
</body>
</html>
+30 -1
View File
@@ -1 +1,30 @@
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Login | GTI Fiscal</title><link rel="stylesheet" href="/static/control-panel.css"></head><body class="login-page"><form class="login-card" method="post" action="/login"><p class="eyebrow">GTI FISCAL / CONTROL PANEL</p><h1>Entrar no painel</h1>{% if error %}<div class="alert error">{{ error }}</div>{% endif %}<label for="login-username">Usuário</label><input id="login-username" name="username" autocomplete="username" required><label for="login-password">Senha</label><input id="login-password" name="password" type="password" autocomplete="current-password" required><button class="primary" type="submit">Acessar</button></form></body></html>
<!doctype html>
<html lang="pt-BR" data-tema="escuro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% if emergency %}Acesso de emergência{% else %}Entrar{% endif %} | GTI Fiscal</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/gti-theme.css">
<link rel="stylesheet" href="/static/control-panel.css">
</head>
<body class="login-page">
<div class="cartao login-card">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="login-logo">
<h1>GTI Fiscal</h1>
{% if emergency %}
<p class="login-subtitle">Acesso de emergência (conta local). Colaboradores entram pelo Painel de Clientes.</p>
{% else %}
<p class="login-subtitle">Entre com suas credenciais para acessar o sistema</p>
{% endif %}
{% if error %}<div class="mensagem danger">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<label for="login-username">Usuário</label>
<input id="login-username" name="username" autocomplete="username" required autofocus>
<label for="login-password">Senha</label>
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Entrar</button>
</form>
</div>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="pt-BR" data-tema="escuro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Não foi possível entrar | GTI Fiscal</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/gti-theme.css">
<link rel="stylesheet" href="/static/control-panel.css">
</head>
<body class="login-page">
<div class="cartao login-card">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="login-logo">
<h1>Não foi possível entrar no GTI Fiscal</h1>
{% set messages = {
"EXPIRED": "O acesso demorou demais para ser concluído.",
"STATE_MISMATCH": "O acesso foi iniciado em outra aba ou já foi usado.",
"REPLAY": "Este acesso já foi utilizado.",
"DISABLED_IN_FISCAL": "Seu usuário está desativado no GTI Fiscal.",
"SSO_DISABLED": "O acesso pelo Painel de Clientes não está configurado."
} %}
<p class="login-subtitle">{{ messages.get(reason, "A identificação enviada pelo Painel de Clientes não foi aceita.") }}</p>
<div class="login-actions">
<a class="botao" href="/login">Tentar novamente</a>
{% if panel_home %}<a class="botao" href="{{ panel_home }}">Voltar ao Painel de Clientes</a>{% endif %}
</div>
<p class="muted" style="margin-top:16px;font-size:.78rem">Código: {{ reason }}</p>
</div>
</body>
</html>
+2 -1
View File
@@ -110,7 +110,8 @@ def test_login_has_centered_card_and_explicit_field_labels():
assert response.status_code == 200
assert 'class="login-page"' in response.text
assert 'class="login-card"' in response.text
# Same card component as the GTI Gerenciador login (gti-theme.css .cartao).
assert 'class="cartao login-card"' in response.text
assert '<label for="login-username">Usuário</label>' in response.text
assert '<label for="login-password">Senha</label>' in response.text
+235
View File
@@ -0,0 +1,235 @@
"""SSO from GTI Gerenciador into the Hub (central/app/sso.py, web.py)."""
import base64
import dataclasses
import time
import uuid
from datetime import datetime, timedelta, timezone
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, PublicFormat
from fastapi.testclient import TestClient
from sqlalchemy import select
from central.app import config, sso
from central.app.human_auth import verify_password
from central.app.main import create_app
from central.app.models import AdminUser, AuditEvent, SsoUsedAssertion, WebSession
from central.tests.test_control_plane import seed, setup_db
START_URL = "https://gtigerenciador.gtigrupo.com.br/sso/fiscal"
PRIVATE = Ed25519PrivateKey.generate()
PUBLIC_B64 = base64.urlsafe_b64encode(PRIVATE.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode().rstrip("=")
@pytest.fixture(autouse=True)
def sso_settings(monkeypatch):
monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, sso_public_key=PUBLIC_B64, sso_start_url=START_URL, web_cookie_secure=True, federated_session_seconds=3600))
@pytest.fixture
def hub():
factory = setup_db()
seed(factory) # creates the LOCAL break-glass admin "operator"
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None), base_url="https://testserver")
return client, factory
def assertion(state, *, sub="funcionario:7", key=PRIVATE, algorithm="EdDSA", aud=sso.AUDIENCE, iss=sso.ISSUER, iat=None, lifetime=60, jti=None, **extra):
now = int(iat if iat is not None else time.time())
claims = {"iss": iss, "aud": aud, "sub": sub, "iat": now, "nbf": now, "exp": now + lifetime, "jti": jti or str(uuid.uuid4()),
"nonce": state, "login": "maria", "name": "Maria Teste", "perfil": "PADRAO", **extra}
return jwt.encode(claims, key, algorithm=algorithm)
def start(client, next_path="/"):
response = client.get("/login", params={"next": next_path}, follow_redirects=False)
return response, response.cookies.get(sso.STATE_COOKIE) or client.cookies.get(sso.STATE_COOKIE)
def callback(client, token, next_path="/"):
return client.post("/auth/sso/callback", data={"assertion": token, "next": next_path}, follow_redirects=False)
def audits(factory, action):
with factory() as session:
return session.scalars(select(AuditEvent).where(AuditEvent.action == action)).all()
def test_login_starts_sso_at_the_panel_with_a_browser_bound_state(hub):
client, _ = hub
response, state = start(client, "/agents")
assert response.status_code == 303
target = urlsplit(response.headers["location"])
assert f"{target.scheme}://{target.netloc}{target.path}" == START_URL
query = parse_qs(target.query)
assert query["next"] == ["/agents"] and query["state"] == [state] and len(state) >= 16
cookie = next(h for h in response.headers.get_list("set-cookie") if h.startswith(sso.STATE_COOKIE))
assert "HttpOnly" in cookie and "Secure" in cookie and "SameSite=none" in cookie and "Path=/auth/sso" in cookie
def test_active_employee_enters_without_password_and_is_provisioned(hub):
client, factory = hub
_, state = start(client, "/agents")
response = callback(client, assertion(state), "/agents")
assert response.status_code == 303 and response.headers["location"] == "/agents"
cookies = response.headers.get_list("set-cookie")
session_cookie = next(h for h in cookies if h.startswith(config.settings.web_cookie_name))
assert "HttpOnly" in session_cookie and "Secure" in session_cookie and "SameSite=lax" in session_cookie
csrf_cookie = next(h for h in cookies if h.startswith("gti_csrf"))
assert "Secure" in csrf_cookie and "HttpOnly" not in csrf_cookie
page = client.get("/agents")
assert page.status_code == 200 and "Maria Teste" in page.text and 'name="password"' not in page.text
with factory() as session:
user = session.scalar(select(AdminUser).where(AdminUser.external_subject == "funcionario:7"))
assert user.auth_source == "GTI_GERENCIADOR" and user.password_hash is None and user.external_role == "PADRAO"
web_session = session.scalar(select(WebSession).where(WebSession.admin_user_id == user.id))
lifetime = web_session.expires_at.replace(tzinfo=timezone.utc) - datetime.now(timezone.utc)
assert timedelta(minutes=55) < lifetime <= timedelta(hours=1)
success = audits(factory, "SSO_LOGIN_SUCCESS")
assert len(success) == 1 and success[0].target_id == "funcionario:7" and "ip" in success[0].context
def test_second_login_reuses_the_provisioned_user_and_refreshes_its_name(hub):
client, factory = hub
_, state = start(client)
callback(client, assertion(state))
_, state = start(client)
callback(client, assertion(state, name="Maria T. Souza", perfil="ADMIN"))
with factory() as session:
users = session.scalars(select(AdminUser).where(AdminUser.auth_source == "GTI_GERENCIADOR")).all()
assert len(users) == 1 and users[0].display_name == "Maria T. Souza" and users[0].external_role == "ADMIN"
@pytest.mark.parametrize("builder,reason", [
(lambda s: assertion(s, iat=time.time() - 600), "EXPIRED"),
(lambda s: assertion(s, key=Ed25519PrivateKey.generate()), "INVALID_SIGNATURE"),
(lambda s: assertion(s, aud="outro-sistema"), "WRONG_AUDIENCE"),
(lambda s: assertion(s, iss="impostor"), "WRONG_ISSUER"),
(lambda s: jwt.encode({"iss": sso.ISSUER, "aud": sso.AUDIENCE, "sub": "funcionario:7", "nonce": s}, "segredo", algorithm="HS256"), "INVALID_ALGORITHM"),
(lambda s: assertion(s, lifetime=3600), "LIFETIME_TOO_LONG"),
(lambda s: assertion(s, sub="admin"), "INVALID_SUBJECT"),
(lambda s: assertion("outro-navegador-0123456789"), "STATE_MISMATCH"),
(lambda s: "nao-e-um-jwt", "MALFORMED"),
])
def test_invalid_assertions_are_rejected_and_audited(hub, builder, reason):
client, factory = hub
_, state = start(client)
token = builder(state)
response = callback(client, token)
assert response.status_code == 401 and config.settings.web_cookie_name not in response.cookies
rejected = audits(factory, "SSO_LOGIN_REJECTED")
assert [event.context["reason"] for event in rejected] == [reason]
assert token not in str(rejected[0].context)
with factory() as session:
assert session.scalar(select(AdminUser).where(AdminUser.auth_source == "GTI_GERENCIADOR")) is None
def test_assertion_without_the_state_cookie_is_rejected(hub):
client, factory = hub
_, state = start(client)
client.cookies.delete(sso.STATE_COOKIE)
assert callback(client, assertion(state)).status_code == 401
assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "STATE_MISMATCH"
def test_replayed_assertion_is_rejected(hub):
client, factory = hub
_, state = start(client)
token = assertion(state)
assert callback(client, token).status_code == 303
client.cookies.clear()
client.cookies.set(sso.STATE_COOKIE, state) # an attacker replaying with the matching state
assert callback(client, token).status_code == 401
assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "REPLAY"
with factory() as session:
assert len(session.scalars(select(SsoUsedAssertion)).all()) == 1
@pytest.mark.parametrize("malicious", ["//evil.com/x", "https://evil.com", "javascript:alert(1)", "/\\evil.com", "data:text/html,x", "agents"])
def test_next_never_leaves_the_hub(hub, malicious):
client, _ = hub
_, state = start(client)
response = callback(client, assertion(state), malicious)
assert response.status_code == 303 and response.headers["location"] == "/"
login = client.get("/login", params={"next": malicious}, follow_redirects=False)
assert parse_qs(urlsplit(login.headers["location"]).query)["next"] == ["/"]
def test_user_disabled_in_the_hub_cannot_sso(hub):
client, factory = hub
_, state = start(client)
callback(client, assertion(state))
with factory() as session:
user = session.scalar(select(AdminUser).where(AdminUser.external_subject == "funcionario:7"))
user.is_active = False; session.commit()
client.cookies.clear()
_, state = start(client)
assert callback(client, assertion(state)).status_code == 401
assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "DISABLED_IN_FISCAL"
assert client.get("/", follow_redirects=False).status_code == 303
def test_federated_account_has_no_password_login(hub):
client, _ = hub
_, state = start(client)
callback(client, assertion(state))
client.cookies.clear()
response = client.post("/login", data={"username": "gerenciador:7", "password": "qualquer-coisa-longa"}, follow_redirects=False)
assert response.status_code == 401
assert verify_password("x", None) is False
def test_break_glass_local_admin_still_works_and_is_audited(hub):
client, factory = hub
assert client.get("/login/local").status_code == 200
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
assert response.status_code == 303
assert client.get("/").status_code == 200
assert len(audits(factory, "LOGIN_LOCAL_SUCCESS")) == 1
client.cookies.clear()
client.post("/login", data={"username": "operator", "password": "errada-mas-longa"})
assert len(audits(factory, "LOGIN_LOCAL_FAILURE")) == 1
def test_unauthenticated_page_goes_to_sso_and_keeps_the_destination(hub):
client, _ = hub
response = client.get("/blockers?status=open", follow_redirects=False)
assert response.status_code == 303
assert parse_qs(urlsplit(response.headers["location"]).query)["next"] == ["/blockers?status=open"]
def test_logout_ends_only_the_hub_session_and_csrf_is_enforced(hub):
client, factory = hub
_, state = start(client)
callback(client, assertion(state))
assert client.post("/logout", data={}).status_code == 403 # no CSRF token
assert client.get("/").status_code == 200
response = client.post("/logout", data={"csrf_token": client.cookies.get("gti_csrf")})
assert response.status_code == 200 and "Você saiu do GTI Fiscal" in response.text and "gtigerenciador.gtigrupo.com.br" in response.text
assert client.get("/", follow_redirects=False).status_code == 303
assert len(audits(factory, "LOGOUT")) == 1
_, state = start(client) # coming back through the panel signs in again, no password
assert callback(client, assertion(state)).status_code == 303
def test_expired_federated_session_sends_back_through_sso(hub):
client, factory = hub
_, state = start(client)
callback(client, assertion(state))
with factory() as session:
for row in session.scalars(select(WebSession)).all():
row.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
session.commit()
response = client.get("/jobs", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"].startswith("/login?")
def test_without_sso_configuration_the_hub_keeps_the_local_login(hub, monkeypatch):
monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, sso_public_key=""))
client, factory = hub
assert client.get("/login").status_code == 200
assert callback(client, assertion("x" * 20)).status_code == 401
assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "SSO_DISABLED"