Files
plm-lottery/app/auth/routes.py
T
davideandClaude Sonnet 5 739fc9fed2 Invalidate existing sessions on password change/reset (B-34)
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>
2026-07-27 12:02:23 +02:00

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
)