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>
174 lines
6.9 KiB
Python
174 lines
6.9 KiB
Python
"""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
|