Files
gti d21a9d1140 Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
2026-09-29 14:43:23 -03:00

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