feat(auth): Add optional authentication system (#22)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (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

Backend:
- Add User model with username, hashed password, role, is_active
- Add passlib[bcrypt] and python-jose[cryptography] dependencies
- Create auth.py with JWT utilities, password hashing, role checking
- Add /auth endpoints: status, setup, login, me, users CRUD
- Add role-based permission system (viewer/operator/editor/admin)
- Environment: SPOOLMAN_AUTH_ENABLED, SPOOLMAN_AUTH_SECRET_KEY
- Migration for user table

Frontend:
- Add AuthProvider context with login/logout/setup
- Add LoginPage component with setup mode for first user
- Add ProtectedRoute wrapper for auth-required pages
- Add axios interceptor to attach JWT token to requests
- Add User Management tab in Settings (admin only)
- Add translation keys for auth UI

When SPOOLMAN_AUTH_ENABLED=true:
- Users must login to access the app
- First visit shows setup page to create admin account
- Role-based access control for future endpoint protection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 00:15:36 -06:00
parent 94612a6dd3
commit 3613e7739a
15 changed files with 1250 additions and 12 deletions

View File

@@ -420,6 +420,39 @@
"key_not_changed": "Please change the key to something else.",
"delete_confirm": "Delete field {{name}}?",
"delete_confirm_description": "This will delete the field and all associated data for all entities."
},
"users": {
"tab": "Users",
"title": "User Management",
"add": "Add User",
"edit": "Edit User",
"username": "Username",
"password": "Password",
"role": "Role",
"status": "Status",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"you": "You",
"created": "User created successfully",
"updated": "User updated successfully",
"deleted": "User deleted successfully",
"delete_confirm": "Are you sure you want to delete this user?",
"username_required": "Username is required",
"username_min": "Username must be at least 3 characters",
"password_required": "Password is required",
"password_min": "Password must be at least 6 characters",
"password_change_hint": "Leave blank to keep current password",
"leave_blank": "Leave blank to keep current",
"role_viewer": "Viewer - Can only view data",
"role_operator": "Operator - Can view and record usage",
"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",
"admin_required": "Admin Access Required",
"admin_required_hint": "Only administrators can manage users",
"logout": "Log Out"
}
},
"documentTitle": {

View File

@@ -27,10 +27,16 @@ import { Favicon } from "./components/favicon";
import { SpoolmanLayout } from "./components/layout";
import liveProvider from "./components/liveProvider";
import SpoolmanNotificationProvider from "./components/notificationProvider";
import { ProtectedRoute } from "./components/protectedRoute";
import { AuthProvider, useAuth } from "./contexts/auth";
import { ColorModeContextProvider } from "./contexts/color-mode";
import { languages } from "./i18n";
import { setupAxiosAuth } from "./utils/axiosAuth";
import { getAPIURL, getBasePath } from "./utils/url";
// Setup axios auth interceptors once at module load
setupAxiosAuth();
interface ResourcePageProps {
resource: "spools" | "filaments" | "vendors";
page: "list" | "create" | "edit" | "show";
@@ -54,6 +60,11 @@ const LoadablePage = loadable((props: LoadablePageProps) => import(`./pages/${pr
cacheKey: (props: LoadablePageProps) => `page-${props.name}`,
});
// Lazy load login page
const LoginPage = loadable(() => import("./pages/login/index"), {
fallback: <div>Loading...</div>,
});
function App() {
const { t, i18n } = useTranslation();
@@ -89,10 +100,11 @@ function App() {
return (
<BrowserRouter basename={getBasePath() + "/"}>
<RefineKbarProvider>
<ColorModeContextProvider>
<ConfigProvider locale={antdLocale}>
<Refine
<AuthProvider>
<RefineKbarProvider>
<ColorModeContextProvider>
<ConfigProvider locale={antdLocale}>
<Refine
dataProvider={dataProvider(getAPIURL())}
notificationProvider={SpoolmanNotificationProvider}
i18nProvider={i18nProvider}
@@ -183,11 +195,17 @@ function App() {
}}
>
<Routes>
{/* Login route - outside protected area */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes */}
<Route
element={
<SpoolmanLayout>
<Outlet />
</SpoolmanLayout>
<ProtectedRoute>
<SpoolmanLayout>
<Outlet />
</SpoolmanLayout>
</ProtectedRoute>
}
>
<Route index element={<LoadablePage name="home" />} />
@@ -245,9 +263,10 @@ function App() {
<ReactQueryDevtools />
<Favicon url={getBasePath() + "/favicon.svg"} />
</Refine>
</ConfigProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</ConfigProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</AuthProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,28 @@
import { Spin } from "antd";
import React from "react";
import { Navigate } from "react-router";
import { useAuth } from "../contexts/auth";
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, isLoading, authStatus } = useAuth();
// Show loading while checking auth
if (isLoading) {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", height: "100vh" }}>
<Spin size="large" />
</div>
);
}
// If auth is enabled but not authenticated, redirect to login
if (authStatus?.enabled && !isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};

View File

@@ -0,0 +1,174 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
import { getAPIURL } from "../../utils/url";
export interface User {
id: number;
username: string;
role: "admin" | "editor" | "operator" | "viewer";
is_active: boolean;
created_at: string;
}
export interface AuthStatus {
enabled: boolean;
has_users: boolean;
}
interface AuthContextType {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
authStatus: AuthStatus | null;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
setup: (username: string, password: string) => Promise<void>;
refreshAuthStatus: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = "spoolman_auth_token";
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [isLoading, setIsLoading] = useState(true);
const [authStatus, setAuthStatus] = useState<AuthStatus | null>(null);
const apiUrl = getAPIURL();
// Fetch auth status
const refreshAuthStatus = useCallback(async () => {
try {
const res = await fetch(`${apiUrl}/auth/status`);
if (res.ok) {
const status = await res.json();
setAuthStatus(status);
return status;
}
} catch (err) {
console.error("Failed to fetch auth status:", err);
}
return null;
}, [apiUrl]);
// Fetch current user if we have a token
const fetchUser = useCallback(async (authToken: string) => {
try {
const res = await fetch(`${apiUrl}/auth/me`, {
headers: { Authorization: `Bearer ${authToken}` },
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
return true;
} else {
// Token invalid, clear it
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
return false;
}
} catch (err) {
console.error("Failed to fetch user:", err);
return false;
}
}, [apiUrl]);
// Initialize auth state
useEffect(() => {
const init = async () => {
setIsLoading(true);
const status = await refreshAuthStatus();
if (status?.enabled && token) {
await fetchUser(token);
}
setIsLoading(false);
};
init();
}, [refreshAuthStatus, fetchUser, token]);
const login = async (username: string, password: string) => {
const formData = new URLSearchParams();
formData.append("username", username);
formData.append("password", password);
const res = await fetch(`${apiUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData,
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Login failed");
}
const data = await res.json();
localStorage.setItem(TOKEN_KEY, data.access_token);
setToken(data.access_token);
await fetchUser(data.access_token);
};
const logout = () => {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
};
const setup = async (username: string, password: string) => {
const res = await fetch(`${apiUrl}/auth/setup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Setup failed");
}
// After setup, log in
await login(username, password);
await refreshAuthStatus();
};
const isAuthenticated = !!user || !authStatus?.enabled;
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated,
isLoading,
authStatus,
login,
logout,
setup,
refreshAuthStatus,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
// Helper to check if user has minimum role
export const hasRole = (user: User | null, minRole: "viewer" | "operator" | "editor" | "admin"): boolean => {
if (!user) return false;
const roleHierarchy = { viewer: 0, operator: 1, editor: 2, admin: 3 };
return roleHierarchy[user.role] >= roleHierarchy[minRole];
};

View File

@@ -0,0 +1,98 @@
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import { Button, Card, Form, Input, message, Typography } from "antd";
import React, { useState } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "../../contexts/auth";
const { Title, Text } = Typography;
interface LoginForm {
username: string;
password: string;
}
export const LoginPage: React.FC = () => {
const { login, setup, authStatus, refreshAuthStatus } = useAuth();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isSetupMode, setIsSetupMode] = useState(false);
const needsSetup = authStatus?.enabled && !authStatus?.has_users;
const handleSubmit = async (values: LoginForm) => {
setIsLoading(true);
try {
if (needsSetup || isSetupMode) {
await setup(values.username, values.password);
message.success("Admin account created successfully!");
} else {
await login(values.username, values.password);
message.success("Logged in successfully!");
}
navigate("/");
} catch (err) {
message.error(err instanceof Error ? err.message : "Authentication failed");
} finally {
setIsLoading(false);
}
};
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
background: "#f0f2f5",
}}
>
<Card style={{ width: 400, boxShadow: "0 4px 12px rgba(0,0,0,0.1)" }}>
<div style={{ textAlign: "center", marginBottom: 24 }}>
<Title level={2} style={{ marginBottom: 8 }}>
Spoolman
</Title>
<Text type="secondary">
{needsSetup || isSetupMode ? "Create Admin Account" : "Sign in to continue"}
</Text>
</div>
<Form<LoginForm> name="login" onFinish={handleSubmit} layout="vertical" requiredMark={false}>
<Form.Item
name="username"
rules={[
{ required: true, message: "Please enter your username" },
{ min: 3, message: "Username must be at least 3 characters" },
]}
>
<Input prefix={<UserOutlined />} placeholder="Username" size="large" autoFocus />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: "Please enter your password" },
{ min: 6, message: "Password must be at least 6 characters" },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password" size="large" />
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" size="large" block loading={isLoading}>
{needsSetup || isSetupMode ? "Create Account" : "Sign In"}
</Button>
</Form.Item>
</Form>
{needsSetup && (
<Text type="secondary" style={{ display: "block", textAlign: "center" }}>
This is the first time setup. Create an admin account to get started.
</Text>
)}
</Card>
</div>
);
};
export default LoginPage;

View File

@@ -1,4 +1,4 @@
import { FileOutlined, HighlightOutlined, SolutionOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
import { FileOutlined, HighlightOutlined, SolutionOutlined, TeamOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Menu, theme } from "antd";
import { Content } from "antd/es/layout/layout";
@@ -8,6 +8,7 @@ import React from "react";
import { Route, Routes, useNavigate } from "react-router";
import { ExtraFieldsSettings } from "./extraFieldsSettings";
import { GeneralSettings } from "./generalSettings";
import { UserSettings } from "./userSettings";
dayjs.extend(utc);
@@ -58,6 +59,7 @@ export const Settings: React.FC<IResourceComponentsProps> = () => {
}}
items={[
{ key: "", label: t("settings.general.tab"), icon: <ToolOutlined /> },
{ key: "users", label: t("settings.users.tab"), icon: <TeamOutlined /> },
{
key: "extra",
label: t("settings.extra_fields.tab"),
@@ -88,6 +90,7 @@ export const Settings: React.FC<IResourceComponentsProps> = () => {
<main>
<Routes>
<Route index element={<GeneralSettings />} />
<Route path="/users" element={<UserSettings />} />
<Route path="/extra/:entityType" element={<ExtraFieldsSettings />} />
</Routes>
</main>

View File

@@ -0,0 +1,285 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, UserOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { Button, Form, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from "antd";
import React, { useCallback, useEffect, useState } from "react";
import { hasRole, useAuth, User } from "../../contexts/auth";
import { getAPIURL } from "../../utils/url";
const { Title, Text } = Typography;
interface UserFormValues {
username: string;
password?: string;
role: "admin" | "editor" | "operator" | "viewer";
}
export const UserSettings: React.FC = () => {
const t = useTranslate();
const { user: currentUser, token, authStatus, logout } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm<UserFormValues>();
const apiUrl = getAPIURL();
const fetchUsers = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const res = await fetch(`${apiUrl}/auth/users`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
setUsers(await res.json());
}
} catch (err) {
console.error("Failed to fetch users:", err);
} finally {
setLoading(false);
}
}, [apiUrl, token]);
useEffect(() => {
if (authStatus?.enabled && hasRole(currentUser, "admin")) {
fetchUsers();
}
}, [authStatus, currentUser, fetchUsers]);
const handleCreate = async (values: UserFormValues) => {
try {
const res = await fetch(`${apiUrl}/auth/users`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to create user");
}
message.success(t("settings.users.created"));
setModalOpen(false);
form.resetFields();
fetchUsers();
} catch (err) {
message.error(err instanceof Error ? err.message : "Failed to create user");
}
};
const handleUpdate = async (values: UserFormValues) => {
if (!editingUser) return;
try {
const body: Partial<UserFormValues> = { role: values.role };
if (values.password) {
body.password = values.password;
}
const res = await fetch(`${apiUrl}/auth/users/${editingUser.id}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to update user");
}
message.success(t("settings.users.updated"));
setModalOpen(false);
setEditingUser(null);
form.resetFields();
fetchUsers();
} catch (err) {
message.error(err instanceof Error ? err.message : "Failed to update user");
}
};
const handleDelete = async (userId: number) => {
try {
const res = await fetch(`${apiUrl}/auth/users/${userId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to delete user");
}
message.success(t("settings.users.deleted"));
fetchUsers();
} catch (err) {
message.error(err instanceof Error ? err.message : "Failed to delete user");
}
};
const openCreateModal = () => {
setEditingUser(null);
form.resetFields();
form.setFieldsValue({ role: "viewer" });
setModalOpen(true);
};
const openEditModal = (user: User) => {
setEditingUser(user);
form.setFieldsValue({ username: user.username, role: user.role, password: undefined });
setModalOpen(true);
};
const roleColors: Record<string, string> = {
admin: "red",
editor: "blue",
operator: "green",
viewer: "default",
};
// If auth is not enabled, show message
if (!authStatus?.enabled) {
return (
<div style={{ textAlign: "center", padding: 40 }}>
<UserOutlined style={{ fontSize: 48, color: "#999", marginBottom: 16 }} />
<Title level={4}>{t("settings.users.auth_disabled")}</Title>
<Text type="secondary">{t("settings.users.auth_disabled_hint")}</Text>
</div>
);
}
// If user is not admin, show access denied
if (!hasRole(currentUser, "admin")) {
return (
<div style={{ textAlign: "center", padding: 40 }}>
<UserOutlined style={{ fontSize: 48, color: "#999", marginBottom: 16 }} />
<Title level={4}>{t("settings.users.admin_required")}</Title>
<Text type="secondary">{t("settings.users.admin_required_hint")}</Text>
</div>
);
}
return (
<>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>
{t("settings.users.title")}
</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
{t("settings.users.add")}
</Button>
</div>
<Table<User>
dataSource={users}
rowKey="id"
loading={loading}
pagination={false}
columns={[
{
title: t("settings.users.username"),
dataIndex: "username",
key: "username",
render: (username: string, record: User) => (
<Space>
<UserOutlined />
{username}
{record.id === currentUser?.id && <Tag>{t("settings.users.you")}</Tag>}
</Space>
),
},
{
title: t("settings.users.role"),
dataIndex: "role",
key: "role",
render: (role: string) => <Tag color={roleColors[role]}>{role.toUpperCase()}</Tag>,
},
{
title: t("settings.users.status"),
dataIndex: "is_active",
key: "is_active",
render: (isActive: boolean) => (
<Tag color={isActive ? "green" : "red"}>{isActive ? t("settings.users.active") : t("settings.users.inactive")}</Tag>
),
},
{
title: t("settings.users.actions"),
key: "actions",
width: 120,
render: (_: unknown, record: User) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)} />
<Popconfirm
title={t("settings.users.delete_confirm")}
onConfirm={() => handleDelete(record.id)}
disabled={record.id === currentUser?.id}
>
<Button size="small" danger icon={<DeleteOutlined />} disabled={record.id === currentUser?.id} />
</Popconfirm>
</Space>
),
},
]}
/>
<Modal
title={editingUser ? t("settings.users.edit") : t("settings.users.add")}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
setEditingUser(null);
form.resetFields();
}}
onOk={() => form.submit()}
okText={editingUser ? t("buttons.save") : t("buttons.create")}
>
<Form<UserFormValues>
form={form}
layout="vertical"
onFinish={editingUser ? handleUpdate : handleCreate}
>
<Form.Item
name="username"
label={t("settings.users.username")}
rules={[
{ required: !editingUser, message: t("settings.users.username_required") },
{ min: 3, message: t("settings.users.username_min") },
]}
>
<Input disabled={!!editingUser} />
</Form.Item>
<Form.Item
name="password"
label={t("settings.users.password")}
rules={[
{ required: !editingUser, message: t("settings.users.password_required") },
{ min: 6, message: t("settings.users.password_min") },
]}
help={editingUser ? t("settings.users.password_change_hint") : undefined}
>
<Input.Password placeholder={editingUser ? t("settings.users.leave_blank") : undefined} />
</Form.Item>
<Form.Item
name="role"
label={t("settings.users.role")}
rules={[{ required: true }]}
>
<Select>
<Select.Option value="viewer">{t("settings.users.role_viewer")}</Select.Option>
<Select.Option value="operator">{t("settings.users.role_operator")}</Select.Option>
<Select.Option value="editor">{t("settings.users.role_editor")}</Select.Option>
<Select.Option value="admin">{t("settings.users.role_admin")}</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
{currentUser && (
<div style={{ marginTop: 24, textAlign: "center" }}>
<Button onClick={logout}>{t("settings.users.logout")}</Button>
</div>
)}
</>
);
};
export default UserSettings;

View File

@@ -0,0 +1,34 @@
import { axiosInstance } from "@refinedev/simple-rest";
const TOKEN_KEY = "spoolman_auth_token";
/**
* Setup axios interceptor to add auth token to requests.
* Should be called once at app initialization.
*/
export function setupAxiosAuth() {
axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Handle 401 responses by clearing token
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Clear invalid token
localStorage.removeItem(TOKEN_KEY);
// Optionally redirect to login
// window.location.href = "/login";
}
return Promise.reject(error);
}
);
}

View File

@@ -0,0 +1,42 @@
"""Add user authentication table.
Revision ID: d4e5f6g7h8i9
Revises: c3d4e5f6g7h8
Create Date: 2025-01-16 01:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "d4e5f6g7h8i9"
down_revision = "c3d4e5f6g7h8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"user",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("username", sa.String(length=64), nullable=False),
sa.Column("hashed_password", sa.String(length=128), nullable=False),
sa.Column(
"role",
sa.String(length=16),
nullable=False,
server_default="viewer",
comment="Role: 'admin', 'editor', 'operator', 'viewer'.",
),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False)
op.create_index(op.f("ix_user_username"), "user", ["username"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_user_username"), table_name="user")
op.drop_index(op.f("ix_user_id"), table_name="user")
op.drop_table("user")

View File

@@ -26,6 +26,8 @@ dependencies = [
"httpx~=0.28",
"hishel~=0.1",
"python-multipart~=0.0",
"passlib[bcrypt]~=1.7",
"python-jose[cryptography]~=3.3",
]
requires-python = ">=3.9,<3.13"

289
spoolman/api/v1/auth.py Normal file
View File

@@ -0,0 +1,289 @@
"""Authentication API endpoints."""
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,
)
from spoolman.database import models
from spoolman.database.database import get_db_session
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"}

View File

@@ -444,6 +444,7 @@ class Info(BaseModel):
db_type: str = Field(examples=["sqlite"])
git_commit: Optional[str] = Field(None, examples=["a1b2c3d"])
build_date: Optional[SpoolmanDateTime] = Field(None, examples=["2021-01-01T00:00:00Z"])
auth_enabled: bool = Field(default=False, examples=[False])
class HealthCheck(BaseModel):

View File

@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager
from . import backup as backup_module, export, externaldb, field, filament, models, other, print_job, setting, spool, vendor
from . import auth, backup as backup_module, export, externaldb, field, filament, models, other, print_job, setting, spool, vendor
logger = logging.getLogger(__name__)
@@ -57,6 +57,7 @@ async def info() -> models.Info:
db_type=str(env.get_database_type() or "sqlite"),
git_commit=env.get_commit_hash(),
build_date=env.get_build_date(),
auth_enabled=auth.is_auth_enabled(),
)
@@ -114,3 +115,4 @@ app.include_router(externaldb.router)
app.include_router(export.router)
app.include_router(backup_module.router)
app.include_router(print_job.router)
app.include_router(auth.router)

211
spoolman/auth.py Normal file
View File

@@ -0,0 +1,211 @@
"""Authentication utilities and dependencies."""
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Annotated, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import models
from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__)
# JWT Configuration
SECRET_KEY = os.environ.get("SPOOLMAN_AUTH_SECRET_KEY", secrets.token_urlsafe(32))
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("SPOOLMAN_AUTH_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours default
# Password hashing
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)
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")
class Role(str, Enum):
"""User roles with increasing permissions."""
VIEWER = "viewer" # Can only view
OPERATOR = "operator" # Can view + record usage
EDITOR = "editor" # Can view + use + create + edit
ADMIN = "admin" # Full access
# Role hierarchy - each role includes permissions of roles below it
ROLE_HIERARCHY = {
Role.VIEWER: 0,
Role.OPERATOR: 1,
Role.EDITOR: 2,
Role.ADMIN: 3,
}
class Token(BaseModel):
"""JWT token response."""
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
"""Data extracted from JWT token."""
username: str
role: Role
class UserResponse(BaseModel):
"""User data returned by API (no password)."""
id: int
username: str
role: Role
is_active: bool
created_at: datetime
@staticmethod
def from_db(user: models.User) -> "UserResponse":
"""Convert database model to response model."""
return UserResponse(
id=user.id,
username=user.username,
role=Role(user.role),
is_active=user.is_active,
created_at=user.created_at,
)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hashed password."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password."""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[models.User]:
"""Get a user by username."""
result = await db.execute(select(models.User).where(models.User.username == username))
return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, username: str, password: str) -> Optional[models.User]:
"""Authenticate a user by username and password."""
user = await get_user_by_username(db, username)
if not user or not verify_password(password, user.hashed_password):
return None
if not user.is_active:
return None
return user
async def get_current_user(
token: Annotated[Optional[str], Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> Optional[models.User]:
"""Get the current authenticated user from JWT token.
Returns None if auth is disabled or no token provided.
Raises HTTPException if token is invalid.
"""
if not is_auth_enabled():
return None
if token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
user = await get_user_by_username(db, username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_role(minimum_role: Role):
"""Dependency factory that requires a minimum role level.
Usage:
@router.post("/spool", dependencies=[Depends(require_role(Role.EDITOR))])
async def create_spool(...):
...
"""
async def role_checker(
current_user: Annotated[Optional[models.User], Depends(get_current_user)],
) -> Optional[models.User]:
# If auth is disabled, allow all operations
if not is_auth_enabled():
return None
if current_user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
user_role = Role(current_user.role)
if ROLE_HIERARCHY[user_role] < ROLE_HIERARCHY[minimum_role]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires {minimum_role.value} role or higher",
)
return current_user
return role_checker
# Common dependencies for different permission levels
RequireViewer = Depends(require_role(Role.VIEWER))
RequireOperator = Depends(require_role(Role.OPERATOR))
RequireEditor = Depends(require_role(Role.EDITOR))
RequireAdmin = Depends(require_role(Role.ADMIN))

View File

@@ -163,3 +163,20 @@ class SpoolField(Base):
spool: Mapped["Spool"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class User(Base):
"""User account for authentication."""
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(
String(16),
default="viewer",
comment="Role: 'admin', 'editor', 'operator', 'viewer'.",
)
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column()