Files
plm-lottery/tests/unit/test_auth.py
T
davideandClaude Opus 5 0aac73e557 Bound registrations with a per-IP quota instead of failure backoff (B-58)
The registration throttle called record_failure on every attempt, successful
ones included. Five legitimate signups from one shared or NAT address locked the
sixth real user out for up to 600s, doubling from there — while an attacker
sidestepped the limiter entirely through B-54. Failure backoff is the wrong
instrument here: nothing about creating an account is a failed guess at a
secret, so the only people it reliably punished were the honest ones.

RollingQuota says what was actually meant: 5 accounts per IP per hour, in a
rolling window. The caller over it waits exactly until the oldest of the five
ages out — an accurate Retry-After, and waiting never makes the next wait
longer. It is recorded only once an account exists, so attempts that create
nothing (a taken username, a validation error) leave the quota untouched, and
checked before the Argon2 hash, so an IP out of quota costs nothing to refuse.

Bounded like the failure limiter (B-56): the keys are caller-chosen, so the dict
gets both a sweep and a hard cap, evicting keys with room left in their quota
before full ones.

Also fixes the inline comment that cited B-31 (the resubscribe finding) where it
meant B-33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:32:20 +02:00

207 lines
8.0 KiB
Python

import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
import app.wallet.hd as hd
hd._account_key = None
hd.generate_master_key()
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import base as db_base
import app.db.models # noqa: F401
db_base.engine = create_async_engine(settings.database_url)
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
from app.db import session as db_session
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from fastapi import FastAPI
from app.auth.routes import router as auth_router
from app.electrum.listener import ElectrumListener
app = FastAPI()
app.include_router(auth_router)
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
async def _register(client, username="alice", password="original-password"):
resp = await client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 201
return resp.json()["access_token"]
async def test_login_locks_out_after_repeated_failures(client):
await _register(client)
for _ in range(5):
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "rate_limited"
# Even the *correct* password is refused while locked out — the throttle
# protects against a lucky guess landing inside the backoff window too.
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 429
async def test_unknown_username_and_wrong_password_share_a_bucket_and_response(client):
await _register(client, username="bob")
for _ in range(5):
resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"})
assert resp.status_code == 401
assert resp.json()["detail"]["code"] == "invalid_credentials"
resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"})
assert resp.status_code == 429
async def test_login_failures_against_one_account_do_not_lock_out_another(client):
await _register(client, username="alice")
await _register(client, username="carol", password="carols-password")
for _ in range(6):
await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
# Different username, but same IP (the test client always looks the same) —
# only the per-username bucket should be exhausted, not the whole IP, since
# the per-username threshold (5) is hit well before the shared IP bucket's.
resp = await client.post("/auth/login", json={"username": "carol", "password": "carols-password"})
assert resp.status_code == 200
async def test_successful_login_resets_the_username_bucket(client):
await _register(client)
for _ in range(4):
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 200
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 200
async def test_registration_is_quota_limited_per_ip(client): # B-58
"""Five accounts per IP per hour. The sixth is told to wait, not punished with a
backoff that doubles from there."""
for i in range(5):
resp = await client.post(
"/auth/register", json={"username": f"user{i}", "password": "a-strong-password"}
)
assert resp.status_code == 201
resp = await client.post(
"/auth/register", json={"username": "user5", "password": "a-strong-password"}
)
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "rate_limited"
# --- B-57: usernames are one namespace, case included ---------------------------
async def test_registration_refuses_a_username_differing_only_by_case(client):
""""Bob" and "bob" used to be two accounts. On a custodial system that's an
impersonation vector — and the two also shared a single rate-limit bucket, since
the throttle key has always been lowercased, so each could lock the other out."""
await _register(client, username="Bob")
resp = await client.post("/auth/register", json={"username": "bob", "password": "another-password"})
assert resp.status_code == 409
assert resp.json()["detail"]["code"] == "username_taken"
async def test_login_accepts_the_username_in_any_case(client):
"""The flip side of the same rule: one account, reachable however it's typed."""
await _register(client, username="Alice", password="original-password")
resp = await client.post("/auth/login", json={"username": "ALICE", "password": "original-password"})
assert resp.status_code == 200
assert resp.json()["access_token"]
async def test_the_database_itself_rejects_a_case_variant(client):
"""Not just the pre-check in the handler: two requests racing between the SELECT
and the INSERT must still leave only one account, which is what the unique index
on lower(username) guarantees."""
from sqlalchemy.exc import IntegrityError
from app.db.base import AsyncSessionLocal
from app.db.models import User
await _register(client, username="Carol")
async with AsyncSessionLocal() as session:
session.add(
User(username="CAROL", password_hash="x", derivation_index=999, address="plm1-unused")
)
with pytest.raises(IntegrityError):
await session.commit()
async def test_failed_registrations_do_not_consume_the_quota(client): # B-58
"""The limit is on accounts that exist, not on requests: record_failure used to
fire on every attempt, so five signups — successful ones included — locked the
sixth real user out for up to 600s from a shared or NAT address. Attempts that
create nothing must leave the quota untouched."""
await _register(client, username="taken")
for _ in range(10):
resp = await client.post(
"/auth/register", json={"username": "taken", "password": "a-strong-password"}
)
assert resp.status_code == 409 # username_taken, no account created
# Four slots left out of five, all still usable.
for i in range(4):
resp = await client.post(
"/auth/register", json={"username": f"genuine{i}", "password": "a-strong-password"}
)
assert resp.status_code == 201
async def test_the_quota_reports_how_long_to_wait(client): # B-58
for i in range(5):
await _register(client, username=f"quotauser{i}", password="a-strong-password")
resp = await client.post(
"/auth/register", json={"username": "one-too-many", "password": "a-strong-password"}
)
assert resp.status_code == 429
retry_after = resp.json()["detail"]["params"]["retry_after_seconds"]
assert 0 < retry_after <= 3601 # bounded by the window, not by a growing penalty