"""Authentication API endpoints.""" import json 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, set_auth_enabled_cache, ) from spoolman.database import models from spoolman.database.database import get_db_session from spoolman.settings import SETTINGS 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"} class AuthToggleRequest(BaseModel): """Request to enable or disable authentication.""" enabled: bool password: Optional[str] = Field(None, description="Admin password required when disabling auth") @router.post( "/toggle", name="Toggle authentication", description="Enable or disable authentication. Requires admin password to disable.", response_model=AuthStatusResponse, ) async def toggle_auth( body: AuthToggleRequest, db: Annotated[AsyncSession, Depends(get_db_session)], ) -> AuthStatusResponse: """Enable or disable authentication.""" # Get current user count result = await db.execute(select(func.count()).select_from(models.User)) user_count = result.scalar() or 0 if body.enabled: # Enabling auth if user_count == 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot enable auth without users. Use /auth/setup to create the first admin user.", ) else: # Disabling auth - require admin password for security if not body.password: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Admin password required to disable authentication", ) # Find any admin user and verify password result = await db.execute( select(models.User).where(models.User.role == Role.ADMIN.value, models.User.is_active == True) # noqa: E712 ) admin_users = result.scalars().all() if not admin_users: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No active admin user found", ) # Check if password matches any admin password_valid = False for admin in admin_users: if auth.verify_password(body.password, admin.hashed_password): password_valid = True break if not password_valid: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin password", ) # Update the setting in database setting = models.Setting( key="auth_enabled", value=json.dumps(body.enabled), last_updated=datetime.utcnow().replace(microsecond=0), ) await db.merge(setting) await db.commit() # Update the cache set_auth_enabled_cache(body.enabled) logger.info("Authentication %s", "enabled" if body.enabled else "disabled") return AuthStatusResponse( enabled=body.enabled, has_users=user_count > 0, )