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>
160 lines
6.8 KiB
Python
160 lines
6.8 KiB
Python
from fastapi import APIRouter, Depends, Request, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.client_ip import client_ip as _client_ip
|
|
from app.api.errors import http_error
|
|
from app.auth.rate_limit import AuthRateLimiters
|
|
from app.auth.security import (
|
|
MIN_PASSWORD_LENGTH,
|
|
create_access_token,
|
|
hash_password_async,
|
|
verify_password_async,
|
|
)
|
|
from app.db.models import User
|
|
from app.db.session import get_session
|
|
from app.wallet.hd import derive_user_address
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
_MAX_REGISTER_RETRIES = 5
|
|
|
|
|
|
def _rate_limiters(request: Request) -> AuthRateLimiters:
|
|
# B-33: no rate limiting on login was a brute-forceable path to withdrawing
|
|
# someone else's funds. Keyed per-username *and* per-IP so an attacker can't
|
|
# dodge the throttle by spraying one password across many accounts, nor by
|
|
# routing one account's guesses through many IPs alone (the username key
|
|
# still catches that). The IP limiter's threshold is deliberately higher
|
|
# than the username one: a single account should lock out fast, but a
|
|
# shared/NAT IP hosting several genuine users shouldn't be punished for one
|
|
# of them mistyping a password a few times. Registration gets its own
|
|
# instrument entirely: a per-IP *quota* on accounts created (B-58), since
|
|
# bounding how many accounts one source can spin up is not the same problem
|
|
# as slowing down guesses at a secret, and failure backoff only punished the
|
|
# honest signups. Lives on app.state (see AuthRateLimiters) rather than a module
|
|
# global so each app instance gets its own, isolated throttle state.
|
|
if not hasattr(request.app.state, "auth_rate_limiters"):
|
|
request.app.state.auth_rate_limiters = AuthRateLimiters()
|
|
return request.app.state.auth_rate_limiters
|
|
|
|
|
|
def _rate_limited_error(retry_after: float):
|
|
return http_error(
|
|
status.HTTP_429_TOO_MANY_REQUESTS,
|
|
"rate_limited",
|
|
"too many attempts, try again later",
|
|
retry_after_seconds=int(retry_after) + 1,
|
|
)
|
|
|
|
|
|
class RegisterRequest(BaseModel):
|
|
"""Registration used to accept an empty username and a one-character password,
|
|
while /users/me/change-password demanded 8 characters — an odd place to be
|
|
lenient on a custodial system holding real funds (B-12). MIN_PASSWORD_LENGTH is
|
|
shared with that endpoint so the two can't drift apart again."""
|
|
|
|
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_.-]+$")
|
|
password: str = Field(min_length=MIN_PASSWORD_LENGTH, max_length=256)
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
address: str
|
|
|
|
|
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
|
async def register(
|
|
body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session)
|
|
) -> TokenResponse:
|
|
limiters = _rate_limiters(request)
|
|
ip_key = f"ip:{_client_ip(request)}"
|
|
# B-58: checked before the Argon2 hash below, so an IP that's out of quota
|
|
# costs nothing to turn away. Recorded only once an account actually exists —
|
|
# see the successful path below.
|
|
retry_after = limiters.register_ip.retry_after(ip_key)
|
|
if retry_after > 0:
|
|
raise _rate_limited_error(retry_after)
|
|
|
|
# B-57: case-insensitive, matching the unique index on lower(username) — and
|
|
# matching the throttle key below, which has always been lowercased.
|
|
existing = await session.scalar(
|
|
select(User).where(func.lower(User.username) == body.username.lower())
|
|
)
|
|
if existing is not None:
|
|
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
|
|
|
|
password_hash = await hash_password_async(body.password)
|
|
|
|
for _ in range(_MAX_REGISTER_RETRIES):
|
|
max_index = await session.scalar(select(func.max(User.derivation_index)))
|
|
next_index = 0 if max_index is None else max_index + 1
|
|
address = derive_user_address(next_index)
|
|
user = User(
|
|
username=body.username,
|
|
password_hash=password_hash,
|
|
derivation_index=next_index,
|
|
address=address,
|
|
)
|
|
session.add(user)
|
|
try:
|
|
await session.commit()
|
|
except IntegrityError as exc:
|
|
await session.rollback()
|
|
# Only a derivation-index collision is worth retrying. A username
|
|
# collision (someone registered the same name between the check above and
|
|
# this commit) is permanent, and retrying it five times only to report
|
|
# "derivation_index_conflict" told the user the wrong thing entirely (B-12).
|
|
if "username" in str(exc.orig).lower():
|
|
raise http_error(
|
|
status.HTTP_409_CONFLICT, "username_taken", "username already taken"
|
|
) from exc
|
|
continue
|
|
await session.refresh(user)
|
|
limiters.register_ip.record(ip_key) # B-58: one account created, one slot used
|
|
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, user.token_version), address=user.address
|
|
)
|
|
|
|
raise http_error(
|
|
status.HTTP_409_CONFLICT,
|
|
"derivation_index_conflict",
|
|
"could not allocate a derivation index, retry",
|
|
)
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
async def login(
|
|
body: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)
|
|
) -> TokenResponse:
|
|
limiters = _rate_limiters(request)
|
|
username_key = f"user:{body.username.lower()}"
|
|
ip_key = f"ip:{_client_ip(request)}"
|
|
retry_after = max(limiters.login.retry_after(username_key), limiters.login_ip.retry_after(ip_key))
|
|
if retry_after > 0:
|
|
raise _rate_limited_error(retry_after)
|
|
|
|
user = await session.scalar(select(User).where(func.lower(User.username) == body.username.lower()))
|
|
if user is None or not await verify_password_async(body.password, user.password_hash):
|
|
# Same code path (and therefore the same response) whether the username
|
|
# doesn't exist or the password is wrong — no enumeration oracle here.
|
|
limiters.login.record_failure(username_key)
|
|
limiters.login_ip.record_failure(ip_key)
|
|
raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials")
|
|
|
|
# Only the username bucket resets on success — the IP bucket is left to decay
|
|
# on its own, so one correct login can't be used to wipe out an IP's failure
|
|
# count while it's mid-attack against other accounts.
|
|
limiters.login.record_success(username_key)
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, user.token_version), address=user.address
|
|
)
|