Browse Source

Post tasks for PR #117 (Authentication Feature)

Fixes:

  - Fixed is_auth_enabled() returning None instead of False when setting doesn't exist (in both auth.py and routes/auth.py)
  - Fixed get_current_user() crashing when credentials is None
  - Added missing async_session patch in conftest.py for auth tests

  Backup/Restore - Added Users Support
  - Added include_users parameter to backup export
  - Users are exported with username, role, and is_active (passwords excluded for security)
  - Restore creates users with temporary passwords that must be changed

Backend Tests - 16 New Auth Tests
  - test_auth_api.py with tests for:
  - Auth status endpoint
  - Auth setup (enable/disable)
  - Login flow (success, invalid credentials, auth disabled)
  - /me endpoint with/without token
  - User management (list, create, update, delete)
  - Auth disable

Frontend Tests - 6 New Login Tests
  - LoginPage.test.tsx with tests for:
  - Form rendering
  - Input validation
  - Login submission
  - Loading states

Documentation Updates
  - CHANGELOG.md/README.md: Added authentication feature description
  - Website (features.html): Added new "Optional Authentication" section
  - Wiki: Created authentication.md with full documentation
  - Wiki index: Added authentication to features list
maziggy 4 months ago
parent
commit
f334c74d02

+ 8 - 0
CHANGELOG.md

@@ -71,6 +71,14 @@ All notable changes to Bambuddy will be documented in this file.
   - Three-dot menu button always visible on mobile (hover-only on desktop)
   - Selection checkbox always visible on mobile devices
   - Better PWA experience for file management
+- **Optional Authentication** - Secure your Bambuddy instance with user authentication:
+  - Enable/disable authentication via Setup page or Settings → Users
+  - Role-based access control: Admin and User roles
+  - Admins have full access; Users can manage prints but not settings
+  - JWT-based authentication with 7-day token expiration
+  - User management page for creating, editing, and deleting users
+  - Backward compatible: existing installations work without authentication
+  - Settings page restricted to admin users when auth is enabled
 
 ### Changed
 - **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)

+ 6 - 0
README.md

@@ -134,6 +134,12 @@
 - Live application log viewer with filtering
 - Support bundle generator (privacy-filtered)
 
+### 🔒 Optional Authentication
+- Enable/disable authentication any time
+- Role-based access (Admin/User)
+- JWT tokens with secure password hashing
+- User management (create, edit, delete)
+
 </td>
 </tr>
 </table>

+ 3 - 1
backend/app/api/routes/auth.py

@@ -24,7 +24,9 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
     """Check if authentication is enabled."""
     result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
     setting = result.scalar_one_or_none()
-    return setting and setting.value.lower() == "true"
+    if setting is None:
+        return False
+    return setting.value.lower() == "true"
 
 
 async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:

+ 61 - 0
backend/app/api/routes/settings.py

