feat: add identification page
This commit is contained in:
+81
-1
@@ -5,7 +5,7 @@ from typing import Sequence
|
||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import asyncio
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -17,9 +17,89 @@ from app.utils.playlist_merge import SyncMode, SYNC_ARTIFACTS_DIR, sync_all_play
|
||||
from app.utils.plex_client import plex_client
|
||||
from app.utils.scheduler import start_scheduler, update_scheduler_job, get_next_run_time, validate_cron_expression
|
||||
from app.utils.sync_manager import sync_manager
|
||||
from app.utils.auth import load_auth_config, issue_token, verify_token
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# --- Optional API Authentication (username/password) ---
|
||||
AUTH_CONFIG = load_auth_config()
|
||||
|
||||
|
||||
class LoginPayload(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@app.get("/api/auth/config")
|
||||
async def get_auth_config():
|
||||
return {"enabled": AUTH_CONFIG.enabled}
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def api_login(payload: LoginPayload):
|
||||
if not AUTH_CONFIG.enabled:
|
||||
raise HTTPException(status_code=400, detail="Authentication is disabled")
|
||||
|
||||
if payload.username != AUTH_CONFIG.username or payload.password != AUTH_CONFIG.password:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
token = issue_token(AUTH_CONFIG, payload.username)
|
||||
return {
|
||||
"token": token,
|
||||
"username": payload.username,
|
||||
"expires_in": AUTH_CONFIG.token_ttl_seconds,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def api_me(request: Request):
|
||||
if not AUTH_CONFIG.enabled:
|
||||
return {"username": ""}
|
||||
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
else:
|
||||
token = ""
|
||||
|
||||
payload = verify_token(AUTH_CONFIG, token) if token else None
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return {"username": payload.get("u", "")}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def api_logout():
|
||||
# Stateless token auth; client clears token.
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
_AUTH_API_WHITELIST = {
|
||||
"/api/auth/config",
|
||||
"/api/auth/login",
|
||||
}
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
if AUTH_CONFIG.enabled and request.url.path.startswith("/api"):
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return await call_next(request)
|
||||
if request.url.path not in _AUTH_API_WHITELIST:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
token = ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
|
||||
# For endpoints like EventSource(SSE) where custom headers are not available.
|
||||
if not token:
|
||||
token = request.query_params.get("access_token", "").strip()
|
||||
|
||||
if not token or not verify_token(AUTH_CONFIG, token):
|
||||
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
sync_manager.set_event_loop(asyncio.get_running_loop())
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
def _parse_bool(value: str | None, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
padding = "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode((data + padding).encode("utf-8"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfig:
|
||||
enabled: bool
|
||||
username: str
|
||||
password: str
|
||||
token_secret: bytes
|
||||
token_ttl_seconds: int
|
||||
|
||||
|
||||
def load_auth_config() -> AuthConfig:
|
||||
enabled = _parse_bool(os.environ.get("PLEXPLAYLISTSYNC_AUTH_ENABLED"), default=False)
|
||||
username = os.environ.get("PLEXPLAYLISTSYNC_AUTH_USERNAME", "").strip()
|
||||
password = os.environ.get("PLEXPLAYLISTSYNC_AUTH_PASSWORD", "")
|
||||
ttl = int(os.environ.get("PLEXPLAYLISTSYNC_AUTH_TOKEN_TTL_SECONDS", "86400"))
|
||||
ttl = max(60, ttl)
|
||||
|
||||
secret_env = os.environ.get("PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET", "").strip()
|
||||
if secret_env:
|
||||
token_secret = secret_env.encode("utf-8")
|
||||
elif enabled:
|
||||
# If auth is enabled but no explicit secret is set, fall back to an ephemeral secret.
|
||||
# This means tokens become invalid after restart, which is acceptable for this project.
|
||||
token_secret = os.urandom(32)
|
||||
logger.warning(
|
||||
"PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET not set; using ephemeral secret (tokens reset on restart)."
|
||||
)
|
||||
else:
|
||||
token_secret = b""
|
||||
|
||||
if enabled:
|
||||
if not username or not password:
|
||||
raise RuntimeError(
|
||||
"Auth enabled but missing credentials: please set PLEXPLAYLISTSYNC_AUTH_USERNAME and PLEXPLAYLISTSYNC_AUTH_PASSWORD."
|
||||
)
|
||||
|
||||
return AuthConfig(
|
||||
enabled=enabled,
|
||||
username=username,
|
||||
password=password,
|
||||
token_secret=token_secret,
|
||||
token_ttl_seconds=ttl,
|
||||
)
|
||||
|
||||
|
||||
def issue_token(config: AuthConfig, username: str) -> str:
|
||||
now = int(time.time())
|
||||
payload = {"u": username, "exp": now + config.token_ttl_seconds}
|
||||
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
payload_b64 = _b64url_encode(payload_bytes)
|
||||
|
||||
sig = hmac.new(config.token_secret, payload_b64.encode("utf-8"), hashlib.sha256).digest()
|
||||
sig_b64 = _b64url_encode(sig)
|
||||
return f"{payload_b64}.{sig_b64}"
|
||||
|
||||
|
||||
def verify_token(config: AuthConfig, token: str) -> dict | None:
|
||||
try:
|
||||
payload_b64, sig_b64 = token.split(".", 1)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
try:
|
||||
expected_sig = hmac.new(
|
||||
config.token_secret, payload_b64.encode("utf-8"), hashlib.sha256
|
||||
).digest()
|
||||
actual_sig = _b64url_decode(sig_b64)
|
||||
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||
return None
|
||||
|
||||
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
|
||||
exp = int(payload.get("exp", 0))
|
||||
if exp <= int(time.time()):
|
||||
return None
|
||||
if not payload.get("u"):
|
||||
return None
|
||||
return payload
|
||||
except Exception:
|
||||
return None
|
||||
Reference in New Issue
Block a user