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:
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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");
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user