git-subtree-dir: fiscal git-subtree-mainline:d95165fc8fgit-subtree-split:102ba84500
236 lines
11 KiB
Python
236 lines
11 KiB
Python
"""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"
|