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.
409 lines
16 KiB
Python
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
|