Files
ai-rpg/app/api/auth.py

225 lines
7.1 KiB
Python
Raw Normal View History

2026-06-20 19:13:05 +03:00
"""Auth endpoints: register, register/admin, login, refresh, logout, me."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
validate_password_strength,
verify_password,
)
from app.core.settings_service import get_admin_setup_token
from app.db import get_db
from app.models import User
from app.schemas import (
AdminRegisterRequest,
LoginRequest,
RegisterRequest,
TokenResponse,
UserPublic,
)
router = APIRouter(prefix="/api", tags=["auth"])
async def _check_first_admin(db: AsyncSession) -> bool:
"""Return True if at least one admin exists."""
cnt = (
await db.execute(select(func.count(User.id)).where(User.is_admin.is_(True)))
).scalar_one()
return cnt > 0
@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED)
async def register(
body: RegisterRequest,
db: AsyncSession = Depends(get_db),
) -> User:
"""Register a regular user. Only allowed if at least one admin already exists."""
has_admin = await _check_first_admin(db)
if not has_admin:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"no_admin_yet_use_admin_register",
)
existing = (
await db.execute(
select(User).where(
or_(User.email == body.email, User.username == body.username)
)
)
).scalar_one_or_none()
if existing is not None:
if existing.email == body.email:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "email_already_exists")
raise HTTPException(status.HTTP_400_BAD_REQUEST, "username_already_exists")
errors = validate_password_strength(body.password)
if errors:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=errors[0])
user = User(
email=body.email,
username=body.username,
password_hash=hash_password(body.password),
is_admin=False,
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post(
"/register/admin",
response_model=UserPublic,
status_code=status.HTTP_201_CREATED,
)
async def register_admin(
body: AdminRegisterRequest,
db: AsyncSession = Depends(get_db),
) -> User:
"""Register the first admin user. Requires a valid setup token."""
has_admin = await _check_first_admin(db)
if has_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "admin_already_exists")
expected_token = await get_admin_setup_token(db)
if body.token != expected_token:
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid_admin_token")
existing = (
await db.execute(
select(User).where(
or_(User.email == body.email, User.username == body.username)
)
)
).scalar_one_or_none()
if existing is not None:
if existing.email == body.email:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "email_already_exists")
raise HTTPException(status.HTTP_400_BAD_REQUEST, "username_already_exists")
errors = validate_password_strength(body.password)
if errors:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=errors[0])
user = User(
email=body.email,
username=body.username,
password_hash=hash_password(body.password),
is_admin=True,
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post("/auth/login", response_model=TokenResponse)
async def login(
body: LoginRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
"""Login by email OR username. Returns access + refresh JWTs."""
stmt = select(User).where(
or_(User.email == body.login, User.username == body.login)
)
user = (await db.execute(stmt)).scalar_one_or_none()
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid_credentials")
if not verify_password(body.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid_credentials")
if not user.is_active:
raise HTTPException(status.HTTP_403_FORBIDDEN, "account_disabled")
user.last_login_at = datetime.now(timezone.utc)
await db.commit()
access = create_access_token(user.id, extra_claims={"is_admin": user.is_admin})
refresh = create_refresh_token(user.id)
return TokenResponse(
access_token=access,
refresh_token=refresh,
token_type="bearer",
expires_in=60 * 24,
user=UserPublic.model_validate(user),
)
@router.post("/auth/refresh", response_model=TokenResponse)
async def refresh_token(
db: AsyncSession = Depends(get_db),
token: str = ...,
) -> TokenResponse:
"""Exchange a refresh token for a new access + refresh pair.
The token is passed in the request body as `{refresh_token: "..."}`.
"""
raise NotImplementedError("Implemented below via RefreshRequest body")
from pydantic import BaseModel # noqa: E402
class RefreshRequest(BaseModel):
refresh_token: str
@router.post("/auth/refresh", response_model=TokenResponse, name="refresh_real")
async def refresh_real(
body: RefreshRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
try:
payload = decode_token(body.refresh_token)
except Exception as e: # noqa: BLE001
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid refresh token: {e}")
if payload.get("type") != "refresh":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not a refresh token")
user_id = uuid.UUID(payload["sub"])
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if user is None or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or disabled")
access = create_access_token(user.id, extra_claims={"is_admin": user.is_admin})
new_refresh = create_refresh_token(user.id)
return TokenResponse(
access_token=access,
refresh_token=new_refresh,
token_type="bearer",
expires_in=60 * 24,
user=UserPublic.model_validate(user),
)
# Remove the placeholder earlier /auth/refresh route so only the real one stays.
_refresh_routes = [r for r in router.routes if getattr(r, "path", "") == "/api/auth/refresh"]
if len(_refresh_routes) > 1:
router.routes.remove(_refresh_routes[0])
@router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
async def logout() -> Response:
"""Stateless logout — client drops the tokens."""
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/auth/me", response_model=UserPublic)
async def me(current: User = Depends(get_current_user)) -> User:
"""Return the current user's profile."""
return current