Files
plm-lottery/app/auth/routes.py
T
davideandClaude Sonnet 5.5 575f1a5354 Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)
- GET /health/ready (Electrum connected, header < 1800s, scheduler heartbeat < 30s) is the
  container healthcheck; the scheduler beats on every tick and draw-wait iteration
- GET /admin/electrum and an Electrum indicator in /admin (server, header age, last rotation)
- GET /admin/audit-log: before_id, event_type, round_id, user_id, from, to; the panel gets filters
- registration writes user_registered in its transaction
- the app's own logs also go to stdout; uvicorn trusts X-Forwarded-For from Caddy's fixed
  address only, so the access log shows the client's IP

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:15:22 +02:00

193 lines
8.6 KiB
Python

from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError, OperationalError
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.audit.log import write_audit_log
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.base import begin_immediate
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:
# B-90 (REG-15): after a restore a new user could be handed the derivation index — the
# address, with the funds — of someone who registered after the backup.
if not getattr(request.app.state.electrum_listener, "registration_open", True):
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"registration_unavailable",
"registration is temporarily unavailable",
)
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):
# B-88: the max(derivation_index) read and the insert that uses it are one
# transaction under the write lock — with a plain BEGIN two concurrent
# registrations both read the same maximum and one of them failed on the
# unique index. A lock that cannot be had is a "try again", not a 500.
try:
await begin_immediate(session)
except OperationalError as exc:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"service_busy",
"the service is busy right now, nothing was sent — please try again",
) from exc
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:
# B-92 (AUD-41, REG-16): in the same transaction as the user, so after a restore the log
# says which derivation indexes were already assigned.
await session.flush()
await write_audit_log(
session,
"user_registered",
{"derivation_index": next_index, "address": address},
user_id=user.id,
)
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):
# B-94 (API-19): bounded before anything is hashed or counted; a megabyte password would
# otherwise be an Argon2 input and a rate-limit key.
username: str = Field(max_length=256)
password: str = Field(max_length=256)
@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
)