@@ -25,6 +25,7 @@ from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.spoolman import init_spoolman_client
@@ -242,6 +243,9 @@ async def export_backup(
     include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
     include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
     include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"),
+    include_users: bool = Query(
+        False, description="Include users (passwords not exported - users will need new passwords)"
+    ),
 ):
     """Export selected data as JSON backup."""
     backup: dict = {
@@ -776,6 +780,22 @@ async def export_backup(
             )
         backup["included"].append("api_keys")
 
+    # Users (note: passwords not exported for security - users will need new passwords on import)
+    if include_users:
+        result = await db.execute(select(User))
+        users = result.scalars().all()
+        backup["users"] = []
+        for user in users:
+            backup["users"].append(
+                {
+                    "username": user.username,
+                    "role": user.role,
+                    "is_active": user.is_active,
+                    # password_hash intentionally not exported for security
+                }
+            )
+        backup["included"].append("users")
+
     # If there are files to include (icons or archives), create ZIP file
     if backup_files:
         zip_buffer = io.BytesIO()
@@ -866,6 +886,7 @@ async def import_backup(
         "maintenance_types": 0,
         "projects": 0,
         "pending_uploads": 0,
+        "users": 0,
     }
     skipped = {
         "settings": 0,
@@ -879,6 +900,7 @@ async def import_backup(
         "archives": 0,
         "projects": 0,
         "pending_uploads": 0,
+        "users": 0,
     }
     skipped_details = {
         "notification_providers": [],
@@ -890,6 +912,7 @@ async def import_backup(
         "archives": [],
         "projects": [],
         "pending_uploads": [],
+        "users": [],
     }
 
     # Restore settings (always overwrites)
@@ -1772,6 +1795,40 @@ async def import_backup(
                     }
                 )
 
+    # Restore users (note: passwords not included in backup - users will need new passwords)
+    # Users are skipped by default since they have no passwords; admin must recreate them
+    new_users: list[str] = []
+    if "users" in backup:
+        from backend.app.core.auth import get_password_hash
+
+        for user_data in backup["users"]:
+            result = await db.execute(select(User).where(User.username == user_data["username"]))
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    existing.role = user_data.get("role", "user")
+                    existing.is_active = user_data.get("is_active", True)
+                    # Don't change password - keep existing
+                    restored["users"] += 1
+                else:
+                    skipped["users"] += 1
+                    skipped_details["users"].append(user_data["username"])
+            else:
+                # Create user with a temporary password that must be changed
+                # Generate a random temporary password
+                import secrets
+
+                temp_password = secrets.token_urlsafe(16)
+                user = User(
+                    username=user_data["username"],
+                    password_hash=get_password_hash(temp_password),
+                    role=user_data.get("role", "user"),
+                    is_active=user_data.get("is_active", True),
+                )
+                db.add(user)
+                restored["users"] += 1
+                new_users.append(f"{user_data['username']} (temp password: {temp_password})")
+
     await db.commit()
 
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
@@ -1882,6 +1939,10 @@ async def import_backup(
     if new_api_keys:
         response["new_api_keys"] = new_api_keys
 
+    # Include newly created users with temp passwords (so admin can share them)
+    if new_users:
+        response["new_users"] = new_users
+
     return response
 
 

+ 8 - 2
backend/app/core/auth.py

@@ -81,7 +81,9 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
     try:
         result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
         setting = result.scalar_one_or_none()
-        return setting and setting.value.lower() == "true"
+        if setting is None:
+            return False
+        return setting.value.lower() == "true"
     except Exception:
         # If settings table doesn't exist or query fails, assume auth is disabled
         return False
@@ -110,13 +112,17 @@ async def get_current_user_optional(
         return user
 
 
-async def get_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> User:
+async def get_current_user(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+) -> User:
     """Get the current authenticated user from JWT token."""
     credentials_exception = HTTPException(
         status_code=status.HTTP_401_UNAUTHORIZED,
         detail="Could not validate credentials",
         headers={"WWW-Authenticate": "Bearer"},
     )
+    if credentials is None:
+        raise credentials_exception
     try:
         token = credentials.credentials
         payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])

+ 362 - 0
backend/tests/integration/test_auth_api.py

@@ -0,0 +1,362 @@
+"""Integration tests for Authentication API endpoints.
+
+Tests the full request/response cycle for /api/v1/auth/ and /api/v1/users/ endpoints.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestAuthStatusAPI:
+    """Integration tests for /api/v1/auth/status endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_auth_status_disabled(self, async_client: AsyncClient):
+        """Verify auth status returns disabled when not configured."""
+        response = await async_client.get("/api/v1/auth/status")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "auth_enabled" in result
+        assert result["auth_enabled"] is False
+        assert result["requires_setup"] is True
+
+
+class TestAuthSetupAPI:
+    """Integration tests for /api/v1/auth/setup endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_auth_disabled(self, async_client: AsyncClient):
+        """Verify auth can be set up with auth disabled (no password required)."""
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": False},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auth_enabled"] is False
+        assert result["admin_created"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_auth_enabled_requires_credentials(self, async_client: AsyncClient):
+        """Verify enabling auth requires admin username and password."""
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": True},
+        )
+
+        assert response.status_code == 400
+        assert "Admin username and password are required" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_auth_enabled_with_credentials(self, async_client: AsyncClient):
+        """Verify auth can be enabled with admin credentials."""
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "testadmin",
+                "admin_password": "testpassword123",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auth_enabled"] is True
+        assert result["admin_created"] is True
+
+
+class TestAuthLoginAPI:
+    """Integration tests for /api/v1/auth/login endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_auth_disabled(self, async_client: AsyncClient):
+        """Verify login fails when auth is not enabled."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "admin", "password": "password"},
+        )
+
+        assert response.status_code == 400
+        assert "Authentication is not enabled" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_success(self, async_client: AsyncClient):
+        """Verify login succeeds with valid credentials after setup."""
+        # First enable auth
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "logintest",
+                "admin_password": "loginpassword123",
+            },
+        )
+
+        # Now login
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "logintest", "password": "loginpassword123"},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "access_token" in result
+        assert result["token_type"] == "bearer"
+        assert result["user"]["username"] == "logintest"
+        assert result["user"]["role"] == "admin"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_invalid_credentials(self, async_client: AsyncClient):
+        """Verify login fails with invalid credentials."""
+        # First enable auth
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "invalidtest",
+                "admin_password": "correctpassword",
+            },
+        )
+
+        # Try login with wrong password
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "invalidtest", "password": "wrongpassword"},
+        )
+
+        assert response.status_code == 401
+        assert "Incorrect username or password" in response.json()["detail"]
+
+
+class TestAuthMeAPI:
+    """Integration tests for /api/v1/auth/me endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_without_token(self, async_client: AsyncClient):
+        """Verify /me fails without authentication token."""
+        response = await async_client.get("/api/v1/auth/me")
+
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_valid_token(self, async_client: AsyncClient):
+        """Verify /me returns user info with valid token."""
+        # Setup and login
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "metest",
+                "admin_password": "mepassword123",
+            },
+        )
+
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "metest", "password": "mepassword123"},
+        )
+        token = login_response.json()["access_token"]
+
+        # Get current user
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"Authorization": f"Bearer {token}"},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["username"] == "metest"
+        assert result["role"] == "admin"
+        assert result["is_active"] is True
+
+
+class TestUsersAPI:
+    """Integration tests for /api/v1/users/ endpoints."""
+
+    @pytest.fixture
+    async def auth_token(self, async_client: AsyncClient):
+        """Setup auth and return admin token."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "usersadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "usersadmin", "password": "adminpassword123"},
+        )
+        return login_response.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_users_requires_auth(self, async_client: AsyncClient):
+        """Verify listing users requires authentication."""
+        response = await async_client.get("/api/v1/users/")
+
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_users_as_admin(self, async_client: AsyncClient, auth_token: str):
+        """Verify admin can list users."""
+        response = await async_client.get(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert isinstance(result, list)
+        assert len(result) >= 1  # At least the admin user
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user(self, async_client: AsyncClient, auth_token: str):
+        """Verify admin can create a new user."""
+        response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "newuser",
+                "password": "newuserpassword",
+                "role": "user",
+            },
+        )
+
+        assert response.status_code == 201
+        result = response.json()
+        assert result["username"] == "newuser"
+        assert result["role"] == "user"
+        assert result["is_active"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user_duplicate_username(self, async_client: AsyncClient, auth_token: str):
+        """Verify creating user with duplicate username fails."""
+        # Create first user
+        await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "duplicateuser",
+                "password": "password123",
+                "role": "user",
+            },
+        )
+
+        # Try to create duplicate
+        response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "duplicateuser",
+                "password": "password456",
+                "role": "user",
+            },
+        )
+
+        assert response.status_code == 400
+        assert "Username already exists" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_user(self, async_client: AsyncClient, auth_token: str):
+        """Verify admin can update a user."""
+        # Create user
+        create_response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "updateuser",
+                "password": "password123",
+                "role": "user",
+            },
+        )
+        user_id = create_response.json()["id"]
+
+        # Update user
+        response = await async_client.patch(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={"role": "admin"},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["role"] == "admin"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_user(self, async_client: AsyncClient, auth_token: str):
+        """Verify admin can delete a user."""
+        # Create user
+        create_response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "deleteuser",
+                "password": "password123",
+                "role": "user",
+            },
+        )
+        user_id = create_response.json()["id"]
+
+        # Delete user
+        response = await async_client.delete(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 204
+
+
+class TestAuthDisableAPI:
+    """Integration tests for /api/v1/auth/disable endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_auth(self, async_client: AsyncClient):
+        """Verify admin can disable authentication."""
+        # Setup auth
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "disableadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        # Login to get token
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "disableadmin", "password": "adminpassword123"},
+        )
+        token = login_response.json()["access_token"]
+
+        # Disable auth
+        response = await async_client.post(
+            "/api/v1/auth/disable",
+            headers={"Authorization": f"Bearer {token}"},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["auth_enabled"] is False
+
+        # Verify auth is now disabled
+        status_response = await async_client.get("/api/v1/auth/status")
+        assert status_response.json()["auth_enabled"] is False

+ 157 - 0
frontend/src/__tests__/pages/LoginPage.test.tsx

@@ -0,0 +1,157 @@
+/**
+ * Tests for the LoginPage component.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { LoginPage } from '../../pages/LoginPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+describe('LoginPage', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/auth/status', () => {
+        return HttpResponse.json({ auth_enabled: true, requires_setup: false });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the login form', async () => {
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
+      });
+
+      expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      expect(screen.getByLabelText(/Password/i)).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
+    });
+
+    it('renders the sign in description', async () => {
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Sign in to your account/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('form validation', () => {
+    it('shows error when submitting empty form', async () => {
+      const user = userEvent.setup();
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      // The form has required fields, so HTML5 validation should prevent submission
+      // or the component shows a toast
+    });
+
+    it('allows entering username and password', async () => {
+      const user = userEvent.setup();
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByLabelText(/Username/i), 'testuser');
+      await user.type(screen.getByLabelText(/Password/i), 'testpassword');
+
+      expect(screen.getByLabelText(/Username/i)).toHaveValue('testuser');
+      expect(screen.getByLabelText(/Password/i)).toHaveValue('testpassword');
+    });
+  });
+
+  describe('login flow', () => {
+    it('submits login request with credentials', async () => {
+      const user = userEvent.setup();
+      let loginCalled = false;
+
+      server.use(
+        http.post('/api/v1/auth/login', async ({ request }) => {
+          loginCalled = true;
+          const body = await request.json() as { username: string; password: string };
+          if (body.username === 'validuser' && body.password === 'validpass') {
+            return HttpResponse.json({
+              access_token: 'test-token',
+              token_type: 'bearer',
+              user: {
+                id: 1,
+                username: 'validuser',
+                role: 'admin',
+                is_active: true,
+                created_at: new Date().toISOString(),
+              },
+            });
+          }
+          return HttpResponse.json(
+            { detail: 'Incorrect username or password' },
+            { status: 401 }
+          );
+        })
+      );
+
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByLabelText(/Username/i), 'validuser');
+      await user.type(screen.getByLabelText(/Password/i), 'validpass');
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      // Verify the login endpoint was called
+      await waitFor(() => {
+        expect(loginCalled).toBe(true);
+      });
+    });
+
+    it('shows loading state during login', async () => {
+      const user = userEvent.setup();
+
+      // Slow login endpoint
+      server.use(
+        http.post('/api/v1/auth/login', async () => {
+          await new Promise(resolve => setTimeout(resolve, 100));
+          return HttpResponse.json({
+            access_token: 'test-token',
+            token_type: 'bearer',
+            user: {
+              id: 1,
+              username: 'testuser',
+              role: 'admin',
+              is_active: true,
+              created_at: new Date().toISOString(),
+            },
+          });
+        })
+      );
+
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByLabelText(/Username/i), 'testuser');
+      await user.type(screen.getByLabelText(/Password/i), 'testpass');
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      // Check for loading state
+      await waitFor(() => {
+        expect(screen.getByRole('button')).toBeDisabled();
+      });
+    });
+  });
+});