"""Authentication API endpoints.""" import logging from datetime import datetime from typing import Annotated, Optional from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from spoolman import auth from spoolman.auth import ( RequireAdmin, Role, Token, UserResponse, authenticate_user, create_access_token, get_current_user, get_password_hash, get_user_by_username, is_auth_enabled, ) from spoolman.database import models from spoolman.database.database import get_db_session logger = logging.getLogger(__name__) router = APIRouter( prefix="/auth", tags=["auth"], ) class AuthStatusResponse(BaseModel): """Authentication status response.""" enabled: bool has_users: bool class UserCreateRequest(BaseModel): """Request to create a new user.""" username: str = Field(..., min_length=3, max_length=64) password: str = Field(..., min_length=6) role: Role = Role.VIEWER class UserUpdateRequest(BaseModel): """Request to update a user.""" password: Optional[str] = Field(None, min_length=6) role: Optional[Role] = None is_active: Optional[bool] = None class SetupRequest(BaseModel): """Initial setup request to create admin user.""" username: str = Field(..., min_length=3, max_length=64) password: str = Field(..., min_length=6) @router.get( "/status", name="Get auth status", description="Check if authentication is enabled and if any users exist.", response_model=AuthStatusResponse, ) async def get_auth_status( db: Annotated[AsyncSession, Depends(get_db_session)], ) -> AuthStatusResponse: """Get authentication status.""" result = await db.execute(select(func.count()).select_from(models.User)) user_count = result.scalar() or 0 return AuthStatusResponse( enabled=is_auth_enabled(), has_users=user_count > 0, ) @router.post( "/setup", name="Initial setup", description="Create the first admin user. Only works if no users exist.", response_model=UserResponse, ) async def setup( body: SetupRequest, db: Annotated[AsyncSession, Depends(get_db_session)], ) -> UserResponse: """Create the first admin user during initial setup.""" # Check if any users exist result = await db.execute(select(func.count()).select_from(models.User)) user_count = result.scalar() or 0 if user_count > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Setup already completed. Users already exist.", ) # Create admin user user = models.User( username=body.username, hashed_password=get_password_hash(body.password), role=Role.ADMIN.value, is_active=True, created_at=datetime.utcnow(), ) db.add(user) await db.commit() await db.refresh(user) logger.info("Initial admin user created: %s", body.username) return UserResponse.from_db(user) @router.post( "/login", name="Login", description="Authenticate and get an access token.", response_model=Token, ) async def login( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[AsyncSession, Depends(get_db_session)], ) -> Token: """Authenticate user and return JWT token.""" if not is_auth_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is not enabled", ) user = await authenticate_user(db, form_data.username, form_data.password) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) access_token = create_access_token( data={"sub": user.username, "role": user.role} ) return Token(access_token=access_token) @router.get( "/me", name="Get current user", description="Get the currently authenticated user's info.", response_model=UserResponse, ) async def get_me( current_user: Annotated[Optional[models.User], Depends(get_current_user)], ) -> UserResponse: """Get current user info.""" if current_user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", ) return UserResponse.from_db(current_user) # User management endpoints (admin only) @router.get( "/users", name="List users", description="Get all users (admin only).", response_model=list[UserResponse], dependencies=[RequireAdmin], ) async def list_users( db: Annotated[AsyncSession, Depends(get_db_session)], ) -> list[UserResponse]: """List all users.""" result = await db.execute(select(models.User).order_by(models.User.username)) users = result.scalars().all() return [UserResponse.from_db(u) for u in users] @router.post( "/users", name="Create user", description="Create a new user (admin only).", response_model=UserResponse, dependencies=[RequireAdmin], ) async def create_user( body: UserCreateRequest, db: Annotated[AsyncSession, Depends(get_db_session)], ) -> UserResponse: """Create a new user.""" # Check if username exists existing = await get_user_by_username(db, body.username) if existing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Username already exists", ) user = models.User( username=body.username, hashed_password=get_password_hash(body.password), role=body.role.value, is_active=True, created_at=datetime.utcnow(), ) db.add(user) await db.commit() await db.refresh(user) logger.info("User created: %s (role: %s)", body.username, body.role.value) return UserResponse.from_db(user) @router.patch( "/users/{user_id}", name="Update user", description="Update a user (admin only).", response_model=UserResponse, dependencies=[RequireAdmin], ) async def update_user( user_id: int, body: UserUpdateRequest, db: Annotated[AsyncSession, Depends(get_db_session)], ) -> UserResponse: """Update a user.""" user = await db.get(models.User, user_id) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", ) if body.password is not None: user.hashed_password = get_password_hash(body.password) if body.role is not None: user.role = body.role.value if body.is_active is not None: user.is_active = body.is_active await db.commit() await db.refresh(user) logger.info("User updated: %s", user.username) return UserResponse.from_db(user) @router.delete( "/users/{user_id}", name="Delete user", description="Delete a user (admin only).", dependencies=[RequireAdmin], ) async def delete_user( user_id: int, current_user: Annotated[Optional[models.User], Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db_session)], ) -> dict: """Delete a user.""" user = await db.get(models.User, user_id) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", ) # Prevent self-deletion if current_user and user.id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete your own account", ) await db.delete(user) await db.commit() logger.info("User deleted: %s", user.username) return {"message": "User deleted"}