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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user