security: harden data auth and deployment

This commit is contained in:
2026-09-17 18:31:35 +12:00
parent a6d1c73837
commit 5639dbcb83
32 changed files with 1401 additions and 378 deletions
+83 -108
View File
@@ -1,11 +1,8 @@
from ..feature_guards import require_invites
from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque
import logging
import secrets
import string
import time
from threading import Lock
import httpx
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
@@ -28,6 +25,7 @@ from ..db import (
list_signup_invites,
create_signup_invite,
update_signup_invite,
rotate_signup_invite_code,
delete_signup_invite,
reserve_signup_invite_use,
release_signup_invite_use,
@@ -39,6 +37,10 @@ from ..db import (
get_global_request_total,
get_setting,
sync_jellyfin_password_state,
increment_user_auth_version,
get_rate_limit_status,
record_rate_limit_event,
clear_rate_limit_events,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
@@ -87,14 +89,6 @@ PASSWORD_RESET_GENERIC_MESSAGE = (
"If an account exists for that username or email, a password reset link has been sent."
)
_LOGIN_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
_RESET_RATE_LOCK = Lock()
_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque)
def _require_recipient_email(value: object) -> str:
normalized = normalize_delivery_email(value)
if normalized:
@@ -145,12 +139,6 @@ def _password_reset_rate_key_identifier(identifier: str) -> str:
return (identifier 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 _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None:
if not users:
return None
@@ -172,56 +160,33 @@ def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) ->
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)
logger.warning("login failure recorded username=%s client=%s", user_key, ip_key)
record_rate_limit_event("login-ip", ip_key)
record_rate_limit_event("login-user", user_key)
logger.warning("login failure recorded")
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)
clear_rate_limit_events("login-ip", ip_key)
clear_rate_limit_events("login-user", user_key)
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)
ip_exceeded, ip_retry = get_rate_limit_status("login-ip", ip_key, window, max_ip)
user_exceeded, user_retry = get_rate_limit_status("login-user", user_key, window, max_user)
exceeded = ip_exceeded or user_exceeded
retry_after = max(ip_retry if ip_exceeded else 1, user_retry if user_exceeded else 1)
if exceeded:
logger.warning(
"login rate limit exceeded username=%s client=%s retry_after=%s",
user_key,
ip_key,
retry_after,
"login rate limit exceeded retry_after=%s", retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -231,48 +196,28 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None:
def _record_password_reset_attempt(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK:
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
ip_bucket.append(now)
identifier_bucket.append(now)
logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key)
record_rate_limit_event("reset-ip", ip_key)
record_rate_limit_event("reset-identifier", identifier_key)
logger.info("password reset rate event recorded")
def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
now = time.monotonic()
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1)
max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1)
ip_key = _auth_client_ip(request)
identifier_key = _password_reset_rate_key_identifier(identifier)
with _RESET_RATE_LOCK:
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(identifier_bucket, now, window)
exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if identifier_bucket:
retry_candidates.append(max(1, int(window - (now - identifier_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
ip_exceeded, ip_retry = get_rate_limit_status("reset-ip", ip_key, window, max_ip)
identifier_exceeded, identifier_retry = get_rate_limit_status(
"reset-identifier", identifier_key, window, max_identifier
)
exceeded = ip_exceeded or identifier_exceeded
retry_after = max(ip_retry if ip_exceeded else 1, identifier_retry if identifier_exceeded else 1)
if exceeded:
logger.warning(
"password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
identifier_key,
ip_key,
retry_after,
"password reset rate limit exceeded retry_after=%s", retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -400,6 +345,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) -
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return {
"code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"email_bound": bool(invite.get("recipient_email")),
"label": invite.get("label"),
"description": invite.get("description"),
@@ -493,6 +439,7 @@ def _serialize_self_invite(invite: dict) -> dict:
return {
"id": invite.get("id"),
"code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"label": invite.get("label"),
"description": invite.get("description"),
"profile_id": invite.get("profile_id"),
@@ -576,6 +523,7 @@ def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
return {
"id": invite.get("id"),
"code": invite.get("code"),
"code_available": bool(invite.get("code_available")),
"label": invite.get("label"),
"description": invite.get("description"),
"profile_id": invite.get("profile_id"),
@@ -664,7 +612,9 @@ async def login(
detail="This account uses external sign-in. Use the external sign-in option.",
)
_assert_user_can_login(user)
token = create_access_token(user["username"], user["role"])
token = create_access_token(
user["username"], user["role"], auth_version=int(user.get("auth_version") or 1)
)
_clear_login_failures(request, form_data.username)
set_last_login(user["username"])
logger.info(
@@ -708,7 +658,9 @@ async def jellyfin_login(
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
_assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(canonical_username, "user")
token = create_access_token(
canonical_username, "user", auth_version=int(user.get("auth_version") or 1)
)
_clear_login_failures(request, username)
set_last_login(canonical_username)
logger.info(
@@ -775,7 +727,10 @@ async def jellyfin_login(
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
if matched_id is not None:
set_user_jellyseerr_id(canonical_username, matched_id)
token = create_access_token(canonical_username, "user")
refreshed_user = get_user_by_username(canonical_username) or user or {}
token = create_access_token(
canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
)
_clear_login_failures(request, username)
set_last_login(canonical_username)
logger.info(
@@ -851,7 +806,10 @@ async def jellyseerr_login(
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
if jellyseerr_email:
set_user_email(canonical_username, jellyseerr_email)
token = create_access_token(canonical_username, "user")
refreshed_user = get_user_by_username(canonical_username) or user or {}
token = create_access_token(
canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
)
_clear_login_failures(request, form_data.username)
set_last_login(canonical_username)
logger.info(
@@ -873,7 +831,10 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
@router.post("/logout")
async def logout(response: Response) -> dict:
async def logout(
response: Response, current_user: dict = Depends(get_current_user)
) -> dict:
increment_user_auth_version(str(current_user.get("username") or ""))
clear_auth_cookies(response)
return {"status": "ok"}
@@ -884,6 +845,7 @@ async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
current_user["username"],
current_user["role"],
expires_seconds=STREAM_TOKEN_TTL_SECONDS,
auth_version=int(current_user.get("auth_version") or 1),
)
return {
"stream_token": token,
@@ -923,11 +885,7 @@ async def signup(payload: dict, response: Response) -> dict:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
logger.info(
"signup attempt username=%s invite_code=%s",
username,
invite_code,
)
logger.info("signup attempt username=%s", username)
invite = get_signup_invite_by_code(invite_code)
if not invite:
@@ -1039,7 +997,7 @@ async def signup(payload: dict, response: Response) -> dict:
auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
invited_by_code=invite.get("code"),
invited_by_code=f"invite:{invite.get('id')}",
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -1066,15 +1024,18 @@ async def signup(payload: dict, response: Response) -> dict:
# Welcome email delivery is best-effort and must not break signup.
logger.warning("Welcome email send skipped for %s: %s", username, exc)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
refreshed_user = get_user_by_username(username) or created_user or {}
token = create_access_token(
username, role, auth_version=int(refreshed_user.get("auth_version") or 1)
)
set_last_login(username)
logger.info(
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_id=%s",
username,
role,
created_user.get("auth_provider") if created_user else auth_provider,
created_user.get("profile_id") if created_user else None,
invite.get("code"),
invite.get("id"),
)
return _auth_success_response(
response,
@@ -1110,8 +1071,7 @@ async def forgot_password(payload: dict, request: Request) -> dict:
)
client_ip = _auth_client_ip(request)
safe_identifier = identifier.strip().lower()[:256]
logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip)
logger.info("password reset requested")
try:
reset_result = await request_password_reset(
identifier,
@@ -1120,24 +1080,17 @@ async def forgot_password(payload: dict, request: Request) -> dict:
)
if reset_result.get("issued"):
logger.info(
"password reset issued username=%s provider=%s recipient=%s client=%s",
"password reset issued username=%s provider=%s",
reset_result.get("username"),
reset_result.get("auth_provider"),
reset_result.get("recipient_email"),
client_ip,
)
else:
logger.info(
"password reset request completed with no eligible account identifier=%s client=%s",
safe_identifier,
client_ip,
"password reset request completed with no eligible account",
)
except Exception as exc:
logger.warning(
"password reset email dispatch failed identifier=%s client=%s detail=%s",
safe_identifier,
client_ip,
str(exc),
"password reset email dispatch failed detail=%s", type(exc).__name__,
)
return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
@@ -1371,8 +1324,13 @@ async def update_profile_invite(
_require_self_service_invite_access(current_user)
existing = _get_owned_invite(invite_id, current_user)
requested_code = payload.get("code", existing.get("code"))
if isinstance(requested_code, str) and requested_code.strip():
requested_code = payload.get("code")
if (
isinstance(requested_code, str)
and requested_code.strip()
and not requested_code.strip().startswith("••••")
and requested_code.strip() != "Protected invite"
):
code = _normalize_invite_code(requested_code)
else:
code = str(existing.get("code") or "").strip()
@@ -1427,6 +1385,10 @@ async def update_profile_invite(
email_error = None
if send_email:
try:
rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not rotated:
raise ValueError("Invite is unavailable")
invite = rotated
email_result = await send_templated_email(
"invited",
invite=invite,
@@ -1450,6 +1412,18 @@ async def update_profile_invite(
}
@router.post("/profile/invites/{invite_id}/rotate")
async def rotate_profile_invite(
invite_id: int, current_user: dict = Depends(get_current_user)
) -> dict:
_require_self_service_invite_access(current_user)
_get_owned_invite(invite_id, current_user)
invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
if not invite:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite is unavailable")
return {"status": "ok", "invite": _serialize_self_invite(invite)}
@router.delete("/profile/invites/{invite_id}")
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
_require_self_service_invite_access(current_user)
@@ -1531,6 +1505,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
# Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
sync_jellyfin_password_state(username, new_password_clean)
increment_user_auth_version(username)
logger.info("password change completed username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin"}