379 lines
13 KiB
Python
379 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import SecretStr
|
|
|
|
from app.auth.service import LoginRateLimiter
|
|
from app.core.errors import AppError
|
|
from app.core.logging import JsonFormatter, configure_logging
|
|
from app.main import create_app
|
|
from tests.conftest import csrf_headers
|
|
|
|
|
|
def test_first_setup_login_and_csrf_flow(client: TestClient) -> None:
|
|
bootstrap = client.get("/api/v1/auth/bootstrap")
|
|
assert bootstrap.status_code == 200
|
|
assert bootstrap.json()["data"] == {
|
|
"requires_setup": True,
|
|
"bootstrap_token_required": False,
|
|
"authenticated": False,
|
|
"username": None,
|
|
}
|
|
assert client.get("/api/v1/auth/me").status_code == 401
|
|
|
|
setup = client.post(
|
|
"/api/v1/auth/setup",
|
|
json={"username": " admin ", "password": "correct-horse-battery-staple"},
|
|
)
|
|
assert setup.status_code == 201
|
|
assert setup.json()["data"] == {"username": "admin"}
|
|
set_cookie = setup.headers.get_list("set-cookie")
|
|
assert any("fluxip_session=" in value and "HttpOnly" in value for value in set_cookie)
|
|
assert any("fluxip_csrf=" in value and "HttpOnly" not in value for value in set_cookie)
|
|
assert all("SameSite=strict" in value for value in set_cookie)
|
|
|
|
authenticated = client.get("/api/v1/auth/bootstrap")
|
|
assert authenticated.json()["data"] == {
|
|
"requires_setup": False,
|
|
"bootstrap_token_required": False,
|
|
"authenticated": True,
|
|
"username": "admin",
|
|
}
|
|
assert client.get("/api/v1/auth/me").json()["data"] == {"username": "admin"}
|
|
|
|
duplicate = client.post(
|
|
"/api/v1/auth/setup",
|
|
json={"username": "other", "password": "another-secure-password"},
|
|
)
|
|
assert duplicate.status_code == 409
|
|
assert duplicate.json()["type"] == "about:blank#setup_already_completed"
|
|
|
|
missing_csrf = client.post("/api/v1/auth/logout")
|
|
assert missing_csrf.status_code == 403
|
|
assert missing_csrf.json()["type"] == "about:blank#csrf_validation_failed"
|
|
assert (
|
|
client.post("/api/v1/auth/logout", headers={"X-CSRF-Token": "incorrect-token"}).status_code
|
|
== 403
|
|
)
|
|
|
|
logout = client.post("/api/v1/auth/logout", headers=csrf_headers(client))
|
|
assert logout.status_code == 204
|
|
assert client.get("/api/v1/auth/me").status_code == 401
|
|
|
|
failed_login = client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "admin", "password": "wrong-password"},
|
|
)
|
|
assert failed_login.status_code == 401
|
|
assert failed_login.json()["type"] == "about:blank#authentication_required"
|
|
|
|
login = client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": " admin ", "password": "correct-horse-battery-staple"},
|
|
)
|
|
assert login.status_code == 200
|
|
assert client.get("/api/v1/auth/me").json()["data"]["username"] == "admin"
|
|
|
|
|
|
def test_bootstrap_token_is_required_for_remote_setup(app_settings) -> None:
|
|
app_settings.bootstrap_token = SecretStr("one-time-bootstrap-token")
|
|
application = create_app(app_settings)
|
|
with TestClient(
|
|
application,
|
|
base_url="http://testserver",
|
|
client=("203.0.113.10", 50000),
|
|
) as client:
|
|
state = client.get("/api/v1/auth/bootstrap").json()["data"]
|
|
assert state["requires_setup"] is True
|
|
assert state["bootstrap_token_required"] is True
|
|
|
|
missing = client.post(
|
|
"/api/v1/auth/setup",
|
|
json={"username": "admin", "password": "correct-horse-battery-staple"},
|
|
)
|
|
assert missing.status_code == 403
|
|
assert missing.json()["type"] == "about:blank#bootstrap_token_invalid"
|
|
|
|
wrong = client.post(
|
|
"/api/v1/auth/setup",
|
|
json={
|
|
"username": "admin",
|
|
"password": "correct-horse-battery-staple",
|
|
"bootstrap_token": "错误令牌",
|
|
},
|
|
)
|
|
assert wrong.status_code == 403
|
|
|
|
setup = client.post(
|
|
"/api/v1/auth/setup",
|
|
json={
|
|
"username": "admin",
|
|
"password": "correct-horse-battery-staple",
|
|
"bootstrap_token": "one-time-bootstrap-token",
|
|
},
|
|
)
|
|
assert setup.status_code == 201
|
|
|
|
|
|
def test_setup_without_token_is_loopback_only_and_ignores_untrusted_forwarding(
|
|
app_settings,
|
|
) -> None:
|
|
application = create_app(app_settings)
|
|
with TestClient(
|
|
application,
|
|
base_url="http://testserver",
|
|
client=("203.0.113.10", 50000),
|
|
) as client:
|
|
response = client.post(
|
|
"/api/v1/auth/setup",
|
|
headers={"X-Forwarded-For": "127.0.0.1"},
|
|
json={"username": "admin", "password": "correct-horse-battery-staple"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["type"] == "about:blank#bootstrap_local_only"
|
|
|
|
|
|
def test_setup_uses_forwarded_client_only_from_trusted_proxy(app_settings) -> None:
|
|
application = create_app(app_settings)
|
|
with TestClient(
|
|
application,
|
|
base_url="http://testserver",
|
|
client=("127.0.0.1", 50000),
|
|
) as client:
|
|
response = client.post(
|
|
"/api/v1/auth/setup",
|
|
headers={"X-Forwarded-For": "203.0.113.25"},
|
|
json={"username": "admin", "password": "correct-horse-battery-staple"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["type"] == "about:blank#bootstrap_local_only"
|
|
|
|
|
|
def test_secure_cookie_honors_https_and_trusted_proxy_boundary(app_settings) -> None:
|
|
app_settings.bootstrap_token = SecretStr("one-time-bootstrap-token")
|
|
application = create_app(app_settings)
|
|
with TestClient(
|
|
application,
|
|
base_url="http://testserver",
|
|
client=("203.0.113.10", 50000),
|
|
) as client:
|
|
response = client.post(
|
|
"/api/v1/auth/setup",
|
|
headers={"X-Forwarded-Proto": "https"},
|
|
json={
|
|
"username": "admin",
|
|
"password": "correct-horse-battery-staple",
|
|
"bootstrap_token": "one-time-bootstrap-token",
|
|
},
|
|
)
|
|
assert response.status_code == 201
|
|
assert all("Secure" not in value for value in response.headers.get_list("set-cookie"))
|
|
|
|
proxy_settings = app_settings.model_copy(
|
|
update={
|
|
"database_path": app_settings.database_path.with_name("proxy.db"),
|
|
"master_key_file": app_settings.master_key_file.with_name("proxy.key"),
|
|
}
|
|
)
|
|
application = create_app(proxy_settings)
|
|
with TestClient(
|
|
application,
|
|
base_url="http://testserver",
|
|
client=("127.0.0.1", 50000),
|
|
) as client:
|
|
response = client.post(
|
|
"/api/v1/auth/setup",
|
|
headers={"X-Forwarded-Proto": "https", "X-Forwarded-For": "203.0.113.10"},
|
|
json={
|
|
"username": "admin",
|
|
"password": "correct-horse-battery-staple",
|
|
"bootstrap_token": "one-time-bootstrap-token",
|
|
},
|
|
)
|
|
assert response.status_code == 201
|
|
assert all("Secure" in value for value in response.headers.get_list("set-cookie"))
|
|
assert response.headers["strict-transport-security"].startswith("max-age=")
|
|
|
|
|
|
def test_https_origin_forces_secure_cookie(app_settings) -> None:
|
|
app_settings.bootstrap_token = SecretStr("one-time-bootstrap-token")
|
|
app_settings.allowed_origins = "http://testserver,https://testserver"
|
|
application = create_app(app_settings)
|
|
with TestClient(
|
|
application,
|
|
base_url="http://testserver",
|
|
client=("203.0.113.10", 50000),
|
|
) as client:
|
|
response = client.post(
|
|
"/api/v1/auth/setup",
|
|
headers={"Origin": "https://testserver"},
|
|
json={
|
|
"username": "admin",
|
|
"password": "correct-horse-battery-staple",
|
|
"bootstrap_token": "one-time-bootstrap-token",
|
|
},
|
|
)
|
|
assert response.status_code == 201
|
|
assert all("Secure" in value for value in response.headers.get_list("set-cookie"))
|
|
|
|
|
|
def test_login_rate_limiter_is_bounded_and_cleans_expired_keys() -> None:
|
|
now = [0.0]
|
|
limiter = LoginRateLimiter(
|
|
attempts=1,
|
|
window_seconds=10,
|
|
max_keys=2,
|
|
cleanup_interval_seconds=1,
|
|
clock=lambda: now[0],
|
|
)
|
|
|
|
limiter.check("read-only-check-does-not-allocate")
|
|
assert not limiter._events
|
|
limiter.record_failure("first")
|
|
limiter.record_failure("second")
|
|
limiter.record_failure("third")
|
|
assert list(limiter._events) == ["second", "third"]
|
|
limiter.check("first")
|
|
with pytest.raises(AppError) as raised:
|
|
limiter.check("second")
|
|
assert raised.value.code == "LOGIN_RATE_LIMITED"
|
|
|
|
now[0] = 10.0
|
|
limiter.check("second")
|
|
assert not limiter._events
|
|
|
|
|
|
def test_json_logging_redacts_nested_credentials_and_dependency_logs() -> None:
|
|
class OpaqueExtra:
|
|
def __str__(self) -> str:
|
|
return "OpaqueExtra(token=object-secret)"
|
|
|
|
formatter = JsonFormatter()
|
|
record = logging.LogRecord(
|
|
name="test",
|
|
level=logging.INFO,
|
|
pathname=__file__,
|
|
lineno=1,
|
|
msg=(
|
|
"Authorization: Bearer bearer-value token=query-value "
|
|
"url=https://example.test/?api_key=url-value AKIAABCDEFGHIJKLMNOP"
|
|
),
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
record.api_token = "structured-token"
|
|
record.context = {
|
|
"password": "nested-password",
|
|
"master-key": "master-key-value",
|
|
"safe": "visible-value",
|
|
"items": [{"aws_access_key_id": "AKIAABCDEFGHIJKLMNOP"}],
|
|
}
|
|
record.opaque = OpaqueExtra()
|
|
|
|
serialized = formatter.format(record)
|
|
payload = json.loads(serialized)
|
|
for secret in (
|
|
"bearer-value",
|
|
"query-value",
|
|
"url-value",
|
|
"AKIAABCDEFGHIJKLMNOP",
|
|
"structured-token",
|
|
"nested-password",
|
|
"master-key-value",
|
|
"object-secret",
|
|
):
|
|
assert secret not in serialized
|
|
assert payload["api_token"] == "[REDACTED]"
|
|
assert payload["context"]["safe"] == "visible-value"
|
|
|
|
try:
|
|
configure_logging("DEBUG")
|
|
for logger_name in ("botocore", "boto3", "httpx", "httpcore"):
|
|
assert logging.getLogger(logger_name).level == logging.WARNING
|
|
finally:
|
|
configure_logging("WARNING")
|
|
|
|
|
|
def test_integration_secrets_are_encrypted_and_masked(
|
|
authenticated_client: TestClient,
|
|
integration_payload: dict[str, object],
|
|
) -> None:
|
|
client = authenticated_client
|
|
response = client.put(
|
|
"/api/v1/integrations",
|
|
json=integration_payload,
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert response.status_code == 200
|
|
view = response.json()["data"]
|
|
assert view["config_version"] == 2
|
|
assert view["secrets_configured"] == {
|
|
"aws_access_key_id": True,
|
|
"aws_secret_access_key": True,
|
|
"aws_session_token": True,
|
|
"cloudflare_api_token": True,
|
|
}
|
|
serialized_response = response.text
|
|
for secret in (
|
|
"AKIA_TEST_ACCESS_KEY",
|
|
"aws-secret-value",
|
|
"aws-session-value",
|
|
"cloudflare-secret-value",
|
|
):
|
|
assert secret not in serialized_response
|
|
|
|
stored_view = client.get("/api/v1/integrations")
|
|
assert stored_view.status_code == 200
|
|
assert stored_view.json()["data"] == view
|
|
|
|
container = client.app.state.container
|
|
with container.database.connect() as connection:
|
|
rows = connection.execute(
|
|
"SELECT name, nonce, ciphertext FROM secrets ORDER BY name"
|
|
).fetchall()
|
|
assert {row["name"] for row in rows} == {
|
|
"aws_access_key_id",
|
|
"aws_secret_access_key",
|
|
"aws_session_token",
|
|
"cloudflare_api_token",
|
|
}
|
|
stored_blob = " ".join(f"{row['nonce']} {row['ciphertext']}" for row in rows)
|
|
assert all(
|
|
secret not in stored_blob
|
|
for secret in (
|
|
"AKIA_TEST_ACCESS_KEY",
|
|
"aws-secret-value",
|
|
"aws-session-value",
|
|
"cloudflare-secret-value",
|
|
)
|
|
)
|
|
assert container.integration_repository.get_secrets() == {
|
|
"aws_access_key_id": "AKIA_TEST_ACCESS_KEY",
|
|
"aws_secret_access_key": "aws-secret-value",
|
|
"aws_session_token": "aws-session-value",
|
|
"cloudflare_api_token": "cloudflare-secret-value",
|
|
}
|
|
|
|
preserve_payload = {
|
|
**integration_payload,
|
|
"aws_access_key_id": None,
|
|
"aws_secret_access_key": None,
|
|
"aws_session_token": None,
|
|
"cloudflare_api_token": None,
|
|
}
|
|
preserved = client.put(
|
|
"/api/v1/integrations",
|
|
json=preserve_payload,
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert preserved.status_code == 200
|
|
assert preserved.json()["data"]["config_version"] == 3
|
|
assert all(preserved.json()["data"]["secrets_configured"].values())
|
|
assert container.integration_repository.get_secret("cloudflare_api_token") == (
|
|
"cloudflare-secret-value"
|
|
)
|