Make auth toggle a settings option instead of env var
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
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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""Authentication API endpoints."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
@@ -22,9 +23,11 @@ from spoolman.auth import (
|
||||
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__)
|
||||
|
||||
@@ -287,3 +290,85 @@ async def delete_user(
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Authentication utilities and dependencies."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
@@ -31,10 +32,35 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
# OAuth2 scheme - auto_error=False allows unauthenticated access when auth is disabled
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login", auto_error=False)
|
||||
|
||||
# Cached auth enabled state (loaded from database on startup)
|
||||
_auth_enabled_cache: bool = False
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Check if authentication is enabled via environment variable."""
|
||||
return os.environ.get("SPOOLMAN_AUTH_ENABLED", "false").lower() in ("true", "1", "yes")
|
||||
"""Check if authentication is enabled (from cached database setting)."""
|
||||
return _auth_enabled_cache
|
||||
|
||||
|
||||
def set_auth_enabled_cache(enabled: bool) -> None:
|
||||
"""Update the cached auth enabled state."""
|
||||
global _auth_enabled_cache
|
||||
_auth_enabled_cache = enabled
|
||||
logger.info("Auth enabled cache updated: %s", enabled)
|
||||
|
||||
|
||||
async def load_auth_enabled_from_db(db: AsyncSession) -> bool:
|
||||
"""Load auth_enabled setting from database and update cache."""
|
||||
from spoolman.settings import SETTINGS
|
||||
|
||||
setting = await db.get(models.Setting, "auth_enabled")
|
||||
if setting is not None:
|
||||
enabled = json.loads(setting.value)
|
||||
else:
|
||||
# Use default from settings definition
|
||||
enabled = json.loads(SETTINGS["auth_enabled"].default)
|
||||
|
||||
set_auth_enabled_cache(enabled)
|
||||
return enabled
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
|
||||
@@ -13,7 +13,7 @@ from fastapi.responses import PlainTextResponse, RedirectResponse, Response
|
||||
from prometheus_client import generate_latest
|
||||
from scheduler.asyncio.scheduler import Scheduler
|
||||
|
||||
from spoolman import env, externaldb
|
||||
from spoolman import auth, env, externaldb
|
||||
from spoolman.api.v1.router import app as v1_app
|
||||
from spoolman.client import SinglePageApplication
|
||||
from spoolman.database import database
|
||||
@@ -173,6 +173,12 @@ async def startup() -> None:
|
||||
project_root = Path(__file__).parent.parent
|
||||
subprocess.run(["alembic", "upgrade", "head"], check=True, cwd=project_root) # noqa: S603, S607, ASYNC221
|
||||
|
||||
# Load auth enabled setting from database
|
||||
logger.info("Loading auth settings...")
|
||||
async for db in database.get_db_session():
|
||||
auth_enabled = await auth.load_auth_enabled_from_db(db)
|
||||
logger.info("Authentication enabled: %s", auth_enabled)
|
||||
|
||||
# Setup scheduler
|
||||
schedule = Scheduler()
|
||||
database.schedule_tasks(schedule)
|
||||
|
||||
@@ -72,3 +72,4 @@ register_setting("base_url", SettingType.STRING, json.dumps(""))
|
||||
|
||||
register_setting("locations", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
|
||||
register_setting("auth_enabled", SettingType.BOOLEAN, json.dumps(False))
|
||||
|
||||
Reference in New Issue
Block a user