Add multi-token authentication with client labels
- Replace single api_token with api_tokens dict (label: token pairs) - Add context-aware logging to track which client made each request - Implement token label lookup with secure comparison - Add logging middleware to inject token labels into request context - Update logging format to display [label] in all log messages - Fix WebSocket authentication to use new multi-token system - Update CLI --show-token to display all tokens with labels - Update config generation to use api_tokens format - Update README with multi-token documentation - Update config.example.yaml with multiple token examples Benefits: - Easy identification of clients in logs (Home Assistant, mobile, web UI, etc.) - Per-client token management and revocation - Better security and auditability Example log output: 2026-02-06 03:36:20,806 - [home_assistant] - WebSocket client connected Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
40
README.md
40
README.md
@@ -140,11 +140,46 @@ Configuration file locations:
|
|||||||
```yaml
|
```yaml
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8765
|
port: 8765
|
||||||
api_token: your-secret-token-here
|
|
||||||
|
# API Tokens - Multiple tokens with labels for client identification
|
||||||
|
api_tokens:
|
||||||
|
home_assistant: "your-home-assistant-token-here"
|
||||||
|
mobile: "your-mobile-app-token-here"
|
||||||
|
web_ui: "your-web-ui-token-here"
|
||||||
|
|
||||||
poll_interval: 1.0
|
poll_interval: 1.0
|
||||||
log_level: INFO
|
log_level: INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
The media server supports multiple API tokens with friendly labels. This allows you to:
|
||||||
|
- Issue different tokens for different clients (Home Assistant, mobile apps, web UI, etc.)
|
||||||
|
- Identify which client is making requests in the server logs
|
||||||
|
- Revoke individual tokens without affecting other clients
|
||||||
|
|
||||||
|
**Token labels** appear in all server logs, making it easy to track and debug client connections:
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-02-06 03:36:20,806 - media_server.services.websocket_manager - [home_assistant] - INFO - WebSocket client connected
|
||||||
|
2026-02-06 03:28:24,258 - media_server.routes.scripts - [mobile] - INFO - Executing script: lock_screen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Viewing your tokens:**
|
||||||
|
```bash
|
||||||
|
python -m media_server.main --show-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
Config directory: C:\Users\...\AppData\Roaming\media-server
|
||||||
|
|
||||||
|
API Tokens:
|
||||||
|
home_assistant B04zhGDjnxH6LIwxL3VOT0F4qORwaipD7LoDyeAG4EU
|
||||||
|
mobile xyz123...
|
||||||
|
web_ui abc456...
|
||||||
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
All settings can be overridden with environment variables (prefix: `MEDIA_SERVER_`):
|
All settings can be overridden with environment variables (prefix: `MEDIA_SERVER_`):
|
||||||
@@ -152,10 +187,11 @@ All settings can be overridden with environment variables (prefix: `MEDIA_SERVER
|
|||||||
```bash
|
```bash
|
||||||
export MEDIA_SERVER_HOST=0.0.0.0
|
export MEDIA_SERVER_HOST=0.0.0.0
|
||||||
export MEDIA_SERVER_PORT=8765
|
export MEDIA_SERVER_PORT=8765
|
||||||
export MEDIA_SERVER_API_TOKEN=your-token
|
|
||||||
export MEDIA_SERVER_LOG_LEVEL=DEBUG
|
export MEDIA_SERVER_LOG_LEVEL=DEBUG
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** For multi-token configuration, use the config.yaml file. Environment variables only support single-token mode.
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
### Health Check
|
### Health Check
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
# Copy this file to config.yaml and customize as needed.
|
# Copy this file to config.yaml and customize as needed.
|
||||||
# A secure token will be auto-generated on first run if not specified.
|
# A secure token will be auto-generated on first run if not specified.
|
||||||
|
|
||||||
# API Token (generate a secure random token)
|
# API Tokens - Multiple tokens with friendly labels
|
||||||
api_token: "your-secure-token-here"
|
# This allows you to identify which client is making requests in the logs
|
||||||
|
api_tokens:
|
||||||
|
home_assistant: "your-home-assistant-token-here"
|
||||||
|
mobile: "your-mobile-app-token-here"
|
||||||
|
web_ui: "your-web-ui-token-here"
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Authentication middleware and utilities."""
|
"""Authentication middleware and utilities."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from contextvars import ContextVar
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Query, Request, status
|
from fastapi import Depends, HTTPException, Query, Request, status
|
||||||
@@ -9,6 +11,24 @@ from .config import settings
|
|||||||
|
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
# Context variable to store current request's token label
|
||||||
|
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_label(token: str) -> Optional[str]:
|
||||||
|
"""Get the label for a token. Returns None if token is invalid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The token to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The label for the token, or None if invalid
|
||||||
|
"""
|
||||||
|
for label, stored_token in settings.api_tokens.items():
|
||||||
|
if secrets.compare_digest(stored_token, token):
|
||||||
|
return label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def verify_token(
|
async def verify_token(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -21,7 +41,7 @@ async def verify_token(
|
|||||||
credentials: The bearer token credentials
|
credentials: The bearer token credentials
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The validated token
|
The token label
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If the token is missing or invalid
|
HTTPException: If the token is missing or invalid
|
||||||
@@ -33,14 +53,17 @@ async def verify_token(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if credentials.credentials != settings.api_token:
|
label = get_token_label(credentials.credentials)
|
||||||
|
if label is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid authentication token",
|
detail="Invalid authentication token",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return credentials.credentials
|
# Set label in context for logging
|
||||||
|
token_label_var.set(label)
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
class TokenAuth:
|
class TokenAuth:
|
||||||
@@ -54,7 +77,7 @@ class TokenAuth:
|
|||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Verify the token and return it or raise an exception."""
|
"""Verify the token and return the label or raise an exception."""
|
||||||
if credentials is None:
|
if credentials is None:
|
||||||
if self.auto_error:
|
if self.auto_error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -64,7 +87,8 @@ class TokenAuth:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if credentials.credentials != settings.api_token:
|
label = get_token_label(credentials.credentials)
|
||||||
|
if label is None:
|
||||||
if self.auto_error:
|
if self.auto_error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -73,7 +97,9 @@ class TokenAuth:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return credentials.credentials
|
# Set label in context for logging
|
||||||
|
token_label_var.set(label)
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
async def verify_token_or_query(
|
async def verify_token_or_query(
|
||||||
@@ -89,23 +115,28 @@ async def verify_token_or_query(
|
|||||||
token: Token from query parameter
|
token: Token from query parameter
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The validated token
|
The token label
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If the token is missing or invalid
|
HTTPException: If the token is missing or invalid
|
||||||
"""
|
"""
|
||||||
|
label = None
|
||||||
|
|
||||||
# Try header first
|
# Try header first
|
||||||
if credentials is not None:
|
if credentials is not None:
|
||||||
if credentials.credentials == settings.api_token:
|
label = get_token_label(credentials.credentials)
|
||||||
return credentials.credentials
|
|
||||||
|
|
||||||
# Try query parameter
|
# Try query parameter
|
||||||
if token is not None:
|
if label is None and token is not None:
|
||||||
if token == settings.api_token:
|
label = get_token_label(token)
|
||||||
return token
|
|
||||||
|
|
||||||
|
if label is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Missing or invalid authentication token",
|
detail="Missing or invalid authentication token",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set label in context for logging
|
||||||
|
token_label_var.set(label)
|
||||||
|
return label
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ class Settings(BaseSettings):
|
|||||||
port: int = Field(default=8765, description="Server port")
|
port: int = Field(default=8765, description="Server port")
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
api_token: str = Field(
|
api_tokens: dict[str, str] = Field(
|
||||||
default_factory=lambda: secrets.token_urlsafe(32),
|
default_factory=lambda: {"default": secrets.token_urlsafe(32)},
|
||||||
description="API authentication token",
|
description="Named API tokens for access control (label: token pairs)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Media controller settings
|
# Media controller settings
|
||||||
@@ -128,7 +128,9 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
|
|||||||
config = {
|
config = {
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
"api_token": secrets.token_urlsafe(32),
|
"api_tokens": {
|
||||||
|
"default": secrets.token_urlsafe(32),
|
||||||
|
},
|
||||||
"poll_interval": 1.0,
|
"poll_interval": 1.0,
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
# Audio device to control (use GET /api/audio/devices to list available devices)
|
# Audio device to control (use GET /api/audio/devices to list available devices)
|
||||||
|
|||||||
@@ -7,24 +7,38 @@ from contextlib import asynccontextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from .auth import get_token_label, token_label_var
|
||||||
from .config import settings, generate_default_config, get_config_dir
|
from .config import settings, generate_default_config, get_config_dir
|
||||||
from .routes import audio_router, health_router, media_router, scripts_router
|
from .routes import audio_router, health_router, media_router, scripts_router
|
||||||
from .services import get_media_controller
|
from .services import get_media_controller
|
||||||
from .services.websocket_manager import ws_manager
|
from .services.websocket_manager import ws_manager
|
||||||
|
|
||||||
|
|
||||||
|
class TokenLabelFilter(logging.Filter):
|
||||||
|
"""Add token label to log records."""
|
||||||
|
|
||||||
|
def filter(self, record):
|
||||||
|
record.token_label = token_label_var.get("unknown")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
"""Configure application logging."""
|
"""Configure application logging with token labels."""
|
||||||
|
# Create filter and handler
|
||||||
|
token_filter = TokenLabelFilter()
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.addFilter(token_filter)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, settings.log_level.upper()),
|
level=getattr(logging, settings.log_level.upper()),
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
format="%(asctime)s - %(name)s - [%(token_label)s] - %(levelname)s - %(message)s",
|
||||||
handlers=[logging.StreamHandler(sys.stdout)],
|
handlers=[handler],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +48,10 @@ async def lifespan(app: FastAPI):
|
|||||||
setup_logging()
|
setup_logging()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||||
logger.info(f"API Token: {settings.api_token[:8]}...")
|
|
||||||
|
# Log all configured tokens
|
||||||
|
for label, token in settings.api_tokens.items():
|
||||||
|
logger.info(f"API Token [{label}]: {token[:8]}...")
|
||||||
|
|
||||||
# Start WebSocket status monitor
|
# Start WebSocket status monitor
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
@@ -66,6 +83,31 @@ def create_app() -> FastAPI:
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add token logging middleware
|
||||||
|
@app.middleware("http")
|
||||||
|
async def token_logging_middleware(request: Request, call_next):
|
||||||
|
"""Extract token label and set in context for logging."""
|
||||||
|
token_label = "unknown"
|
||||||
|
|
||||||
|
# Try Authorization header
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token = auth_header[7:]
|
||||||
|
label = get_token_label(token)
|
||||||
|
if label:
|
||||||
|
token_label = label
|
||||||
|
|
||||||
|
# Try query parameter (for artwork endpoint)
|
||||||
|
elif "token" in request.query_params:
|
||||||
|
token = request.query_params["token"]
|
||||||
|
label = get_token_label(token)
|
||||||
|
if label:
|
||||||
|
token_label = label
|
||||||
|
|
||||||
|
token_label_var.set(token_label)
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
app.include_router(audio_router)
|
app.include_router(audio_router)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
@@ -122,8 +164,10 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
if args.show_token:
|
if args.show_token:
|
||||||
print(f"API Token: {settings.api_token}")
|
|
||||||
print(f"Config directory: {get_config_dir()}")
|
print(f"Config directory: {get_config_dir()}")
|
||||||
|
print(f"\nAPI Tokens:")
|
||||||
|
for label, token in settings.api_tokens.items():
|
||||||
|
print(f" {label:20} {token}")
|
||||||
return
|
return
|
||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
|
|||||||
@@ -282,10 +282,16 @@ async def websocket_endpoint(
|
|||||||
- {"type": "get_status"} - Request current status
|
- {"type": "get_status"} - Request current status
|
||||||
"""
|
"""
|
||||||
# Verify token
|
# Verify token
|
||||||
if token != settings.api_token:
|
from ..auth import get_token_label, token_label_var
|
||||||
|
|
||||||
|
label = get_token_label(token) if token else None
|
||||||
|
if label is None:
|
||||||
await websocket.close(code=4001, reason="Invalid authentication token")
|
await websocket.close(code=4001, reason="Invalid authentication token")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Set label in context for logging
|
||||||
|
token_label_var.set(label)
|
||||||
|
|
||||||
await ws_manager.connect(websocket)
|
await ws_manager.connect(websocket)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user