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:
+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