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
- 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>
375 lines
10 KiB
Python
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,
|
|
)
|