diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index f11c774..f5e43cd 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -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": { diff --git a/client/src/App.tsx b/client/src/App.tsx index c590436..48da920 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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:
Loading...
, +}); + function App() { const { t, i18n } = useTranslation(); @@ -89,10 +100,11 @@ function App() { return ( - - - - + + + + + {/* Login route - outside protected area */} + } /> + + {/* Protected routes */} - - + + + + + } > } /> @@ -245,9 +263,10 @@ function App() { - - - + + + + ); } diff --git a/client/src/components/protectedRoute.tsx b/client/src/components/protectedRoute.tsx new file mode 100644 index 0000000..d7a784c --- /dev/null +++ b/client/src/components/protectedRoute.tsx @@ -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 = ({ children }) => { + const { isAuthenticated, isLoading, authStatus } = useAuth(); + + // Show loading while checking auth + if (isLoading) { + return ( +
+ +
+ ); + } + + // If auth is enabled but not authenticated, redirect to login + if (authStatus?.enabled && !isAuthenticated) { + return ; + } + + return <>{children}; +}; diff --git a/client/src/contexts/auth/index.tsx b/client/src/contexts/auth/index.tsx new file mode 100644 index 0000000..2a87960 --- /dev/null +++ b/client/src/contexts/auth/index.tsx @@ -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; + logout: () => void; + setup: (username: string, password: string) => Promise; + refreshAuthStatus: () => Promise; +} + +const AuthContext = createContext(undefined); + +const TOKEN_KEY = "spoolman_auth_token"; + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); + const [isLoading, setIsLoading] = useState(true); + const [authStatus, setAuthStatus] = useState(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 ( + + {children} + + ); +}; + +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]; +}; diff --git a/client/src/pages/login/index.tsx b/client/src/pages/login/index.tsx new file mode 100644 index 0000000..37b6739 --- /dev/null +++ b/client/src/pages/login/index.tsx @@ -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 ( +
+ +
+ + Spoolman + + + {needsSetup || isSetupMode ? "Create Admin Account" : "Sign in to continue"} + +
+ + name="login" onFinish={handleSubmit} layout="vertical" requiredMark={false}> + + } placeholder="Username" size="large" autoFocus /> + + + + } placeholder="Password" size="large" /> + + + + + + + + {needsSetup && ( + + This is the first time setup. Create an admin account to get started. + + )} +
+
+ ); +}; + +export default LoginPage; diff --git a/client/src/pages/settings/index.tsx b/client/src/pages/settings/index.tsx index 1a08d3e..9dbf959 100644 --- a/client/src/pages/settings/index.tsx +++ b/client/src/pages/settings/index.tsx @@ -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 = () => { }} items={[ { key: "", label: t("settings.general.tab"), icon: }, + { key: "users", label: t("settings.users.tab"), icon: }, { key: "extra", label: t("settings.extra_fields.tab"), @@ -88,6 +90,7 @@ export const Settings: React.FC = () => {
} /> + } /> } />
diff --git a/client/src/pages/settings/userSettings.tsx b/client/src/pages/settings/userSettings.tsx new file mode 100644 index 0000000..e3db74a --- /dev/null +++ b/client/src/pages/settings/userSettings.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [form] = Form.useForm(); + + 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 = { 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 = { + admin: "red", + editor: "blue", + operator: "green", + viewer: "default", + }; + + // If auth is not enabled, show message + if (!authStatus?.enabled) { + return ( +
+ + {t("settings.users.auth_disabled")} + {t("settings.users.auth_disabled_hint")} +
+ ); + } + + // If user is not admin, show access denied + if (!hasRole(currentUser, "admin")) { + return ( +
+ + {t("settings.users.admin_required")} + {t("settings.users.admin_required_hint")} +
+ ); + } + + return ( + <> +
+ + {t("settings.users.title")} + + +
+ + + dataSource={users} + rowKey="id" + loading={loading} + pagination={false} + columns={[ + { + title: t("settings.users.username"), + dataIndex: "username", + key: "username", + render: (username: string, record: User) => ( + + + {username} + {record.id === currentUser?.id && {t("settings.users.you")}} + + ), + }, + { + title: t("settings.users.role"), + dataIndex: "role", + key: "role", + render: (role: string) => {role.toUpperCase()}, + }, + { + title: t("settings.users.status"), + dataIndex: "is_active", + key: "is_active", + render: (isActive: boolean) => ( + {isActive ? t("settings.users.active") : t("settings.users.inactive")} + ), + }, + { + title: t("settings.users.actions"), + key: "actions", + width: 120, + render: (_: unknown, record: User) => ( + + + + )} + + ); +}; + +export default UserSettings; diff --git a/client/src/utils/axiosAuth.ts b/client/src/utils/axiosAuth.ts new file mode 100644 index 0000000..b7681ba --- /dev/null +++ b/client/src/utils/axiosAuth.ts @@ -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); + } + ); +} diff --git a/migrations/versions/2025_01_16_0100-d4e5f6g7h8i9_add_user_auth.py b/migrations/versions/2025_01_16_0100-d4e5f6g7h8i9_add_user_auth.py new file mode 100644 index 0000000..e275ae5 --- /dev/null +++ b/migrations/versions/2025_01_16_0100-d4e5f6g7h8i9_add_user_auth.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index d66ac7e..37c504f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/spoolman/api/v1/auth.py b/spoolman/api/v1/auth.py new file mode 100644 index 0000000..f4b76b9 --- /dev/null +++ b/spoolman/api/v1/auth.py @@ -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"} diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 49a32dc..f651f34 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -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): diff --git a/spoolman/api/v1/router.py b/spoolman/api/v1/router.py index e05a0ae..0d634c8 100644 --- a/spoolman/api/v1/router.py +++ b/spoolman/api/v1/router.py @@ -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) diff --git a/spoolman/auth.py b/spoolman/auth.py new file mode 100644 index 0000000..04327d4 --- /dev/null +++ b/spoolman/auth.py @@ -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)) diff --git a/spoolman/database/models.py b/spoolman/database/models.py index c450072..9574e09 100644 --- a/spoolman/database/models.py +++ b/spoolman/database/models.py @@ -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()