Harden auth flows and add backend quality gate
This commit is contained in:
@@ -41,6 +41,7 @@ from ..db import (
|
||||
delete_user_activity_by_username,
|
||||
set_user_auto_search_enabled,
|
||||
set_auto_search_enabled_for_non_admin_users,
|
||||
set_user_email,
|
||||
set_user_invite_management_enabled,
|
||||
set_invite_management_enabled_for_non_admin_users,
|
||||
set_user_profile_id,
|
||||
@@ -78,6 +79,8 @@ from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..services.jellyfin_sync import sync_jellyfin_users
|
||||
from ..services.user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
extract_jellyseerr_user_email,
|
||||
find_matching_jellyseerr_user,
|
||||
get_cached_jellyfin_users,
|
||||
get_cached_jellyseerr_users,
|
||||
match_jellyseerr_user_id,
|
||||
@@ -85,9 +88,11 @@ from ..services.user_cache import (
|
||||
save_jellyseerr_users_cache,
|
||||
clear_user_import_caches,
|
||||
)
|
||||
from ..security import validate_password_policy
|
||||
from ..services.invite_email import (
|
||||
TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS,
|
||||
get_invite_email_templates,
|
||||
normalize_delivery_email,
|
||||
reset_invite_email_template,
|
||||
save_invite_email_template,
|
||||
send_test_email,
|
||||
@@ -106,6 +111,16 @@ events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
||||
logger = logging.getLogger(__name__)
|
||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||
|
||||
|
||||
def _require_recipient_email(value: object) -> str:
|
||||
normalized = normalize_delivery_email(value)
|
||||
if normalized:
|
||||
return normalized
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="recipient_email is required and must be a valid email address",
|
||||
)
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"magent_ssl_certificate_pem",
|
||||
"magent_ssl_private_key_pem",
|
||||
@@ -820,8 +835,12 @@ async def jellyseerr_users_sync() -> Dict[str, Any]:
|
||||
continue
|
||||
username = user.get("username") or ""
|
||||
matched_id = match_jellyseerr_user_id(username, candidate_to_id)
|
||||
matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users)
|
||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
||||
if matched_id is not None:
|
||||
set_user_jellyseerr_id(username, matched_id)
|
||||
if matched_email:
|
||||
set_user_email(username, matched_email)
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
@@ -858,10 +877,12 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
|
||||
username = _pick_jellyseerr_username(user)
|
||||
if not username:
|
||||
continue
|
||||
email = extract_jellyseerr_user_email(user)
|
||||
created = create_user_if_missing(
|
||||
username,
|
||||
"jellyseerr-user",
|
||||
role="user",
|
||||
email=email,
|
||||
auth_provider="jellyseerr",
|
||||
jellyseerr_user_id=user_id,
|
||||
)
|
||||
@@ -869,6 +890,8 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
|
||||
imported += 1
|
||||
else:
|
||||
set_user_jellyseerr_id(username, user_id)
|
||||
if email:
|
||||
set_user_email(username, email)
|
||||
return {"status": "ok", "imported": imported, "cleared": cleared}
|
||||
|
||||
@router.post("/requests/sync")
|
||||
@@ -1458,12 +1481,15 @@ async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@router.post("/users/{username}/password")
|
||||
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
new_password = payload.get("password") if isinstance(payload, dict) else None
|
||||
if not isinstance(new_password, str) or len(new_password.strip()) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
|
||||
if not isinstance(new_password, str):
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
try:
|
||||
new_password_clean = validate_password_policy(new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
new_password_clean = new_password.strip()
|
||||
user = normalize_user_auth_provider(user)
|
||||
auth_provider = resolve_user_auth_provider(user)
|
||||
if auth_provider == "local":
|
||||
@@ -1775,7 +1801,7 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if invite is None:
|
||||
invite = _resolve_user_invite(user)
|
||||
|
||||
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
|
||||
recipient_email = _require_recipient_email(payload.get("recipient_email"))
|
||||
message = _normalize_optional_text(payload.get("message"))
|
||||
reason = _normalize_optional_text(payload.get("reason"))
|
||||
|
||||
@@ -1825,7 +1851,7 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
|
||||
role = _normalize_role_or_none(payload.get("role"))
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
|
||||
recipient_email = _require_recipient_email(payload.get("recipient_email"))
|
||||
send_email = bool(payload.get("send_email"))
|
||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||
try:
|
||||
|
||||
@@ -19,6 +19,7 @@ from ..db import (
|
||||
get_users_by_username_ci,
|
||||
set_user_password,
|
||||
set_user_jellyseerr_id,
|
||||
set_user_email,
|
||||
set_user_auth_provider,
|
||||
get_signup_invite_by_code,
|
||||
get_signup_invite_by_id,
|
||||
@@ -39,17 +40,28 @@ from ..db import (
|
||||
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 (
|
||||
PASSWORD_POLICY_MESSAGE,
|
||||
create_access_token,
|
||||
validate_password_policy,
|
||||
verify_password,
|
||||
)
|
||||
from ..security import create_stream_token
|
||||
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider
|
||||
from ..config import settings
|
||||
from ..services.user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
extract_jellyseerr_user_email,
|
||||
find_matching_jellyseerr_user,
|
||||
get_cached_jellyseerr_users,
|
||||
match_jellyseerr_user_id,
|
||||
save_jellyfin_users_cache,
|
||||
)
|
||||
from ..services.invite_email import send_templated_email, smtp_email_config_ready
|
||||
from ..services.invite_email import (
|
||||
normalize_delivery_email,
|
||||
send_templated_email,
|
||||
smtp_email_config_ready,
|
||||
)
|
||||
from ..services.password_reset import (
|
||||
PasswordResetUnavailableError,
|
||||
apply_password_reset,
|
||||
@@ -68,6 +80,19 @@ PASSWORD_RESET_GENERIC_MESSAGE = (
|
||||
_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:
|
||||
return normalized
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="recipient_email is required and must be a valid email address.",
|
||||
)
|
||||
|
||||
|
||||
def _auth_client_ip(request: Request) -> str:
|
||||
@@ -86,6 +111,10 @@ def _login_rate_key_user(username: str) -> str:
|
||||
return (username or "").strip().lower()[:256] or "<empty>"
|
||||
|
||||
|
||||
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:
|
||||
@@ -171,6 +200,57 @@ 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)
|
||||
|
||||
|
||||
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)
|
||||
if exceeded:
|
||||
logger.warning(
|
||||
"password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
|
||||
identifier_key,
|
||||
ip_key,
|
||||
retry_after,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many password reset attempts. Try again shortly.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
|
||||
def _normalize_username(value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if "@" in normalized:
|
||||
@@ -219,6 +299,13 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_jellyseerr_response_email(response: dict) -> str | None:
|
||||
if not isinstance(response, dict):
|
||||
return None
|
||||
user_payload = response.get("user") if isinstance(response.get("user"), dict) else response
|
||||
return extract_jellyseerr_user_email(user_payload)
|
||||
|
||||
|
||||
def _extract_http_error_detail(exc: Exception) -> str:
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
response = exc.response
|
||||
@@ -569,6 +656,8 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
preferred_match = _pick_preferred_ci_user_match(ci_matches, username)
|
||||
canonical_username = str(preferred_match.get("username") or username) if preferred_match else username
|
||||
user = preferred_match or get_user_by_username(username)
|
||||
matched_seerr_user = find_matching_jellyseerr_user(canonical_username, jellyseerr_users or [])
|
||||
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")
|
||||
@@ -597,7 +686,13 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
_record_login_failure(request, username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||
if not preferred_match:
|
||||
create_user_if_missing(canonical_username, "jellyfin-user", role="user", auth_provider="jellyfin")
|
||||
create_user_if_missing(
|
||||
canonical_username,
|
||||
"jellyfin-user",
|
||||
role="user",
|
||||
email=matched_email,
|
||||
auth_provider="jellyfin",
|
||||
)
|
||||
elif (
|
||||
user
|
||||
and str(user.get("role") or "user").strip().lower() != "admin"
|
||||
@@ -605,6 +700,8 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
):
|
||||
set_user_auth_provider(canonical_username, "jellyfin")
|
||||
user = get_user_by_username(canonical_username)
|
||||
if matched_email:
|
||||
set_user_email(canonical_username, matched_email)
|
||||
user = get_user_by_username(canonical_username)
|
||||
_assert_user_can_login(user)
|
||||
try:
|
||||
@@ -660,6 +757,7 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
||||
_record_login_failure(request, form_data.username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
|
||||
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
|
||||
jellyseerr_email = _extract_jellyseerr_response_email(response)
|
||||
ci_matches = get_users_by_username_ci(form_data.username)
|
||||
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
||||
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
||||
@@ -668,13 +766,22 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
||||
canonical_username,
|
||||
"jellyseerr-user",
|
||||
role="user",
|
||||
email=jellyseerr_email,
|
||||
auth_provider="jellyseerr",
|
||||
jellyseerr_user_id=jellyseerr_user_id,
|
||||
)
|
||||
elif (
|
||||
preferred_match
|
||||
and str(preferred_match.get("role") or "user").strip().lower() != "admin"
|
||||
and str(preferred_match.get("auth_provider") or "local").strip().lower() not in {"jellyfin", "jellyseerr"}
|
||||
):
|
||||
set_user_auth_provider(canonical_username, "jellyseerr")
|
||||
user = get_user_by_username(canonical_username)
|
||||
_assert_user_can_login(user)
|
||||
if jellyseerr_user_id is not None:
|
||||
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")
|
||||
_clear_login_failures(request, form_data.username)
|
||||
set_last_login(canonical_username)
|
||||
@@ -735,11 +842,10 @@ async def signup(payload: dict) -> dict:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
|
||||
if len(password.strip()) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password must be at least 8 characters.",
|
||||
)
|
||||
try:
|
||||
password_value = validate_password_policy(password)
|
||||
except ValueError as exc:
|
||||
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(
|
||||
@@ -786,7 +892,6 @@ async def signup(payload: dict) -> dict:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
password_value = password.strip()
|
||||
auth_provider = "local"
|
||||
local_password_value = password_value
|
||||
matched_jellyseerr_user_id: int | None = None
|
||||
@@ -839,6 +944,7 @@ async def signup(payload: dict) -> dict:
|
||||
username,
|
||||
local_password_value,
|
||||
role=role,
|
||||
email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None,
|
||||
auth_provider=auth_provider,
|
||||
jellyseerr_user_id=matched_jellyseerr_user_id,
|
||||
auto_search_enabled=auto_search_enabled,
|
||||
@@ -901,6 +1007,8 @@ async def forgot_password(payload: dict, request: Request) -> dict:
|
||||
identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
|
||||
if not isinstance(identifier, str) or not identifier.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email is required.")
|
||||
_enforce_password_reset_rate_limit(request, identifier)
|
||||
_record_password_reset_attempt(request, identifier)
|
||||
|
||||
ready, detail = smtp_email_config_ready()
|
||||
if not ready:
|
||||
@@ -960,14 +1068,15 @@ async def password_reset(payload: dict) -> dict:
|
||||
new_password = payload.get("new_password")
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
|
||||
if not isinstance(new_password, str) or len(new_password.strip()) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password must be at least 8 characters.",
|
||||
)
|
||||
if not isinstance(new_password, str):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=PASSWORD_POLICY_MESSAGE)
|
||||
try:
|
||||
new_password_clean = validate_password_policy(new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
result = await apply_password_reset(token.strip(), new_password.strip())
|
||||
result = await apply_password_reset(token.strip(), new_password_clean)
|
||||
except PasswordResetUnavailableError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
@@ -1065,8 +1174,7 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
|
||||
label = str(label).strip() or None
|
||||
if description is not None:
|
||||
description = str(description).strip() or None
|
||||
if recipient_email is not None:
|
||||
recipient_email = str(recipient_email).strip() or None
|
||||
recipient_email = _require_recipient_email(recipient_email)
|
||||
send_email = bool(payload.get("send_email"))
|
||||
delivery_message = str(payload.get("message") or "").strip() or None
|
||||
|
||||
@@ -1156,8 +1264,7 @@ async def update_profile_invite(
|
||||
label = str(label).strip() or None
|
||||
if description is not None:
|
||||
description = str(description).strip() or None
|
||||
if recipient_email is not None:
|
||||
recipient_email = str(recipient_email).strip() or None
|
||||
recipient_email = _require_recipient_email(recipient_email)
|
||||
send_email = bool(payload.get("send_email"))
|
||||
delivery_message = str(payload.get("message") or "").strip() or None
|
||||
|
||||
@@ -1232,14 +1339,13 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
|
||||
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
||||
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
if len(new_password.strip()) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
|
||||
)
|
||||
try:
|
||||
new_password_clean = validate_password_policy(new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||
new_password_clean = new_password.strip()
|
||||
stored_user = normalize_user_auth_provider(get_user_by_username(username))
|
||||
auth_provider = resolve_user_auth_provider(stored_user or current_user)
|
||||
logger.info("password change requested username=%s provider=%s", username, auth_provider)
|
||||
|
||||
Reference in New Issue
Block a user