Implements AccountRepository against the control-plane schema plus the phase-1 identity migration, and wires it in so production can select it. The adapter is not a thin SQL wrapper, because the control plane does not store the two things the application needs on the users row. Role and organization come from user_roles. There is no role column and no organization column on users; a role is assigned through user_roles with a scope, and app.validate_user_role_scope() enforces that the role's scope matches the assignment. Resolving one role per user is therefore a join, and a user holding more than one active assignment is refused rather than resolved to an arbitrary row. The organization on that assignment becomes the scope of every administrative decision, so guessing would hand out the wrong tenant's authority. Reconciling this with project-scoped membership stays an open review question, recorded as such. Email became a required field on account creation. users.email is NOT NULL UNIQUE, so accepting a missing address would let account creation succeed against the development repositories and then fail on the authoritative store. It is now required in the service, the API schema, and the bootstrap command, so it fails identically everywhere. Audit identifiers now belong to the store. audit_events.id is a bigint identity, so the adapter reports that value; the application correlation UUID is preserved in the detail and never displaces the row key. AuditEvent.id is a string for that reason, which removes a type the two stores disagreed about. Three further fail-closed behaviours: a users.status of invited or locked is refused rather than coerced onto active; a row with mfa_required is refused, because this phase cannot satisfy a second factor and authenticating it anyway would grant access to someone who was supposed to present one; and a missing organization blocks access rather than granting it. save_user deliberately does not write username or email, since both are identity keys with no reviewed change path and no audit event behind them. Every operation runs in one transaction on one pooled connection and publishes the caller with SET LOCAL app.current_user_id, so a returned connection cannot carry one request's identity into the next. That is inert until the reviewed RLS policies exist; the explicit WHERE clauses enforce scope today. A statement timeout bounds how long a pooled connection can be held. Production fails closed at two independent gates: the settings layer refuses without a DSN, and the repository boundary refuses to build a PostgreSQL store without one. Verified by running both, including that an unreachable host refuses to start rather than degrading. The migration gains an index on audit_events (target_type, target_id), which the administrator audit filter needs and nothing covered, and the review questions now include the three things that must be checked before provisioning: the roles rows and their scopes, any row with mfa_required set, and any user holding more than one active assignment. Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row mapping, role resolution, conflict mapping, and configuration are unit tested and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed: this environment has no database credentials, because sudo to the postgres user needs a password, there is no umbra database role, and there is no .pgpass. The integration suite is written and selected by UMBRATRANS_TEST_DSN, and its fixture refuses any DSN whose database name contains umbracontrol. A green run here says nothing about whether the SQL is correct.
430 lines
16 KiB
Python
430 lines
16 KiB
Python
"""HTTP authentication tests: health, login, first-password change, sessions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from umbratrans_web.app import create_app
|
|
from umbratrans_web.config import Environment, Settings
|
|
from umbratrans_web.errors import ConfigurationError
|
|
from umbratrans_web.repository import InMemoryAccountRepository
|
|
from umbratrans_web.service import AccountService
|
|
|
|
from .conftest import TEST_DSN, ApiHarness, activated_admin, provision_local, random_password
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Health
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_health_is_public_and_discloses_no_secret(client: TestClient) -> None:
|
|
response = client.get("/api/health")
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["status"] == "ok"
|
|
assert body["service"] == "umbratrans-web"
|
|
assert body["environment"] == "test"
|
|
assert body["repository"] == "memory"
|
|
assert set(body) == {
|
|
"status",
|
|
"service",
|
|
"version",
|
|
"environment",
|
|
"repository",
|
|
"cookie_secure",
|
|
"csrf_required",
|
|
"time",
|
|
}
|
|
text = response.text.lower()
|
|
for forbidden in ("password", "token", "secret", "argon2", "postgres://", "dsn"):
|
|
assert forbidden not in text
|
|
|
|
|
|
def test_api_responses_are_not_cached_and_carry_a_request_id(client: TestClient) -> None:
|
|
response = client.get("/api/health")
|
|
|
|
assert response.headers["cache-control"] == "no-store"
|
|
assert response.headers["x-content-type-options"] == "nosniff"
|
|
assert response.headers["x-frame-options"] == "DENY"
|
|
assert response.headers["referrer-policy"] == "no-referrer"
|
|
assert response.headers["x-request-id"]
|
|
|
|
|
|
def test_request_id_is_echoed_when_supplied(client: TestClient) -> None:
|
|
response = client.get("/api/health", headers={"X-Request-Id": "trace-1234"})
|
|
assert response.headers["x-request-id"] == "trace-1234"
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Login
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_login_sets_a_private_session_cookie_and_no_token_in_the_body(
|
|
api: ApiHarness, client: TestClient, settings: Settings
|
|
) -> None:
|
|
account = provision_local(_service_of(api))
|
|
|
|
response = api.login_ok(account)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["must_change_password"] is True
|
|
assert body["user"]["username"] == account.username
|
|
assert body["csrf_token"] == api.csrf()
|
|
|
|
cookie_header = response.headers["set-cookie"]
|
|
assert f"{settings.session_cookie_name}=" in cookie_header
|
|
assert "HttpOnly" in cookie_header
|
|
assert "SameSite=lax" in cookie_header
|
|
assert "Path=/" in cookie_header
|
|
# Development only: the flag is configurable and must be secure in production.
|
|
assert "Secure" not in cookie_header
|
|
assert client.cookies[settings.session_cookie_name]
|
|
|
|
assert set(body) == {
|
|
"user",
|
|
"must_change_password",
|
|
"csrf_token",
|
|
"idle_expires_at",
|
|
"absolute_expires_at",
|
|
}
|
|
serialized = response.text
|
|
assert account.password not in serialized
|
|
assert "$argon2" not in serialized
|
|
assert "password_hash" not in serialized
|
|
|
|
|
|
def test_stored_session_token_is_never_the_cookie_value(
|
|
api: ApiHarness, client: TestClient, settings: Settings, service: AccountService
|
|
) -> None:
|
|
account = activated_admin(service)
|
|
api.login_ok(account)
|
|
|
|
token = client.cookies[settings.session_cookie_name]
|
|
stored = [session for session in service.repository.list_sessions(account.user_id, include_revoked=True)]
|
|
assert stored
|
|
assert all(session.token_hash != token for session in stored)
|
|
assert all(token not in session.token_hash for session in stored)
|
|
|
|
|
|
def test_login_failures_are_generic(api: ApiHarness, service: AccountService) -> None:
|
|
"""Unknown user, wrong password and suspended account are indistinguishable."""
|
|
|
|
account = provision_local(service, username="Umbraadmin")
|
|
suspended_account = provision_local(service, username="Suspended.User", role="member")
|
|
|
|
admin = activated_admin(service, username="Admin.Operator")
|
|
api.login_ok(admin)
|
|
api.patch(
|
|
f"/api/uadmin/accounts/{suspended_account.user_id}/status",
|
|
json={"status": "suspended", "reason": "policy review"},
|
|
)
|
|
api.logout()
|
|
|
|
unknown = api.login("Nobody.Here", random_password())
|
|
wrong = api.login(account.username, random_password())
|
|
suspended = api.login(suspended_account.username, suspended_account.password)
|
|
|
|
assert unknown.status_code == wrong.status_code == suspended.status_code == 401
|
|
assert unknown.json() == wrong.json() == suspended.json()
|
|
assert unknown.json()["error"]["code"] == "invalid_credentials"
|
|
# The audit trail keeps the real reason; the response does not.
|
|
events, _ = service.repository.list_audit_events(target_user_id=suspended_account.user_id)
|
|
reasons = {event.detail.get("reason") for event in events if event.action == "auth.login"}
|
|
assert "account_suspended" in reasons
|
|
|
|
|
|
def test_validation_errors_do_not_echo_the_submitted_password(client: TestClient) -> None:
|
|
# Longer than the accepted maximum, so the request is rejected by the
|
|
# schema. FastAPI's default error body would embed this value.
|
|
password = random_password(1200)
|
|
response = client.post("/api/auth/login", json={"username": "Umbraadmin", "password": password})
|
|
|
|
assert response.status_code == 422
|
|
assert response.json()["error"]["code"] == "invalid_request"
|
|
assert response.json()["error"]["fields"] == ["password"]
|
|
assert password not in response.text
|
|
assert "input" not in response.json()["error"]
|
|
|
|
|
|
def test_missing_credentials_are_rejected_without_detail_leak(client: TestClient) -> None:
|
|
response = client.post("/api/auth/login", json={})
|
|
assert response.status_code == 422
|
|
assert response.json()["error"]["fields"] == ["password", "username"]
|
|
assert "input" not in response.text
|
|
|
|
|
|
def test_login_rejects_unknown_fields(client: TestClient) -> None:
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
json={"username": "Umbraadmin", "password": random_password(), "role": "platform_admin"},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# First-password change
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_must_change_session_cannot_reach_other_routes(
|
|
api: ApiHarness, client: TestClient, service: AccountService
|
|
) -> None:
|
|
account = provision_local(service, username="Umbraadmin")
|
|
api.login_ok(account)
|
|
|
|
me = client.get("/api/auth/me")
|
|
accounts = client.get("/api/uadmin/accounts")
|
|
|
|
assert me.status_code == 403
|
|
assert me.json()["error"]["code"] == "password_change_required"
|
|
assert accounts.status_code == 403
|
|
assert accounts.json()["error"]["code"] == "password_change_required"
|
|
|
|
|
|
def test_first_password_change_completes_the_flow(
|
|
api: ApiHarness, client: TestClient, settings: Settings, service: AccountService
|
|
) -> None:
|
|
account = provision_local(service, username="Umbraadmin")
|
|
api.login_ok(account)
|
|
old_cookie = client.cookies[settings.session_cookie_name]
|
|
new_password = random_password(28)
|
|
|
|
response = api.post(
|
|
"/api/auth/change-first-password",
|
|
json={"current_password": account.password, "new_password": new_password},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["must_change_password"] is False
|
|
new_cookie = client.cookies[settings.session_cookie_name]
|
|
assert new_cookie != old_cookie
|
|
|
|
# The rotated session works, the old one is gone, and the temporary
|
|
# password no longer authenticates.
|
|
assert client.get("/api/auth/me").status_code == 200
|
|
api.logout()
|
|
assert api.login(account.username, account.password).status_code == 401
|
|
assert api.login(account.username, new_password).status_code == 200
|
|
|
|
|
|
def test_first_password_change_validates_the_temporary_password(api: ApiHarness, service: AccountService) -> None:
|
|
account = provision_local(service, username="Umbraadmin")
|
|
api.login_ok(account)
|
|
|
|
wrong = api.post(
|
|
"/api/auth/change-first-password",
|
|
json={"current_password": random_password(), "new_password": random_password(28)},
|
|
)
|
|
weak = api.post(
|
|
"/api/auth/change-first-password",
|
|
json={"current_password": account.password, "new_password": "short"},
|
|
)
|
|
|
|
assert wrong.status_code == 401
|
|
assert wrong.json()["error"]["code"] == "invalid_credentials"
|
|
assert weak.status_code == 422
|
|
assert weak.json()["error"]["code"] == "password_policy"
|
|
# Unmet rules are named; the candidate value is never echoed.
|
|
assert "min_length" in weak.json()["error"]["requirements"]
|
|
assert "short" not in weak.text
|
|
|
|
|
|
def test_first_password_change_requires_a_csrf_token(
|
|
api: ApiHarness, client: TestClient, service: AccountService
|
|
) -> None:
|
|
account = provision_local(service, username="Umbraadmin")
|
|
api.login_ok(account)
|
|
|
|
response = client.post(
|
|
"/api/auth/change-first-password",
|
|
json={"current_password": account.password, "new_password": random_password(28)},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert response.json()["error"]["code"] == "csrf_token_missing"
|
|
|
|
|
|
def test_csrf_token_mismatch_is_rejected(api: ApiHarness, client: TestClient, settings: Settings) -> None:
|
|
account = activated_admin(_service_of(api))
|
|
api.login_ok(account)
|
|
|
|
response = client.post(
|
|
"/api/auth/logout",
|
|
headers={settings.csrf_header_name: "not-the-right-token"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert response.json()["error"]["code"] in {"csrf_token_missing", "csrf_token_invalid"}
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Sessions
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_logout_revokes_the_session_and_clears_the_cookie(
|
|
api: ApiHarness, client: TestClient, settings: Settings, service: AccountService
|
|
) -> None:
|
|
account = activated_admin(service)
|
|
api.login_ok(account)
|
|
|
|
response = api.post("/api/auth/logout")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["revoked_sessions"] == 1
|
|
assert "Max-Age=0" in response.headers["set-cookie"] or "max-age=0" in response.headers["set-cookie"]
|
|
assert client.get("/api/auth/me").status_code == 401
|
|
assert api.login(account.username, account.password).status_code == 200
|
|
|
|
|
|
def test_logout_everywhere_ends_all_sessions(
|
|
api: ApiHarness, client: TestClient, settings: Settings, service: AccountService
|
|
) -> None:
|
|
account = activated_admin(service)
|
|
api.login_ok(account)
|
|
first_cookie = client.cookies[settings.session_cookie_name]
|
|
|
|
# A second browser session for the same account.
|
|
with TestClient(client.app) as second:
|
|
second.post("/api/auth/login", json={"username": account.username, "password": account.password})
|
|
second_cookie = second.cookies[settings.session_cookie_name]
|
|
assert second_cookie != first_cookie
|
|
|
|
live_before = len(service.repository.list_sessions(account.user_id))
|
|
assert live_before >= 2
|
|
|
|
response = api.post("/api/auth/logout-everywhere")
|
|
assert response.status_code == 200
|
|
assert response.json()["revoked_sessions"] == live_before
|
|
|
|
assert second.get("/api/auth/me").status_code == 401
|
|
assert client.get("/api/auth/me").status_code == 401
|
|
assert service.repository.list_sessions(account.user_id) == []
|
|
|
|
|
|
def test_me_requires_a_session(client: TestClient) -> None:
|
|
response = client.get("/api/auth/me")
|
|
assert response.status_code == 401
|
|
assert response.json()["error"]["code"] == "authentication_required"
|
|
assert response.headers["x-umbratrans-reauthenticate"] == "1"
|
|
|
|
|
|
def test_me_returns_only_the_callers_own_identity(api: ApiHarness, service: AccountService) -> None:
|
|
account = activated_admin(service, username="Admin.One")
|
|
other = provision_local(service, username="Other.User", role="member")
|
|
api.login_ok(account)
|
|
|
|
response = api.client.get("/api/auth/me")
|
|
body = response.json()
|
|
|
|
assert body["user"]["id"] == str(account.user_id)
|
|
assert body["user"]["username"] == account.username
|
|
assert body["role"] == "platform_admin"
|
|
assert body["must_change_password"] is False
|
|
assert str(other.user_id) not in response.text
|
|
assert other.password not in response.text
|
|
assert "password_hash" not in response.text
|
|
|
|
|
|
def test_forged_session_cookie_is_rejected(client: TestClient, settings: Settings) -> None:
|
|
client.cookies.set(settings.session_cookie_name, random_password(40))
|
|
response = client.get("/api/auth/me")
|
|
assert response.status_code == 401
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Cookie hardening
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_production_cookies_are_secure(settings: Settings) -> None:
|
|
production = Settings(
|
|
environment=Environment.PRODUCTION,
|
|
repository_kind="postgres",
|
|
database_dsn=TEST_DSN,
|
|
cookie_secure=True,
|
|
)
|
|
repository = InMemoryAccountRepository()
|
|
service = AccountService(repository, production)
|
|
app = create_app(settings=production, repository=repository, service=service)
|
|
account = activated_admin(service, username="Umbraadmin")
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/auth/login", json={"username": account.username, "password": account.password})
|
|
cookie_header = response.headers["set-cookie"]
|
|
|
|
assert response.status_code == 200
|
|
assert "Secure" in cookie_header
|
|
assert "HttpOnly" in cookie_header
|
|
assert "SameSite=lax" in cookie_header
|
|
|
|
|
|
def test_production_refuses_insecure_configuration() -> None:
|
|
with pytest.raises(ConfigurationError) as error:
|
|
Settings(
|
|
environment=Environment.PRODUCTION,
|
|
database_dsn=TEST_DSN,
|
|
repository_kind="postgres",
|
|
cookie_secure=False,
|
|
)
|
|
assert error.value.code == "insecure_production_cookie"
|
|
|
|
with pytest.raises(ConfigurationError) as csrf_error:
|
|
Settings(
|
|
environment=Environment.PRODUCTION,
|
|
database_dsn=TEST_DSN,
|
|
repository_kind="postgres",
|
|
cookie_secure=True,
|
|
require_csrf=False,
|
|
)
|
|
assert csrf_error.value.code == "insecure_production_csrf"
|
|
|
|
with pytest.raises(ConfigurationError) as repository_error:
|
|
Settings(
|
|
environment=Environment.PRODUCTION,
|
|
database_dsn=TEST_DSN,
|
|
repository_kind="memory",
|
|
cookie_secure=True,
|
|
)
|
|
assert repository_error.value.code == "production_repository_missing"
|
|
|
|
|
|
def test_production_app_requires_an_explicit_repository() -> None:
|
|
production = Settings(
|
|
environment=Environment.PRODUCTION,
|
|
repository_kind="postgres",
|
|
database_dsn=TEST_DSN,
|
|
cookie_secure=True,
|
|
)
|
|
with pytest.raises(ConfigurationError) as error:
|
|
create_app(settings=production)
|
|
assert error.value.code == "production_repository_missing"
|
|
|
|
|
|
def test_wildcard_hosts_are_refused() -> None:
|
|
with pytest.raises(ConfigurationError) as error:
|
|
Settings(environment=Environment.TEST, allowed_hosts=("example.test", "*"))
|
|
assert error.value.code == "wildcard_host_forbidden"
|
|
|
|
|
|
def test_allowed_hosts_are_enforced_when_configured() -> None:
|
|
settings = Settings(
|
|
environment=Environment.TEST,
|
|
allowed_hosts=("umbratrans.bdmurphy.me",),
|
|
argon2_memory_cost_kib=8 * 1024,
|
|
argon2_time_cost=1,
|
|
argon2_parallelism=1,
|
|
)
|
|
app = create_app(settings=settings, repository=InMemoryAccountRepository())
|
|
with TestClient(app) as client:
|
|
assert client.get("/api/health", headers={"Host": "umbratrans.bdmurphy.me"}).status_code == 200
|
|
assert client.get("/api/health", headers={"Host": "attacker.example"}).status_code == 400
|
|
|
|
|
|
def _service_of(api: ApiHarness) -> AccountService:
|
|
return api.client.app.state.service
|