Neither self-service password change nor the admin reset invalidated already-issued JWTs — a 24h-lifetime token stayed valid regardless, so a stolen token (or an attacker who already had the old password) kept working past a password change meant to lock them out. The admin reset exists precisely for the "account compromised" case and didn't evict the attacker at all. Add User.token_version (migration 943dbd74d983), embedded in every JWT as a "tv" claim and checked against the DB on every request in get_current_user/get_optional_user; a mismatch reads as session_expired. Both change-password and the admin reset bump it. change-password hands back a freshly minted token so the caller's own session keeps working instead of being logged out by its own request; the admin reset does not, since that session isn't the one making the call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
156 lines
6.7 KiB
Python
156 lines
6.7 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.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 _client_ip(request: Request) -> str:
|
|
# Caddy (see Caddyfile) reverse-proxies every request, so request.client.host
|
|
# is the proxy's address, not the caller's — fall back to it only if the
|
|
# header is somehow missing (e.g. hitting the app container directly).
|
|
forwarded = request.headers.get("x-forwarded-for")
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip()
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
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
|
|
)
|