diff --git a/central/alembic/versions/0021_sso_federated_identity.py b/central/alembic/versions/0021_sso_federated_identity.py new file mode 100644 index 0000000..c1cf777 --- /dev/null +++ b/central/alembic/versions/0021_sso_federated_identity.py @@ -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) diff --git a/central/app/config.py b/central/app/config.py index 57214a0..e8eff43 100644 --- a/central/app/config.py +++ b/central/app/config.py @@ -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") diff --git a/central/app/human_auth.py b/central/app/human_auth.py index 34f9568..4eac924 100644 --- a/central/app/human_auth.py +++ b/central/app/human_auth.py @@ -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 diff --git a/central/app/models.py b/central/app/models.py index 8bf1f4d..f782d6b 100644 --- a/central/app/models.py +++ b/central/app/models.py @@ -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): diff --git a/central/app/sso.py b/central/app/sso.py new file mode 100644 index 0000000..84fe176 --- /dev/null +++ b/central/app/sso.py @@ -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 diff --git a/central/app/web.py b/central/app/web.py index ddf5faa..f727934 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -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): diff --git a/central/pyproject.toml b/central/pyproject.toml index 4bee8a8..58ab4d5 100644 --- a/central/pyproject.toml +++ b/central/pyproject.toml @@ -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", ] diff --git a/central/static/control-panel.css b/central/static/control-panel.css index 3af7efb..acd53ac 100644 --- a/central/static/control-panel.css +++ b/central/static/control-panel.css @@ -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: {% endif %} - -
{% block content %}{% endblock %}
+{% set path = request.url.path %} +
+ + +
+
+ + GTI Fiscal +
+
{% block content %}{% endblock %}
+
+
+ + diff --git a/central/templates/logged_out.html b/central/templates/logged_out.html new file mode 100644 index 0000000..a8e147f --- /dev/null +++ b/central/templates/logged_out.html @@ -0,0 +1,22 @@ + + + + + + Você saiu | GTI Fiscal + + + + + +
+ +

Você saiu do GTI Fiscal

+

Sua sessão no Painel de Clientes continua ativa.

+
+ {% if panel_home %}Voltar ao Painel de Clientes{% endif %} + Entrar novamente no Fiscal +
+
+ + diff --git a/central/templates/login.html b/central/templates/login.html index 23545c5..a133132 100644 --- a/central/templates/login.html +++ b/central/templates/login.html @@ -1 +1,30 @@ -Login | GTI Fiscal

GTI FISCAL / CONTROL PANEL

Entrar no painel

{% if error %}
{{ error }}
{% endif %}
+ + + + + + {% if emergency %}Acesso de emergência{% else %}Entrar{% endif %} | GTI Fiscal + + + + + +
+ +

GTI Fiscal

+ {% if emergency %} +

Acesso de emergência (conta local). Colaboradores entram pelo Painel de Clientes.

+ {% else %} +

Entre com suas credenciais para acessar o sistema

+ {% endif %} + {% if error %}
{{ error }}
{% endif %} +
+ + + + + +
+
+ + diff --git a/central/templates/sso_error.html b/central/templates/sso_error.html new file mode 100644 index 0000000..ba2218e --- /dev/null +++ b/central/templates/sso_error.html @@ -0,0 +1,30 @@ + + + + + + Não foi possível entrar | GTI Fiscal + + + + + +
+ +

Não foi possível entrar no GTI Fiscal

