Files
UmbraTrans/umbratrans_web/tests/test_api_auth.py
umbra audio b474995414 Add phase-1 account management backend slice
Implements the account and user-management system behind the new web UI
against development repositories only. Nothing here connects to, reads, or
modifies the production umbracontrol database, and no account is created in
any approved environment.

Account service and API:
- Local temporary-password and OIDC enrollment, separated by type.
- Argon2id credentials, must-change enforcement, server-side sessions with
  hashed tokens, idle and absolute deadlines, rotation and revocation.
- Administrator account management: list, get, create, suspend, reactivate,
  deactivate, revoke sessions, org-scoped audit view.
- Hidden-prompt operator command for the first administrator, refusing
  production mode and refusing to run without a TTY.

Temporary passwords are never accepted from a client. The request schema has
no password field and refuses unknown fields; the administrator path generates
the one-time password in process, hashes it immediately, and returns it once in
the creation response. The account can reach no route, including
/api/auth/me, until it replaces that password, after which the one-time value
no longer authenticates.

Security posture: fail-closed configuration, Secure cookies in production,
double-submit CSRF, constant-time token comparison, generic authentication
errors, audit detail allow-listing, and logger-tree secret redaction.

Reviewed but unapplied: migrations/0001_phase1_identity.sql adds
users.username, a separate user_credentials table, and additive session
columns, with a rollback and open review questions. The PostgreSQL adapter is
deliberately absent, so the application refuses to start in production rather
than run against an unreviewed store.

Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff
format, compileall, and make web-check are clean.
2026-09-26 08:29:23 -07:00

409 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 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", 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, repository_kind="postgres", cookie_secure=False)
assert error.value.code == "insecure_production_cookie"
with pytest.raises(ConfigurationError) as csrf_error:
Settings(
environment=Environment.PRODUCTION,
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, 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", 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