diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d78920f..6d9e2707d 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/README.md b/README.md index f1e29a296..8a72dce98 100644 --- a/README.md +++ b/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) + diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index b50e332f3..473ca0ef4 100644 --- a/backend/app/api/routes/auth.py +++ b/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: diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 4517a870e..5bcc66b41 100644 --- a/backend/app/api/routes/settings.py +++ b/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 diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 850e966c1..f79a66b49 100644 --- a/backend/app/core/auth.py +++ b/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]) diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py new file mode 100644 index 000000000..f6349b043 --- /dev/null +++ b/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 diff --git a/frontend/src/__tests__/pages/LoginPage.test.tsx b/frontend/src/__tests__/pages/LoginPage.test.tsx new file mode 100644 index 000000000..3e119101a --- /dev/null +++ b/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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); + }); +});