Production Login page passcode updates
This commit is contained in:
@@ -326,3 +326,123 @@ def test_malformed_hash_fails_closed(bad):
|
||||
from app.infrastructure.security import verify_password
|
||||
|
||||
assert verify_password("anything", bad) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Why a sign-in failed
|
||||
# ---------------------------------------------------------------------------
|
||||
# The caller is told the same thing whatever went wrong - that is deliberate and
|
||||
# is pinned below. The operator is not: an account whose configured hash is
|
||||
# stale or corrupt needs a different repair from a mistyped password, and
|
||||
# collapsing the two is how a production sign-in outage stayed unexplained for a
|
||||
# day. These tests hold both halves at once: three reasons in the log, one
|
||||
# response on the wire.
|
||||
#
|
||||
# Throttle budget: conftest sets AUTH_MAX_LOGIN_ATTEMPTS=3 per (username, IP),
|
||||
# so each test below keeps `admin` to at most two attempts. Exceeding it turns a
|
||||
# 401 assertion into a 429 and reads like a code bug.
|
||||
import logging
|
||||
|
||||
from app.api.routers import auth as auth_router
|
||||
from app.infrastructure.security import hash_is_wellformed
|
||||
|
||||
_AUTH_LOGGER = "app.api.routers.auth"
|
||||
|
||||
|
||||
def test_an_unknown_username_is_logged_as_such(client, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER):
|
||||
assert client.post(
|
||||
"/api/auth/login", json={"username": "nobody", "password": "whatever"}
|
||||
).status_code == 401
|
||||
|
||||
assert "reason=unknown-username" in caplog.text
|
||||
# Names the setting to look at, since that is the actual repair.
|
||||
assert "AUTH_ADMIN_USERNAME" in caplog.text
|
||||
|
||||
|
||||
def test_a_wrong_password_is_logged_as_such(client, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER):
|
||||
assert client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "not-the-password"}
|
||||
).status_code == 401
|
||||
|
||||
assert "reason=bad-password" in caplog.text
|
||||
|
||||
|
||||
def test_a_malformed_configured_hash_is_logged_as_an_error(client, caplog, monkeypatch):
|
||||
"""Not a WARNING: no password can match an unparseable digest, so this is a
|
||||
broken deployment rather than a failed guess. `_accounts()` re-reads this
|
||||
module global on every call, which is what makes it patchable here."""
|
||||
monkeypatch.setattr(auth_router, "AUTH_ADMIN_PASSWORD_HASH", "not-a-hash")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER):
|
||||
assert client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD}
|
||||
).status_code == 401
|
||||
|
||||
assert "reason=malformed-hash" in caplog.text
|
||||
assert any(
|
||||
r.levelno == logging.ERROR and "malformed-hash" in r.getMessage()
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_every_failure_reason_returns_an_identical_response(client, monkeypatch):
|
||||
"""The log distinguishes them; the wire must not. If any of these three
|
||||
responses differed - by status, body, or headers - the endpoint would
|
||||
enumerate valid usernames and report its own misconfiguration to anyone."""
|
||||
unknown = client.post(
|
||||
"/api/auth/login", json={"username": "nobody", "password": "x"}
|
||||
)
|
||||
wrong = client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "not-the-password"}
|
||||
)
|
||||
monkeypatch.setattr(auth_router, "AUTH_ADMIN_PASSWORD_HASH", "not-a-hash")
|
||||
broken = client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD}
|
||||
)
|
||||
|
||||
responses = [unknown, wrong, broken]
|
||||
assert {r.status_code for r in responses} == {401}
|
||||
assert len({r.text for r in responses}) == 1
|
||||
assert all(r.json() == {"detail": "Invalid username or password."} for r in responses)
|
||||
for r in responses:
|
||||
joined = r.text + " ".join(f"{k}:{v}" for k, v in r.headers.items())
|
||||
for leak in ("unknown-username", "bad-password", "malformed-hash", "reason"):
|
||||
assert leak not in joined
|
||||
|
||||
|
||||
def test_the_failure_log_never_carries_the_hash_or_the_password(client, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger=_AUTH_LOGGER):
|
||||
client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "some-guessed-password"},
|
||||
)
|
||||
|
||||
assert "some-guessed-password" not in caplog.text
|
||||
assert TEST_ADMIN_PASSWORD not in caplog.text
|
||||
assert "pbkdf2_sha256$" not in caplog.text
|
||||
|
||||
|
||||
def test_a_malformed_hash_still_costs_a_full_password_check(client, monkeypatch):
|
||||
"""verify_password returns from an unparseable digest without doing any
|
||||
PBKDF2 work - measured at 0.16ms against 439ms for a real one. Left alone,
|
||||
an account with a corrupt hash would answer ~2700x faster than every other
|
||||
username and announce itself to anyone with a stopwatch, inverting the
|
||||
property _DUMMY_HASH exists to provide. So the work must still be paid."""
|
||||
checked = []
|
||||
real_verify = auth_router.verify_password
|
||||
|
||||
def spy(password, encoded):
|
||||
checked.append(encoded)
|
||||
return real_verify(password, encoded)
|
||||
|
||||
monkeypatch.setattr(auth_router, "AUTH_ADMIN_PASSWORD_HASH", "not-a-hash")
|
||||
monkeypatch.setattr(auth_router, "verify_password", spy)
|
||||
|
||||
assert client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": TEST_ADMIN_PASSWORD}
|
||||
).status_code == 401
|
||||
|
||||
assert len(checked) == 1, "exactly one verification per attempt"
|
||||
assert hash_is_wellformed(checked[0]), "the broken hash must not short-circuit it"
|
||||
|
||||
Reference in New Issue
Block a user