Auth is now optional: when `auth.api_keys` is empty, all endpoints are open (no login screen, no Bearer tokens). Health endpoint reports `auth_required` so the frontend knows which mode to use. Backup/restore fixes: - Auto-backup uses atomic writes (was `write_text`, risked corruption) - Startup backup skipped if recent backup exists (<5 min cooldown), preventing rapid restarts from rotating out good backups - Restore rejects all-empty backups to prevent accidental data wipes - Store saves frozen after restore to prevent stale in-memory data from overwriting freshly-restored files before restart completes - Missing stores during restore logged as warnings - STORE_MAP completeness verified at startup against StorageConfig
This commit is contained in:
@@ -15,11 +15,19 @@ logger = get_logger(__name__)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Return True when at least one API key is configured."""
|
||||
return bool(get_config().auth.api_keys)
|
||||
|
||||
|
||||
def verify_api_key(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)]
|
||||
) -> str:
|
||||
"""Verify API key from Authorization header.
|
||||
|
||||
When no API keys are configured, authentication is disabled and all
|
||||
requests are allowed through as "anonymous".
|
||||
|
||||
Args:
|
||||
credentials: HTTP authorization credentials
|
||||
|
||||
@@ -31,6 +39,10 @@ def verify_api_key(
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
# No keys configured → auth disabled, allow all requests
|
||||
if not config.auth.api_keys:
|
||||
return "anonymous"
|
||||
|
||||
# Check if credentials are provided
|
||||
if not credentials:
|
||||
logger.warning("Request missing Authorization header")
|
||||
@@ -43,14 +55,6 @@ def verify_api_key(
|
||||
# Extract token
|
||||
token = credentials.credentials
|
||||
|
||||
# Verify against configured API keys
|
||||
if not config.auth.api_keys:
|
||||
logger.error("No API keys configured - server misconfiguration")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Server authentication not configured properly",
|
||||
)
|
||||
|
||||
# Find matching key and return its label using constant-time comparison
|
||||
authenticated_as = None
|
||||
for label, api_key in config.auth.api_keys.items():
|
||||
@@ -80,10 +84,14 @@ AuthRequired = Annotated[str, Depends(verify_api_key)]
|
||||
def verify_ws_token(token: str) -> bool:
|
||||
"""Check a WebSocket query-param token against configured API keys.
|
||||
|
||||
Use this for WebSocket endpoints where FastAPI's Depends() isn't available.
|
||||
When no API keys are configured, authentication is disabled and all
|
||||
WebSocket connections are allowed.
|
||||
"""
|
||||
config = get_config()
|
||||
if token and config.auth.api_keys:
|
||||
# No keys configured → auth disabled, allow all connections
|
||||
if not config.auth.api_keys:
|
||||
return True
|
||||
if token:
|
||||
for _label, api_key in config.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
return True
|
||||
|
||||
@@ -27,6 +27,7 @@ from wled_controller.api.schemas.system import (
|
||||
)
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.storage.base_store import freeze_saves
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -175,6 +176,7 @@ async def import_store(
|
||||
return len(incoming)
|
||||
|
||||
count = await asyncio.to_thread(_write)
|
||||
freeze_saves()
|
||||
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
|
||||
_schedule_restart()
|
||||
return {
|
||||
@@ -269,6 +271,27 @@ async def restore_config(
|
||||
if not isinstance(stores[key], dict):
|
||||
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
|
||||
|
||||
# Guard: reject backups where every store is empty (version key only, no entities).
|
||||
# This prevents accidental data wipes from restoring a backup taken when the
|
||||
# server had no data loaded.
|
||||
total_entities = 0
|
||||
for key in present_keys:
|
||||
store_data = stores[key]
|
||||
for field_key, field_val in store_data.items():
|
||||
if field_key != "version" and isinstance(field_val, dict):
|
||||
total_entities += len(field_val)
|
||||
if total_entities == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Backup contains no entity data (all stores are empty). Aborting to prevent data loss.",
|
||||
)
|
||||
|
||||
# Log missing stores as warnings
|
||||
missing = known_keys - present_keys
|
||||
if missing:
|
||||
for store_key in sorted(missing):
|
||||
logger.warning(f"Restore: backup is missing store '{store_key}' — existing data will be kept")
|
||||
|
||||
# Write store files atomically (in thread to avoid blocking event loop)
|
||||
config = get_config()
|
||||
|
||||
@@ -284,10 +307,13 @@ async def restore_config(
|
||||
|
||||
written = await asyncio.to_thread(_write_stores)
|
||||
|
||||
# Freeze all store saves so the old process can't overwrite restored files
|
||||
# with stale in-memory data before the restart completes.
|
||||
freeze_saves()
|
||||
|
||||
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
|
||||
_schedule_restart()
|
||||
|
||||
missing = known_keys - present_keys
|
||||
return RestoreResponse(
|
||||
status="restored",
|
||||
stores_written=written,
|
||||
|
||||
@@ -14,7 +14,7 @@ import psutil
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.auth import AuthRequired, is_auth_enabled
|
||||
from wled_controller.api.dependencies import (
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
@@ -107,6 +107,7 @@ async def health_check():
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
version=__version__,
|
||||
demo_mode=get_config().demo,
|
||||
auth_required=is_auth_enabled(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ class HealthResponse(BaseModel):
|
||||
timestamp: datetime = Field(description="Current server time")
|
||||
version: str = Field(description="Application version")
|
||||
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
|
||||
auth_required: bool = Field(default=True, description="Whether API key authentication is required")
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user