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>
207 lines
8.0 KiB
Python
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
|