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"
|
||||
|
||||
166
tests/test_auth_diagnostics.py
Normal file
166
tests/test_auth_diagnostics.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
The credential-diagnostics surface: hash fingerprints, config provenance, and
|
||||
the `auth` block on /api/health.
|
||||
|
||||
These exist because of a real incident. Production rejected the correct admin
|
||||
password while localhost accepted it, and every observable said the app was
|
||||
healthy: /api/health was 200, CORS passed, the route table was current, and the
|
||||
only log line was `Failed sign-in for 'admin'` - which is what a user with caps
|
||||
lock on produces too. Nothing distinguished "wrong password" from "this image
|
||||
was built from a different .env.production", so there was no way to tell which
|
||||
of them it was without a shell on the box.
|
||||
|
||||
What is pinned here is therefore not a feature so much as the ability to answer
|
||||
one question from outside a container: *is this deployment running the
|
||||
credential I think it is?* The fingerprint is the answer, and these tests hold
|
||||
it to the two properties that make it usable - it identifies a hash, and it
|
||||
discloses nothing about the password behind it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.infrastructure.security import (
|
||||
auth_config_summary,
|
||||
describe_password_hash,
|
||||
hash_is_wellformed,
|
||||
hash_password,
|
||||
password_hash_fingerprint,
|
||||
)
|
||||
from app.infrastructure.settings import config_source
|
||||
from tests.conftest import TEST_ADMIN_PASSWORD
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fingerprint
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_fingerprint_is_stable_for_a_given_hash():
|
||||
"""Comparing prod against local is the whole point, so the same input must
|
||||
give the same answer on both machines and across runs."""
|
||||
encoded = hash_password("whatever", iterations=1000)
|
||||
assert password_hash_fingerprint(encoded) == password_hash_fingerprint(encoded)
|
||||
|
||||
|
||||
def test_fingerprint_differs_when_the_hash_does():
|
||||
"""Including for the same password: two deployments that hashed the same
|
||||
password separately are NOT running the same credential, and a fingerprint
|
||||
that hid that would defeat the comparison."""
|
||||
a = hash_password("same-password", iterations=1000)
|
||||
b = hash_password("same-password", iterations=1000)
|
||||
assert a != b, "salts must differ"
|
||||
assert password_hash_fingerprint(a) != password_hash_fingerprint(b)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrapper", ['"{}"', "'{}'", " {} ", "{}\r", "\n{}\n"])
|
||||
def test_fingerprint_ignores_quotes_and_whitespace(wrapper):
|
||||
"""A hash pasted into a platform's Environment tab arrives wrapped. It is
|
||||
the same credential, so it must fingerprint the same - otherwise the
|
||||
comparison reports a spurious mismatch in exactly the case it exists for."""
|
||||
encoded = hash_password("p", iterations=1000)
|
||||
assert password_hash_fingerprint(wrapper.format(encoded)) == password_hash_fingerprint(
|
||||
encoded
|
||||
)
|
||||
|
||||
|
||||
def test_fingerprint_discloses_no_part_of_the_hash():
|
||||
"""It is served unauthenticated, so it must be a digest OF the credential
|
||||
and not a piece of it."""
|
||||
encoded = hash_password("p", iterations=1000)
|
||||
fp = password_hash_fingerprint(encoded)
|
||||
|
||||
assert len(fp) == 12
|
||||
assert all(c in "0123456789abcdef" for c in fp)
|
||||
assert fp not in encoded
|
||||
# Nor any run of it long enough to be a foothold into salt or digest.
|
||||
for start in range(len(fp) - 5):
|
||||
assert fp[start : start + 6] not in encoded
|
||||
|
||||
|
||||
def test_absent_hash_fingerprints_as_empty():
|
||||
assert password_hash_fingerprint("") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# describe_password_hash / hash_is_wellformed
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
"bad", ["", "not-a-hash", "pbkdf2_sha256$notanint$a$b", "a$b$c$d", "bcrypt$1$a$b"]
|
||||
)
|
||||
def test_a_malformed_hash_is_reported_invalid(bad):
|
||||
"""Same inputs as test_malformed_hash_fails_closed, held against the shared
|
||||
parser - the two must agree on what 'unusable' means, since one decides the
|
||||
login and the other decides what the log calls it."""
|
||||
assert hash_is_wellformed(bad) is False
|
||||
assert describe_password_hash(bad)["valid"] is False
|
||||
|
||||
|
||||
def test_a_real_hash_is_reported_valid_with_its_iteration_count():
|
||||
described = describe_password_hash(hash_password("p", iterations=4321))
|
||||
assert described["valid"] is True
|
||||
assert described["iterations"] == 4321
|
||||
assert described["algorithm"] == "pbkdf2_sha256"
|
||||
|
||||
|
||||
def test_describe_never_returns_the_hash_itself():
|
||||
encoded = hash_password("p", iterations=1000)
|
||||
assert encoded not in str(describe_password_hash(encoded))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config provenance
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_config_source_reports_process_env_for_harness_supplied_values():
|
||||
"""conftest writes the AUTH_* values into os.environ before app.main is
|
||||
imported - which is structurally the same thing a deployment platform's
|
||||
Environment tab does. That this reads back as 'process-env' is the
|
||||
executable proof that an override is detectable at all."""
|
||||
assert config_source("AUTH_ADMIN_PASSWORD_HASH") == "process-env"
|
||||
assert config_source("AUTH_ADMIN_USERNAME") == "process-env"
|
||||
|
||||
|
||||
def test_config_source_reports_default_for_something_never_set():
|
||||
assert config_source("AUTH_NOT_A_REAL_SETTING_XYZ") == "default"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/health
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_health_reports_the_effective_auth_configuration(client):
|
||||
auth = client.get("/api/health").json()["auth"]
|
||||
|
||||
assert auth["enabled"] is True
|
||||
assert auth["allow_any_login"] is False
|
||||
assert auth["admin_username"] == "admin"
|
||||
assert auth["password_hash_valid"] is True
|
||||
assert auth["password_hash_iterations"] == 20_000 # conftest._hash
|
||||
assert auth["password_hash_fingerprint"] == auth_config_summary()[
|
||||
"password_hash_fingerprint"
|
||||
]
|
||||
assert auth["password_hash_source"] == "process-env"
|
||||
|
||||
|
||||
def test_health_never_exposes_a_hash_or_a_password(client):
|
||||
"""The leak canary on an unauthenticated endpoint. A configured digest
|
||||
always contains '$' separators; a password would appear verbatim."""
|
||||
body = client.get("/api/health").text
|
||||
|
||||
assert TEST_ADMIN_PASSWORD not in body
|
||||
assert "pbkdf2_sha256$" not in body
|
||||
assert "$" not in body
|
||||
|
||||
|
||||
def test_health_stays_ok_shaped_when_auth_is_misconfigured(client, monkeypatch):
|
||||
"""An unusable credential must NOT flip `status` to degraded: the container
|
||||
healthcheck and the frontend's connectivity banner both read that field, so
|
||||
doing so would turn a login problem into an outage and a misleading "database
|
||||
unreachable" banner. The signal belongs in auth.password_hash_valid."""
|
||||
from app.api.routers import health as health_router
|
||||
|
||||
monkeypatch.setattr(
|
||||
health_router, "auth_config_summary", lambda: {**auth_config_summary(),
|
||||
"password_hash_valid": False}
|
||||
)
|
||||
body = client.get("/api/health").json()
|
||||
|
||||
assert body["auth"]["password_hash_valid"] is False
|
||||
assert body["status"] in {"ok", "degraded"} # decided by db/ollama only
|
||||
Reference in New Issue
Block a user