Only successful logins wrote an audit row. Every unsuccessful attempt now writes `login_failed` with reason bad_credentials, unknown_user, inactive, or throttled, plus the client IP. The attempted username is recorded only when it matches a real account (as the actor); unknown-user attempts store no username, since people sometimes type a password into that field. Found while preparing Phase 0 control evidence (milestone 0.9). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
9.3 KiB
Python
301 lines
9.3 KiB
Python
"""End-to-end auth flow via the FastAPI TestClient.
|
|
|
|
Uses the shared SQLite in-memory DB by wiring the `get_db` dependency to the
|
|
per-test session, then seeding a user and driving login/refresh/me/
|
|
change-password.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ypms.auth.rate_limit import login_limiter
|
|
from ypms.auth.security import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
hash_password,
|
|
)
|
|
from ypms.db import get_db
|
|
from ypms.main import create_app
|
|
from ypms.security.rbac import ADMIN_ROLE
|
|
from ypms.users.models import (
|
|
ASSURANCE_ADMIN,
|
|
ASSURANCE_STANDARD,
|
|
Role,
|
|
User,
|
|
UserRole,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_rate_limits() -> Iterator[None]:
|
|
login_limiter.reset()
|
|
yield
|
|
login_limiter.reset()
|
|
|
|
|
|
@pytest.fixture
|
|
def staff_user(db: Session) -> User:
|
|
user = User(
|
|
username="staff.user",
|
|
email="staff@dc.gov",
|
|
full_name="Staff User",
|
|
password_hash=hash_password("correct-horse-battery"),
|
|
is_active=True,
|
|
assurance_level=ASSURANCE_STANDARD,
|
|
)
|
|
db.add(user)
|
|
db.flush()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def admin_user(db: Session) -> User:
|
|
role = Role(code=ADMIN_ROLE, description="super-role")
|
|
db.add(role)
|
|
db.flush()
|
|
user = User(
|
|
username="chris",
|
|
email="chris@dc.gov",
|
|
full_name="Chris Tonjes",
|
|
password_hash=hash_password("correct-horse-battery"),
|
|
is_active=True,
|
|
assurance_level=ASSURANCE_STANDARD, # role bump upgrades to admin
|
|
)
|
|
db.add(user)
|
|
db.flush()
|
|
db.add(UserRole(user_id=user.id, role_id=role.id))
|
|
db.flush()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def app_client(db: Session) -> Iterator[TestClient]:
|
|
"""A TestClient wired to the per-test SQLite session via dep override."""
|
|
app = create_app()
|
|
|
|
def _override_get_db() -> Iterator[Session]:
|
|
yield db
|
|
|
|
app.dependency_overrides[get_db] = _override_get_db
|
|
with TestClient(app) as c:
|
|
yield c
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_login_wrong_password_returns_401(app_client: TestClient, staff_user: User) -> None:
|
|
r = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "wrong"},
|
|
)
|
|
assert r.status_code == 401
|
|
assert r.json()["detail"] == "invalid credentials"
|
|
|
|
|
|
def test_login_success_returns_tokens(app_client: TestClient, staff_user: User) -> None:
|
|
r = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["token_type"] == "bearer"
|
|
assert body["access_token"]
|
|
assert body["refresh_token"]
|
|
|
|
|
|
def test_admin_login_upgrades_assurance_level(
|
|
app_client: TestClient, admin_user: User
|
|
) -> None:
|
|
from ypms.auth.security import decode_token
|
|
|
|
r = app_client.post(
|
|
"/auth/login",
|
|
data={"username": admin_user.username, "password": "correct-horse-battery"},
|
|
)
|
|
assert r.status_code == 200
|
|
claims = decode_token(r.json()["access_token"])
|
|
assert claims["iam.assurance_level"] == ASSURANCE_ADMIN
|
|
assert ADMIN_ROLE in claims["roles"]
|
|
|
|
|
|
def test_me_requires_authentication(app_client: TestClient) -> None:
|
|
r = app_client.get("/auth/me")
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_me_returns_current_user(app_client: TestClient, staff_user: User) -> None:
|
|
login = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
|
)
|
|
access = login.json()["access_token"]
|
|
r = app_client.get("/auth/me", headers={"Authorization": f"Bearer {access}"})
|
|
assert r.status_code == 200
|
|
assert r.json()["username"] == staff_user.username
|
|
assert r.json()["email"] == "staff@dc.gov"
|
|
|
|
|
|
def test_refresh_returns_new_access_token(app_client: TestClient, staff_user: User) -> None:
|
|
login = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
|
)
|
|
refresh = login.json()["refresh_token"]
|
|
r = app_client.post("/auth/refresh", json={"refresh_token": refresh})
|
|
assert r.status_code == 200
|
|
assert r.json()["access_token"]
|
|
assert "refresh_token" not in r.json()
|
|
|
|
|
|
def test_refresh_rejects_access_token(app_client: TestClient, staff_user: User) -> None:
|
|
access = create_access_token(
|
|
sub=staff_user.username,
|
|
uid=staff_user.id,
|
|
roles=[],
|
|
assurance_level=ASSURANCE_STANDARD,
|
|
mfa_verified=False,
|
|
)
|
|
r = app_client.post("/auth/refresh", json={"refresh_token": access})
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_me_rejects_refresh_token(app_client: TestClient, staff_user: User) -> None:
|
|
refresh = create_refresh_token(sub=staff_user.username, uid=staff_user.id)
|
|
r = app_client.get("/auth/me", headers={"Authorization": f"Bearer {refresh}"})
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_change_password_flow(app_client: TestClient, staff_user: User) -> None:
|
|
login = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
|
)
|
|
access = login.json()["access_token"]
|
|
r = app_client.post(
|
|
"/auth/change-password",
|
|
headers={"Authorization": f"Bearer {access}"},
|
|
json={
|
|
"current_password": "correct-horse-battery",
|
|
"new_password": "totally-different-passphrase",
|
|
},
|
|
)
|
|
assert r.status_code == 204
|
|
|
|
# Old password no longer works.
|
|
r2 = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
|
)
|
|
assert r2.status_code == 401
|
|
|
|
# New password does.
|
|
r3 = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "totally-different-passphrase"},
|
|
)
|
|
assert r3.status_code == 200
|
|
|
|
|
|
def test_change_password_rejects_short_new_password(
|
|
app_client: TestClient, staff_user: User
|
|
) -> None:
|
|
login = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
|
)
|
|
access = login.json()["access_token"]
|
|
r = app_client.post(
|
|
"/auth/change-password",
|
|
headers={"Authorization": f"Bearer {access}"},
|
|
json={"current_password": "correct-horse-battery", "new_password": "short"},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_change_password_rejects_wrong_current(
|
|
app_client: TestClient, staff_user: User
|
|
) -> None:
|
|
login = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
|
)
|
|
access = login.json()["access_token"]
|
|
r = app_client.post(
|
|
"/auth/change-password",
|
|
headers={"Authorization": f"Bearer {access}"},
|
|
json={"current_password": "wrong", "new_password": "long-enough-password"},
|
|
)
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_login_lockout_after_five_failed_attempts(
|
|
app_client: TestClient, staff_user: User
|
|
) -> None:
|
|
for _ in range(5):
|
|
r = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "wrong"},
|
|
)
|
|
assert r.status_code == 401
|
|
# 6th attempt (any credentials) is rate-limited.
|
|
r = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
|
)
|
|
assert r.status_code == 429
|
|
assert r.headers["Retry-After"] == "900"
|
|
|
|
|
|
# ---------------- failed-login audit (NIST AU-2, C.5.7.39) ----------------
|
|
|
|
|
|
def _failures(db: Session) -> list[tuple[str | None, int | None, dict[str, str] | None]]:
|
|
from sqlalchemy import select
|
|
|
|
from ypms.audit.models import AuditLog
|
|
|
|
rows = db.execute(select(AuditLog).where(AuditLog.action == "login_failed")).scalars()
|
|
return [(r.actor_username, r.entity_id, r.after) for r in rows]
|
|
|
|
|
|
def test_bad_password_is_audited_against_the_account(
|
|
app_client: TestClient, db: Session, staff_user: User
|
|
) -> None:
|
|
app_client.post("/auth/login", data={"username": staff_user.username, "password": "nope"})
|
|
assert _failures(db) == [(staff_user.username, staff_user.id, {"reason": "bad_credentials"})]
|
|
|
|
|
|
def test_unknown_user_is_audited_without_the_typed_username(
|
|
app_client: TestClient, db: Session
|
|
) -> None:
|
|
app_client.post("/auth/login", data={"username": "my-actual-password!", "password": "x"})
|
|
assert _failures(db) == [(None, None, {"reason": "unknown_user"})]
|
|
|
|
|
|
def test_inactive_user_login_is_audited(
|
|
app_client: TestClient, db: Session, staff_user: User
|
|
) -> None:
|
|
staff_user.is_active = False
|
|
db.flush()
|
|
r = app_client.post(
|
|
"/auth/login",
|
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
|
)
|
|
assert r.status_code == 401
|
|
assert _failures(db) == [(staff_user.username, staff_user.id, {"reason": "inactive"})]
|
|
|
|
|
|
def test_throttled_login_is_audited(app_client: TestClient, db: Session) -> None:
|
|
for _ in range(6):
|
|
r = app_client.post("/auth/login", data={"username": "ghost", "password": "x"})
|
|
assert r.status_code == 429
|
|
reasons = [after["reason"] for _, _, after in _failures(db) if after]
|
|
assert reasons == ["unknown_user"] * 5 + ["throttled"]
|