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
This commit is contained in:
maziggy
2026-01-21 16:41:37 +01:00
parent dc30856238
commit f334c74d02
7 changed files with 605 additions and 3 deletions
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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();
});
});
});
});