GET /rounds/stream capped concurrent subscribers with one global counter (MAX_SUBSCRIBERS=500): anyone opening 500 connections degraded every other user to polling. The comment called it a defensive cap; it was actually the vector, since nothing stopped a single source from exhausting it alone. RoundEventBroadcaster now also tracks subscribers per client IP, capped much lower (MAX_SUBSCRIBERS_PER_IP=5). Past that cap, opening one more stream evicts that same IP's own oldest connection (woken via a new EVICTED sentinel so the SSE generator closes it promptly) rather than refusing the new one or letting one abusive IP crowd out unrelated clients under the old global-only cap. The global cap stays as a backstop against overall resource exhaustion regardless of source. Extracted client_ip() (X-Forwarded-For, since Caddy reverse-proxies every request) out of auth/routes.py into app/api/client_ip.py so the login/registration throttles (B-33) and this new per-IP cap share one definition instead of two that could drift apart. Not implemented: enforcing the connection cap at the Caddy layer itself, which the proposed fix also suggested - the standard Caddy image this project uses has no such directive without a third-party module, and building a custom image felt like a bigger, separate change than this fix warranted. Suite grows from 201 to 211 tests. BUGS.md moves B-38 to Previously fixed.
147 lines
6.3 KiB
Python
147 lines
6.3 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, verify_password
|
|
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,
|
|
# coarser limiter, IP-only — no username exists yet to key on — mainly to
|
|
# bound how many accounts one IP can spin up (B-31), not to protect a
|
|
# secret. 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)}"
|
|
retry_after = limiters.register_ip.retry_after(ip_key)
|
|
if retry_after > 0:
|
|
raise _rate_limited_error(retry_after)
|
|
limiters.register_ip.record_failure(ip_key)
|
|
|
|
existing = await session.scalar(select(User).where(User.username == body.username))
|
|
if existing is not None:
|
|
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
|
|
|
|
password_hash = hash_password(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)
|
|
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(User.username == body.username))
|
|
if user is None or not verify_password(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
|
|
)
|