security: harden data auth and deployment
This commit is contained in:
+83
-108
@@ -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"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user