- 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>
193 lines
8.6 KiB
Python
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
|
|
)
|