Add basic auth

This commit is contained in:
Jack Kingsman
2026-03-11 10:01:57 -07:00
parent fa1c086f5f
commit 528a94d2bd
11 changed files with 356 additions and 7 deletions
+1
View File
@@ -44,6 +44,7 @@ app/
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
├── events.py # Typed WS event payload serialization
├── websocket.py # WS manager + broadcast helpers
├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
├── dependencies.py # Shared FastAPI dependency providers
├── path_utils.py # Path hex rendering and hop-width helpers
+16
View File
@@ -19,6 +19,8 @@ class Settings(BaseSettings):
database_path: str = "data/meshcore.db"
disable_bots: bool = False
enable_message_poll_fallback: bool = False
basic_auth_username: str = ""
basic_auth_password: str = ""
@model_validator(mode="after")
def validate_transport_exclusivity(self) -> "Settings":
@@ -36,6 +38,11 @@ class Settings(BaseSettings):
)
if self.ble_address and not self.ble_pin:
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
if self.basic_auth_partially_configured:
raise ValueError(
"MESHCORE_BASIC_AUTH_USERNAME and MESHCORE_BASIC_AUTH_PASSWORD "
"must be set together."
)
return self
@property
@@ -46,6 +53,15 @@ class Settings(BaseSettings):
return "ble"
return "serial"
@property
def basic_auth_enabled(self) -> bool:
return bool(self.basic_auth_username and self.basic_auth_password)
@property
def basic_auth_partially_configured(self) -> bool:
any_credentials_set = bool(self.basic_auth_username or self.basic_auth_password)
return any_credentials_set and not self.basic_auth_enabled
settings = Settings()
+39 -4
View File
@@ -7,6 +7,32 @@ from fastapi.staticfiles import StaticFiles
logger = logging.getLogger(__name__)
INDEX_CACHE_CONTROL = "no-store"
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
class CacheControlStaticFiles(StaticFiles):
"""StaticFiles variant that adds a fixed Cache-Control header."""
def __init__(self, *args, cache_control: str, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.cache_control = cache_control
def file_response(self, *args, **kwargs):
response = super().file_response(*args, **kwargs)
response.headers["Cache-Control"] = self.cache_control
return response
def _file_response(path: Path, *, cache_control: str) -> FileResponse:
return FileResponse(path, headers={"Cache-Control": cache_control})
def _is_index_file(path: Path, index_file: Path) -> bool:
"""Return True when the requested file is the SPA shell index.html."""
return path == index_file
def _resolve_request_origin(request: Request) -> str:
"""Resolve the external origin, honoring common reverse-proxy headers."""
@@ -57,7 +83,11 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
return False
if assets_dir.exists() and assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
app.mount(
"/assets",
CacheControlStaticFiles(directory=assets_dir, cache_control=ASSET_CACHE_CONTROL),
name="assets",
)
else:
logger.warning(
"Frontend assets directory missing at %s; /assets files will not be served",
@@ -67,7 +97,7 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
@app.get("/")
async def serve_index():
"""Serve the frontend index.html."""
return FileResponse(index_file)
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
@app.get("/site.webmanifest")
async def serve_webmanifest(request: Request):
@@ -114,9 +144,14 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
raise HTTPException(status_code=404, detail="Not found") from None
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
cache_control = (
INDEX_CACHE_CONTROL
if _is_index_file(file_path, index_file)
else STATIC_FILE_CACHE_CONTROL
)
return _file_response(file_path, cache_control=cache_control)
return FileResponse(index_file)
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
logger.info("Serving frontend from %s", frontend_dir)
return True
+5
View File
@@ -5,8 +5,10 @@ from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from app.config import settings as server_settings
from app.config import setup_logging
from app.database import db
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
@@ -30,6 +32,7 @@ from app.routers import (
statistics,
ws,
)
from app.security import add_optional_basic_auth_middleware
from app.services.radio_runtime import radio_runtime as radio_manager
setup_logging()
@@ -114,6 +117,8 @@ app = FastAPI(
lifespan=lifespan,
)
add_optional_basic_auth_middleware(app, server_settings)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
+121
View File
@@ -0,0 +1,121 @@
"""ASGI middleware for optional app-wide HTTP Basic authentication."""
from __future__ import annotations
import base64
import binascii
import json
import logging
import secrets
from typing import Any
from starlette.datastructures import Headers
logger = logging.getLogger(__name__)
_AUTH_REALM = "RemoteTerm"
_UNAUTHORIZED_BODY = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
class BasicAuthMiddleware:
"""Protect all HTTP and WebSocket entrypoints with HTTP Basic auth."""
def __init__(self, app, *, username: str, password: str, realm: str = _AUTH_REALM) -> None:
self.app = app
self.username = username
self.password = password
self.realm = realm
self._challenge_value = f'Basic realm="{realm}", charset="UTF-8"'.encode("latin-1")
def _is_authorized(self, scope: dict[str, Any]) -> bool:
headers = Headers(scope=scope)
authorization = headers.get("authorization")
if not authorization:
return False
scheme, _, token = authorization.partition(" ")
if not token or scheme.lower() != "basic":
return False
token = token.strip()
try:
decoded = base64.b64decode(token, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
logger.debug("Rejecting malformed basic auth header")
return False
username, sep, password = decoded.partition(":")
if not sep:
return False
return secrets.compare_digest(username, self.username) and secrets.compare_digest(
password, self.password
)
async def _send_http_unauthorized(self, send) -> None:
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"cache-control", b"no-store"),
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
(b"www-authenticate", self._challenge_value),
],
}
)
await send(
{
"type": "http.response.body",
"body": _UNAUTHORIZED_BODY,
}
)
async def _send_websocket_unauthorized(self, send) -> None:
await send(
{
"type": "websocket.http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"cache-control", b"no-store"),
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
(b"www-authenticate", self._challenge_value),
],
}
)
await send(
{
"type": "websocket.http.response.body",
"body": _UNAUTHORIZED_BODY,
}
)
async def __call__(self, scope, receive, send) -> None:
scope_type = scope["type"]
if scope_type not in {"http", "websocket"}:
await self.app(scope, receive, send)
return
if self._is_authorized(scope):
await self.app(scope, receive, send)
return
if scope_type == "http":
await self._send_http_unauthorized(send)
return
await self._send_websocket_unauthorized(send)
def add_optional_basic_auth_middleware(app, settings) -> None:
"""Enable app-wide basic auth when configured via environment variables."""
if not settings.basic_auth_enabled:
return
app.add_middleware(
BasicAuthMiddleware,
username=settings.basic_auth_username,
password=settings.basic_auth_password,
)