feat(dashboard): persist administrator login #7
@@ -104,6 +104,8 @@ async def login(request: Request) -> Response:
|
||||
keep_blank_values=True,
|
||||
strict_parsing=True,
|
||||
max_num_fields=3,
|
||||
encoding="utf-8",
|
||||
errors="strict",
|
||||
)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||
|
||||
@@ -113,8 +113,12 @@ def verify_credentials(
|
||||
) -> bool:
|
||||
if not isinstance(submitted_id, str) or not isinstance(submitted_password, str):
|
||||
return False
|
||||
id_matches = hmac.compare_digest(submitted_id, credentials.admin_id)
|
||||
password_matches = hmac.compare_digest(submitted_password, credentials.password)
|
||||
id_matches = hmac.compare_digest(
|
||||
submitted_id.encode("utf-8"), credentials.admin_id.encode("utf-8")
|
||||
)
|
||||
password_matches = hmac.compare_digest(
|
||||
submitted_password.encode("utf-8"), credentials.password.encode("utf-8")
|
||||
)
|
||||
return id_matches and password_matches
|
||||
|
||||
|
||||
|
||||
@@ -87,6 +87,50 @@ def test_remember_me_sets_30_day_cookie(client: TestClient) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_unicode_credentials_can_login_through_adapter(client: TestClient, monkeypatch) -> None:
|
||||
unicode_id = "管理员"
|
||||
unicode_password = "päss🔐"
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id)
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password)
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": unicode_id, "admin_password": unicode_password},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/"
|
||||
assert f"{authentication.AUTH_COOKIE_NAME}=" in response.headers["set-cookie"]
|
||||
|
||||
|
||||
def test_unicode_credential_mismatch_is_generic_and_delayed(
|
||||
client: TestClient, monkeypatch
|
||||
) -> None:
|
||||
import time as real_time
|
||||
|
||||
unicode_id = "管理员"
|
||||
unicode_password = "päss🔐"
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id)
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password)
|
||||
monkeypatch.setattr(authentication.time, "sleep", real_time.sleep)
|
||||
|
||||
started = real_time.monotonic()
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": unicode_id, "admin_password": "неверный🔒"},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
elapsed = real_time.monotonic() - started
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/?auth_error=invalid"
|
||||
assert "set-cookie" not in response.headers
|
||||
assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS * 0.9
|
||||
|
||||
|
||||
def test_invalid_credentials_do_not_set_cookie_and_use_general_error(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
@@ -104,6 +148,23 @@ def test_invalid_credentials_do_not_set_cookie_and_use_general_error(
|
||||
assert ADMIN_ID not in response.headers["location"]
|
||||
|
||||
|
||||
def test_login_rejects_malformed_percent_encoded_utf8_without_cookie(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
content=b"admin_id=%FF&admin_password=wrong",
|
||||
headers={
|
||||
"origin": "https://testserver",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "set-cookie" not in response.headers
|
||||
|
||||
|
||||
def test_missing_configuration_fails_closed(client: TestClient, monkeypatch) -> None:
|
||||
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV)
|
||||
|
||||
|
||||
@@ -63,20 +63,26 @@ def test_load_admin_credentials_fails_closed_when_environment_cannot_be_read():
|
||||
|
||||
def test_verify_credentials_uses_constant_time_comparison_for_both_fields(monkeypatch):
|
||||
credentials = authentication.AdminCredentials(ADMIN_ID, ADMIN_PASSWORD)
|
||||
comparisons: list[tuple[str, str]] = []
|
||||
comparisons: list[tuple[bytes, bytes]] = []
|
||||
|
||||
def record_compare(left: str, right: str) -> bool:
|
||||
def record_compare(left: bytes, right: bytes) -> bool:
|
||||
comparisons.append((left, right))
|
||||
return left == right
|
||||
|
||||
monkeypatch.setattr(authentication.hmac, "compare_digest", record_compare)
|
||||
|
||||
assert authentication.verify_credentials(credentials, ADMIN_ID, ADMIN_PASSWORD) is True
|
||||
assert comparisons == [(ADMIN_ID, ADMIN_ID), (ADMIN_PASSWORD, ADMIN_PASSWORD)]
|
||||
assert comparisons == [
|
||||
(ADMIN_ID.encode("utf-8"), ADMIN_ID.encode("utf-8")),
|
||||
(ADMIN_PASSWORD.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")),
|
||||
]
|
||||
|
||||
comparisons.clear()
|
||||
assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False
|
||||
assert comparisons == [("wrong-id", ADMIN_ID), ("wrong-password", ADMIN_PASSWORD)]
|
||||
assert comparisons == [
|
||||
(b"wrong-id", ADMIN_ID.encode("utf-8")),
|
||||
(b"wrong-password", ADMIN_PASSWORD.encode("utf-8")),
|
||||
]
|
||||
|
||||
|
||||
def test_failed_login_delay_waits_only_for_remaining_minimum_duration():
|
||||
|
||||
Reference in New Issue
Block a user