Build 2602262030: add magent settings and hardening

This commit is contained in:
2026-02-26 20:31:26 +13:00
parent b215e8030c
commit 0b73d9f4ee
16 changed files with 897 additions and 140 deletions

View File

@@ -90,6 +90,14 @@ logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
SENSITIVE_KEYS = {
"magent_ssl_certificate_pem",
"magent_ssl_private_key_pem",
"magent_notify_email_smtp_password",
"magent_notify_discord_webhook_url",
"magent_notify_telegram_bot_token",
"magent_notify_push_token",
"magent_notify_push_user_key",
"magent_notify_webhook_url",
"jellyseerr_api_key",
"jellyfin_api_key",
"sonarr_api_key",
@@ -99,6 +107,11 @@ SENSITIVE_KEYS = {
}
URL_SETTING_KEYS = {
"magent_application_url",
"magent_api_url",
"magent_proxy_base_url",
"magent_notify_discord_webhook_url",
"magent_notify_push_base_url",
"jellyseerr_base_url",
"jellyfin_base_url",
"jellyfin_public_url",
@@ -109,6 +122,44 @@ URL_SETTING_KEYS = {
}
SETTING_KEYS: List[str] = [
"magent_application_url",
"magent_application_port",
"magent_api_url",
"magent_api_port",
"magent_bind_host",
"magent_proxy_enabled",
"magent_proxy_base_url",
"magent_proxy_trust_forwarded_headers",
"magent_proxy_forwarded_prefix",
"magent_ssl_bind_enabled",
"magent_ssl_certificate_path",
"magent_ssl_private_key_path",
"magent_ssl_certificate_pem",
"magent_ssl_private_key_pem",
"magent_notify_enabled",
"magent_notify_email_enabled",
"magent_notify_email_smtp_host",
"magent_notify_email_smtp_port",
"magent_notify_email_smtp_username",
"magent_notify_email_smtp_password",
"magent_notify_email_from_address",
"magent_notify_email_from_name",
"magent_notify_email_use_tls",
"magent_notify_email_use_ssl",
"magent_notify_discord_enabled",
"magent_notify_discord_webhook_url",
"magent_notify_telegram_enabled",
"magent_notify_telegram_bot_token",
"magent_notify_telegram_chat_id",
"magent_notify_push_enabled",
"magent_notify_push_provider",
"magent_notify_push_base_url",
"magent_notify_push_topic",
"magent_notify_push_token",
"magent_notify_push_user_key",
"magent_notify_push_device",
"magent_notify_webhook_enabled",
"magent_notify_webhook_url",
"jellyseerr_base_url",
"jellyseerr_api_key",
"jellyfin_base_url",

View File

@@ -1,9 +1,12 @@
from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque
import secrets
import string
import time
from threading import Lock
import httpx
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
from ..db import (
@@ -34,7 +37,9 @@ from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token, verify_password
from ..security import create_stream_token
from ..auth import get_current_user
from ..config import settings
from ..services.user_cache import (
build_jellyseerr_candidate_map,
get_cached_jellyseerr_users,
@@ -44,6 +49,85 @@ from ..services.user_cache import (
router = APIRouter(prefix="/auth", tags=["auth"])
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
STREAM_TOKEN_TTL_SECONDS = 120
_LOGIN_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
def _auth_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if isinstance(forwarded, str) and forwarded.strip():
return forwarded.split(",", 1)[0].strip()
real = request.headers.get("x-real-ip")
if isinstance(real, str) and real.strip():
return real.strip()
if request.client and request.client.host:
return str(request.client.host)
return "unknown"
def _login_rate_key_user(username: str) -> str:
return (username or "").strip().lower()[:256] or "<empty>"
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
cutoff = now - window_seconds
while bucket and bucket[0] < cutoff:
bucket.popleft()
def _record_login_failure(request: Request, username: str) -> None:
now = time.monotonic()
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(user_bucket, now, window)
ip_bucket.append(now)
user_bucket.append(now)
def _clear_login_failures(request: Request, username: str) -> None:
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
_LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None)
_LOGIN_ATTEMPTS_BY_USER.pop(user_key, None)
def _enforce_login_rate_limit(request: Request, username: str) -> None:
now = time.monotonic()
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1)
max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1)
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(user_bucket, now, window)
exceeded = len(ip_bucket) >= max_ip or len(user_bucket) >= max_user
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if user_bucket:
retry_candidates.append(max(1, int(window - (now - user_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
if exceeded:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again shortly.",
headers={"Retry-After": str(retry_after)},
)
def _normalize_username(value: str) -> str:
@@ -361,9 +445,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
user = verify_user_password(form_data.username, form_data.password)
if not user:
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("auth_provider") != "local":
raise HTTPException(
@@ -372,6 +458,7 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
)
_assert_user_can_login(user)
token = create_access_token(user["username"], user["role"])
_clear_login_failures(request, form_data.username)
set_last_login(user["username"])
return {
"access_token": token,
@@ -381,7 +468,8 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
@router.post("/jellyfin/login")
async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
@@ -394,6 +482,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
_assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(username, "user")
_clear_login_failures(request, username)
set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
try:
@@ -401,6 +490,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"):
_record_login_failure(request, username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(username)
@@ -423,12 +513,14 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
if matched_id is not None:
set_user_jellyseerr_id(username, matched_id)
token = create_access_token(username, "user")
_clear_login_failures(request, username)
set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
@router.post("/jellyseerr/login")
async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
@@ -439,6 +531,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict):
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
create_user_if_missing(
@@ -453,6 +546,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
if jellyseerr_user_id is not None:
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
token = create_access_token(form_data.username, "user")
_clear_login_failures(request, form_data.username)
set_last_login(form_data.username)
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
@@ -462,6 +556,20 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user
@router.get("/stream-token")
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
token = create_stream_token(
current_user["username"],
current_user["role"],
expires_seconds=STREAM_TOKEN_TTL_SECONDS,
)
return {
"stream_token": token,
"token_type": "bearer",
"expires_in": STREAM_TOKEN_TTL_SECONDS,
}
@router.get("/invites/{code}")
async def invite_details(code: str) -> dict:
invite = get_signup_invite_by_code(code.strip())

View File

@@ -11,7 +11,10 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
@router.post("")
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
webhook_url = runtime.discord_webhook_url
webhook_url = (
getattr(runtime, "magent_notify_discord_webhook_url", None)
or runtime.discord_webhook_url
)
if not webhook_url:
raise HTTPException(status_code=400, detail="Discord webhook not configured")