Files
spoolman2/spoolman/api/v1/auth.py
tonym f63f70ce86
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Issue Manager / issue-manager (push) Has been cancelled
Make auth toggle a settings option instead of env var
- Auth enabled state now stored in database setting (auth_enabled)
- Add POST /auth/toggle endpoint to enable/disable auth from UI
- Add auth toggle section to Settings > General page
- Enabling auth without users prompts to create first admin
- Disabling auth requires admin password confirmation
- Auth setting cached in memory and loaded on startup

Removes need for SPOOLMAN_AUTH_ENABLED env var

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 01:01:14 -06:00

375 lines
10 KiB
Python

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