Files
plm-lottery/app/auth/routes.py
T

89 lines
3.6 KiB
Python
Raw Normal View History

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)