"""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