fee_address was the dangerous one (B-05). PUT /admin/config assigned whatever it
was given, and a well-formed address from another chain (bc1...) parses fine as a
witness program — so every round's 30% commission would be signed and broadcast
to a script nobody holds the key for. A malformed one instead wedged the payout
with an unhandled EmbitError. It now has to pass is_valid_plm_address, the same
check user withdrawals already had. Numeric fields got bounds too:
fee_rate_sat_vb=0 produces transactions no node relays, which stalls bets,
payouts and withdrawals alike, and round_duration_seconds=0 expires a round the
instant it opens.
Config changes are audit-logged (B-10). /pause and /resume were logged but a
config edit wasn't, so the most sensitive setting in the system could be changed
without leaving any trace — contradicting CLAUDE.md, which says audit_log records
what changed. The entry carries a before/after diff per field, computed before
assignment, and no-op updates write nothing. `paused` was removed from
_CONFIG_FIELDS so the maintenance switch has exactly one audited path; it stays
in the response model.
Admin token comparison is constant-time (B-14), with the empty-token check kept
*ahead* of it: compare_digest("", "") returns True, so the obvious ordering would
have opened the panel on any instance without an ADMIN_TOKEN.
Registration input (B-12). It accepted an empty username and a one-character
password while /users/me/change-password demanded 8 — an odd place to be lenient
on a custodial system holding real funds. MIN_PASSWORD_LENGTH moved to
auth/security.py so both share it, and the username is constrained to 3-32 chars
of [A-Za-z0-9_.-]. The IntegrityError handler also distinguishes a username
collision (answers username_taken) from a derivation-index one (retries): a
concurrent duplicate username used to be retried five times and then reported as
derivation_index_conflict, which told the user the wrong thing.
verify_password (B-13) catches VerificationError and InvalidHashError, not just
VerifyMismatchError, so an unparseable stored hash reads as "wrong password"
instead of a 500 — logged as an error, since that one is a data problem.
guida-admin.md gains a table of the audit events worth watching, including
payout_failed, which needs manual intervention.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89 lines
3.6 KiB
Python
89 lines
3.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
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.errors import http_error
|
|
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
|
|
|
|
|
|
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:
|
|
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), 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, session: AsyncSession = Depends(get_session)) -> TokenResponse:
|
|
user = await session.scalar(select(User).where(User.username == body.username))
|
|
if user is None or not verify_password(body.password, user.password_hash):
|
|
raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials")
|
|
return TokenResponse(access_token=create_access_token(user.id), address=user.address)
|