From f63f70ce86fb084af453e45a4a8f638b5edc5b68 Mon Sep 17 00:00:00 2001 From: tonym Date: Fri, 16 Jan 2026 01:01:14 -0600 Subject: [PATCH] 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 --- client/public/locales/en/common.json | 18 +- client/src/pages/settings/generalSettings.tsx | 179 +++++++++++++++++- pyproject.toml | 2 +- spoolman/api/v1/auth.py | 85 +++++++++ spoolman/auth.py | 30 ++- spoolman/main.py | 8 +- spoolman/settings.py | 1 + 7 files changed, 316 insertions(+), 7 deletions(-) diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index f5e43cd..260fb1c 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -390,6 +390,22 @@ "replace_warning_title": "Replace all data?", "replace_warning": "This will DELETE all existing data and replace it with the backup. This cannot be undone!" }, + "auth": { + "title": "Authentication", + "description": "Require users to log in to access Spoolman. Once enabled, you'll be redirected to the login page.", + "status_enabled": "Authentication is enabled", + "status_disabled": "Authentication is disabled", + "setup_title": "Enable Authentication", + "setup_description": "Create an admin account to enable authentication. This user will have full access to manage Spoolman.", + "enable_button": "Create Admin & Enable", + "disable_title": "Disable Authentication", + "disable_description": "Enter an admin password to confirm disabling authentication. Anyone will be able to access Spoolman without logging in.", + "disable_button": "Disable Authentication", + "admin_password": "Admin Password", + "password_required": "Password is required", + "enabled_success": "Authentication enabled", + "disabled_success": "Authentication disabled" + }, "extra_fields": { "tab": "Extra Fields", "description": "

Here you can add extra custom fields to your entities.

Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.

The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.

Extra fields can not be sorted or filtered in the table views.

", @@ -449,7 +465,7 @@ "role_editor": "Editor - Can view, use, create, and edit", "role_admin": "Admin - Full access including user management", "auth_disabled": "Authentication is not enabled", - "auth_disabled_hint": "Set SPOOLMAN_AUTH_ENABLED=true to enable user management", + "auth_disabled_hint": "Enable authentication in Settings > General to manage users", "admin_required": "Admin Access Required", "admin_required_hint": "Only administrators can manage users", "logout": "Log Out" diff --git a/client/src/pages/settings/generalSettings.tsx b/client/src/pages/settings/generalSettings.tsx index e5100e0..b4c1cc1 100644 --- a/client/src/pages/settings/generalSettings.tsx +++ b/client/src/pages/settings/generalSettings.tsx @@ -1,11 +1,12 @@ -import { DownloadOutlined, UploadOutlined } from "@ant-design/icons"; +import { DownloadOutlined, LockOutlined, UploadOutlined } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; -import { Button, Checkbox, Divider, Form, Input, message, Popconfirm, Space, Upload } from "antd"; +import { Button, Checkbox, Divider, Form, Input, message, Modal, Popconfirm, Space, Switch, Upload } from "antd"; import type { UploadFile } from "antd"; import { useContext, useEffect, useState } from "react"; import { getAPIURL } from "../../utils/url"; import { useGetSettings, useSetSetting } from "../../utils/querySettings"; import { ColorModeContext, THEME_COLORS, ThemeColorKey } from "../../contexts/color-mode"; +import { useAuth } from "../../contexts/auth"; export function GeneralSettings() { const settings = useGetSettings(); @@ -137,6 +138,10 @@ export function GeneralSettings() { + + + + {contextHolder} @@ -144,6 +149,176 @@ export function GeneralSettings() { ); } +interface AuthSettingsProps { + messageApi: ReturnType[0]; + t: ReturnType; +} + +function AuthSettings({ messageApi, t }: AuthSettingsProps) { + const { authStatus, refreshAuthStatus, setup } = useAuth(); + const [toggling, setToggling] = useState(false); + const [setupModalOpen, setSetupModalOpen] = useState(false); + const [disableModalOpen, setDisableModalOpen] = useState(false); + const [setupForm] = Form.useForm(); + const [disableForm] = Form.useForm(); + + const handleToggle = async (enabled: boolean) => { + if (enabled) { + // If enabling and no users exist, show setup modal + if (!authStatus?.has_users) { + setSetupModalOpen(true); + return; + } + // If users exist, just enable + await toggleAuth(true); + } else { + // Disabling - show password confirmation modal + setDisableModalOpen(true); + } + }; + + const toggleAuth = async (enabled: boolean, password?: string) => { + setToggling(true); + try { + const res = await fetch(`${getAPIURL()}/auth/toggle`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled, password }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || "Failed to toggle authentication"); + } + + await refreshAuthStatus(); + messageApi.success( + enabled + ? t("settings.auth.enabled_success") + : t("settings.auth.disabled_success") + ); + + if (enabled) { + // Redirect to login after enabling + window.location.href = "/login"; + } + } catch (err) { + messageApi.error(err instanceof Error ? err.message : "Failed to toggle authentication"); + } finally { + setToggling(false); + } + }; + + const handleSetup = async (values: { username: string; password: string }) => { + setToggling(true); + try { + // Create the first admin user + await setup(values.username, values.password); + // Enable auth + await toggleAuth(true); + setSetupModalOpen(false); + setupForm.resetFields(); + } catch (err) { + messageApi.error(err instanceof Error ? err.message : "Setup failed"); + } finally { + setToggling(false); + } + }; + + const handleDisable = async (values: { password: string }) => { + await toggleAuth(false, values.password); + setDisableModalOpen(false); + disableForm.resetFields(); + }; + + return ( +
+

+ + {t("settings.auth.title")} +

+

+ {t("settings.auth.description")} +

+ +
+ + + {authStatus?.enabled + ? t("settings.auth.status_enabled") + : t("settings.auth.status_disabled")} + +
+ + {/* Setup Modal - Create first admin user */} + { + setSetupModalOpen(false); + setupForm.resetFields(); + }} + onOk={() => setupForm.submit()} + okText={t("settings.auth.enable_button")} + confirmLoading={toggling} + > +

{t("settings.auth.setup_description")}

+
+ + + + + + +
+
+ + {/* Disable Modal - Require password confirmation */} + { + setDisableModalOpen(false); + disableForm.resetFields(); + }} + onOk={() => disableForm.submit()} + okText={t("settings.auth.disable_button")} + okButtonProps={{ danger: true }} + confirmLoading={toggling} + > +

{t("settings.auth.disable_description")}

+
+ + + +
+
+
+ ); +} + interface BackupRestoreProps { messageApi: ReturnType[0]; t: ReturnType; diff --git a/pyproject.toml b/pyproject.toml index 8ad8111..de5a13b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spoolman" -version = "0.23C.4" +version = "0.23C.5" description = "A web service that keeps track of 3D printing spools." authors = [ { name = "Tony", email = "tony@nastynas.xyz" }, diff --git a/spoolman/api/v1/auth.py b/spoolman/api/v1/auth.py index f4b76b9..3af2d63 100644 --- a/spoolman/api/v1/auth.py +++ b/spoolman/api/v1/auth.py @@ -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, + ) diff --git a/spoolman/auth.py b/spoolman/auth.py index 04327d4..36e7ae9 100644 --- a/spoolman/auth.py +++ b/spoolman/auth.py @@ -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): diff --git a/spoolman/main.py b/spoolman/main.py index 69c3236..13ff96f 100644 --- a/spoolman/main.py +++ b/spoolman/main.py @@ -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) diff --git a/spoolman/settings.py b/spoolman/settings.py index 0c6ce31..c8530e7 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -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))