+ {% 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." + } %} +

{{ messages.get(reason, "A identificação enviada pelo Painel de Clientes não foi aceita.") }}

+
+ Tentar novamente + {% if panel_home %}Voltar ao Painel de Clientes{% endif %} +
+

Código: {{ reason }}

+
+ + diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py index f8ba405..6429ff8 100644 --- a/central/tests/test_control_panel_pages.py +++ b/central/tests/test_control_panel_pages.py @@ -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 '' in response.text assert '' in response.text diff --git a/central/tests/test_sso.py b/central/tests/test_sso.py new file mode 100644 index 0000000..b16df91 --- /dev/null +++ b/central/tests/test_sso.py @@ -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" diff --git a/deploy/hub/.env.example b/deploy/hub/.env.example index 9545995..cabbd5d 100644 --- a/deploy/hub/.env.example +++ b/deploy/hub/.env.example @@ -17,6 +17,13 @@ FORWARDED_ALLOW_IPS=127.0.0.1 # 1 once HTTPS terminates in front of the Hub (session/CSRF cookies become Secure). WEB_COOKIE_SECURE=0 +# SSO from GTI Gerenciador (docs/PILOT_DEPLOYMENT.md, "Human access"). Needs HTTPS (WEB_COOKIE_SECURE=1). +# PUBLIC key only (raw Ed25519, base64url) — the private key lives only in the panel. +SSO_GERENCIADOR_CHAVE_PUBLICA= +SSO_START_URL=https://gtigerenciador.example.gti/sso/fiscal +# Collaborator session lifetime; afterwards they are signed in again transparently. +FEDERATED_SESSION_SECONDS=3600 + LOG_LEVEL=INFO AGENT_ONLINE_THRESHOLD_SECONDS=120 diff --git a/deploy/hub/docker-compose.prod.yml b/deploy/hub/docker-compose.prod.yml index b83265d..20f4e34 100644 --- a/deploy/hub/docker-compose.prod.yml +++ b/deploy/hub/docker-compose.prod.yml @@ -52,6 +52,11 @@ services: FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-127.0.0.1} # 1 = session/CSRF cookies are sent only over HTTPS. Set once TLS is in front. WEB_COOKIE_SECURE: ${WEB_COOKIE_SECURE:-0} + # SSO from GTI Gerenciador (central/app/sso.py): the panel's Ed25519 PUBLIC key + # and its /sso/fiscal URL. Both empty = SSO off (local login only). + SSO_GERENCIADOR_CHAVE_PUBLICA: ${SSO_GERENCIADOR_CHAVE_PUBLICA:-} + SSO_START_URL: ${SSO_START_URL:-} + FEDERATED_SESSION_SECONDS: ${FEDERATED_SESSION_SECONDS:-3600} # AGENT_DEV_TOKEN is intentionally NOT set: real Agents are registered with central/scripts/register_agent.py. ports: - "${HUB_BIND:-127.0.0.1}:${HUB_PORT:-8000}:8000" diff --git a/docs/PILOT_DEPLOYMENT.md b/docs/PILOT_DEPLOYMENT.md index 27024f9..ea554fd 100644 --- a/docs/PILOT_DEPLOYMENT.md +++ b/docs/PILOT_DEPLOYMENT.md @@ -155,6 +155,44 @@ generate absolute links, but keep it accurate for anyone reading the config). `gti-fiscal-hub-port.service` (instructions in the unit file) so only the NPM reaches the port, re-applied on every Docker start. +## Human access: SSO from GTI Gerenciador + +Collaborators never type a second password. GTI Gerenciador (the panel) is the +identity authority; the Hub keeps its own sessions. + +1. "Fiscal" in the panel sidebar -> panel `/sso/fiscal` -> Hub `GET /login`, + which sets a one-time state cookie (`gti_sso_state`, HttpOnly, Secure, + SameSite=None, 5 min) and redirects to `SSO_START_URL?state=...&next=...`. +2. The panel requires its own login (normal login page if needed), checks the + employee is active and auto-POSTs a JWT to `/auth/sso/callback`: + Ed25519 (`EdDSA` only), `iss=gti-gerenciador`, `aud=gti-fiscal`, + `sub=funcionario:`, 60 s, unique `jti`, `nonce`=state. Never in a URL. +3. The Hub verifies signature/iss/aud/exp/nbf/lifetime, the nonce against the + state cookie (login-CSRF protection), records the `jti` + (`sso_used_assertions`, replay = rejected), provisions or refreshes the + collaborator in `admin_users` (`auth_source=GTI_GERENCIADOR`, no password, + enforced by `ck_admin_users_auth_source`) and opens a session of + `FEDERATED_SESSION_SECONDS` (default 1 h; afterwards the same flow signs the + user in again silently, which also picks up a deactivation in the panel). + +Keys: a dedicated Ed25519 pair, used for nothing else (never reuse +SESSION_SECRET, admin, Agent or DB secrets). The **private** key +(raw 32-byte seed, base64url) goes only to the panel's `.env` +(`SSO_FISCAL_CHAVE_PRIVADA`); the **public** key only to this Hub's `.env` +(`SSO_GERENCIADOR_CHAVE_PUBLICA`). Rotating = new pair in both, restart both. + +Logout ("Sair do Fiscal") ends only the Hub session; coming back through the +panel signs in again without a password. There is no distributed single logout. + +Audit (`audit_events`): `SSO_LOGIN_SUCCESS`, `SSO_LOGIN_REJECTED` (with a stable +reason: `EXPIRED`, `INVALID_SIGNATURE`, `WRONG_AUDIENCE`, `WRONG_ISSUER`, +`REPLAY`, `STATE_MISMATCH`, `DISABLED_IN_FISCAL`, ...), `LOGOUT`, +`LOGIN_LOCAL_SUCCESS/FAILURE` — with the real client IP, never the token. + +**Break-glass:** the local admin (`create_admin.py`) still exists for +emergencies at `/login/local` (not linked from the UI; every use is audited). +Disable a collaborator only in the Hub: `admin_users.is_active = false`. + ## Registering an Agent (per client installation) Run once per Agent installation, from anywhere with access to Postgres —