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 from fastapi.security import OAuth2PasswordRequestForm from ..db import ( verify_user_password, create_user, create_user_if_missing, set_last_login, get_user_by_username, 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, list_signup_invites, create_signup_invite, update_signup_invite, delete_signup_invite, increment_signup_invite_use, get_user_profile, get_user_activity, get_user_activity_summary, get_user_request_stats, get_global_request_leader, get_global_request_total, get_setting, sync_jellyfin_password_state, ) from ..runtime import get_runtime_settings from ..clients.jellyfin import JellyfinClient from ..clients.jellyseerr import JellyseerrClient 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 ( normalize_delivery_email, send_templated_email, smtp_email_config_ready, ) from ..services.password_reset import ( PasswordResetUnavailableError, apply_password_reset, request_password_reset, verify_password_reset_token, ) router = APIRouter(prefix="/auth", tags=["auth"]) logger = logging.getLogger(__name__) SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" STREAM_TOKEN_TTL_SECONDS = 120 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: 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: 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 "" def _password_reset_rate_key_identifier(identifier: str) -> str: return (identifier or "").strip().lower()[:256] or "" 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 requested = (requested_username or "").strip() requested_lower = requested.lower() def _rank(user: dict) -> tuple[int, int, int, int]: provider = str(user.get("auth_provider") or "local").strip().lower() role = str(user.get("role") or "user").strip().lower() username = str(user.get("username") or "") return ( 0 if role == "admin" else 1, 0 if isinstance(user.get("jellyseerr_user_id"), int) else 1, 0 if provider == "jellyfin" else (1 if provider == "local" else (2 if provider == "jellyseerr" else 3)), 0 if username.lower() == requested_lower else 1, ) return sorted(users, key=_rank)[0] 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) 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: logger.warning( "login rate limit exceeded username=%s client=%s retry_after=%s", user_key, ip_key, retry_after, ) 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 _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: normalized = normalized.split("@", 1)[0] return normalized def _is_recent_jellyfin_auth(last_auth_at: str) -> bool: if not last_auth_at: return False try: parsed = datetime.fromisoformat(last_auth_at) except ValueError: return False if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) age = datetime.now(timezone.utc) - parsed return age <= timedelta(days=7) def _has_valid_jellyfin_cache(user: dict, password: str) -> bool: if not user or not password: return False cached_hash = user.get("jellyfin_password_hash") last_auth_at = user.get("last_jellyfin_auth_at") if not cached_hash or not last_auth_at: return False if not verify_password(password, cached_hash): return False return _is_recent_jellyfin_auth(last_auth_at) def _extract_jellyseerr_user_id(response: dict) -> int | None: if not isinstance(response, dict): return None candidate = response if isinstance(response.get("user"), dict): candidate = response.get("user") for key in ("id", "userId", "Id"): value = candidate.get(key) if isinstance(candidate, dict) else None if value is None: continue try: return int(value) except (TypeError, ValueError): continue 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 try: text = response.text.strip() except Exception: text = "" if text: return text return f"HTTP {response.status_code}" return str(exc) def _requested_user_agent(request: Request) -> str: user_agent = request.headers.get("user-agent", "") return user_agent[:512] async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None: try: users = await client.get_users() if isinstance(users, list): save_jellyfin_users_cache(users) except Exception: # Cache refresh is best-effort and should not block auth/signup. return def _is_user_expired(user: dict | None) -> bool: if not user: return False expires_at = user.get("expires_at") if not expires_at: return False try: parsed = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00")) except ValueError: return False if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed <= datetime.now(timezone.utc) def _assert_user_can_login(user: dict | None) -> None: if not user: return if user.get("is_blocked"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") if _is_user_expired(user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired") def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict: return { "code": invite.get("code"), "label": invite.get("label"), "description": invite.get("description"), "enabled": bool(invite.get("enabled")), "expires_at": invite.get("expires_at"), "max_uses": invite.get("max_uses"), "use_count": invite.get("use_count", 0), "remaining_uses": invite.get("remaining_uses"), "is_expired": bool(invite.get("is_expired")), "is_usable": bool(invite.get("is_usable")), "profile": ( { "id": profile.get("id"), "name": profile.get("name"), "description": profile.get("description"), } if profile else None ), } def _parse_optional_positive_int(value: object, field_name: str) -> int | None: if value is None or value == "": return None try: parsed = int(value) except (TypeError, ValueError) as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a number") from exc if parsed <= 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be greater than 0", ) return parsed def _parse_optional_expires_at(value: object) -> str | None: if value is None or value == "": return None if not isinstance(value, str): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="expires_at must be an ISO datetime string", ) candidate = value.strip() if not candidate: return None try: parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00")) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="expires_at must be a valid ISO datetime", ) from exc if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed.isoformat() def _normalize_invite_code(value: str | None) -> str: raw = (value or "").strip().upper() filtered = "".join(ch for ch in raw if ch.isalnum()) if len(filtered) < 6: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code must be at least 6 letters/numbers.", ) return filtered def _generate_invite_code(length: int = 12) -> str: alphabet = string.ascii_uppercase + string.digits return "".join(secrets.choice(alphabet) for _ in range(length)) def _same_username(a: object, b: object) -> bool: if not isinstance(a, str) or not isinstance(b, str): return False return a.strip().lower() == b.strip().lower() def _serialize_self_invite(invite: dict) -> dict: profile = None profile_id = invite.get("profile_id") if profile_id is not None: try: profile = get_user_profile(int(profile_id)) except Exception: profile = None return { "id": invite.get("id"), "code": invite.get("code"), "label": invite.get("label"), "description": invite.get("description"), "profile_id": invite.get("profile_id"), "profile": ( {"id": profile.get("id"), "name": profile.get("name")} if isinstance(profile, dict) else None ), "role": invite.get("role"), "max_uses": invite.get("max_uses"), "use_count": invite.get("use_count", 0), "remaining_uses": invite.get("remaining_uses"), "enabled": bool(invite.get("enabled")), "expires_at": invite.get("expires_at"), "recipient_email": invite.get("recipient_email"), "is_expired": bool(invite.get("is_expired")), "is_usable": bool(invite.get("is_usable")), "created_at": invite.get("created_at"), "updated_at": invite.get("updated_at"), "created_by": invite.get("created_by"), } def _current_user_invites(username: str) -> list[dict]: owned = [ invite for invite in list_signup_invites() if _same_username(invite.get("created_by"), username) ] owned.sort(key=lambda item: (str(item.get("created_at") or ""), int(item.get("id") or 0)), reverse=True) return owned def _get_owned_invite(invite_id: int, current_user: dict) -> dict: invite = get_signup_invite_by_id(invite_id) if not invite: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") if not _same_username(invite.get("created_by"), current_user.get("username")): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only manage your own invites") return invite def _self_service_invite_access_enabled(current_user: dict) -> bool: if str(current_user.get("role") or "").lower() == "admin": return True return bool(current_user.get("invite_management_enabled", False)) def _require_self_service_invite_access(current_user: dict) -> None: if _self_service_invite_access_enabled(current_user): return raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invite management is not enabled for your account.", ) def _get_self_service_master_invite() -> dict | None: raw_value = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY) if raw_value is None: return None candidate = str(raw_value).strip() if not candidate: return None try: invite_id = int(candidate) except (TypeError, ValueError): return None if invite_id <= 0: return None return get_signup_invite_by_id(invite_id) def _serialize_self_service_master_invite(invite: dict | None) -> dict | None: if not isinstance(invite, dict): return None profile = None profile_id = invite.get("profile_id") if isinstance(profile_id, int): profile = get_user_profile(profile_id) return { "id": invite.get("id"), "code": invite.get("code"), "label": invite.get("label"), "description": invite.get("description"), "profile_id": invite.get("profile_id"), "recipient_email": invite.get("recipient_email"), "profile": ( {"id": profile.get("id"), "name": profile.get("name")} if isinstance(profile, dict) else None ), "role": invite.get("role"), "max_uses": invite.get("max_uses"), "enabled": bool(invite.get("enabled")), "expires_at": invite.get("expires_at"), "is_expired": bool(invite.get("is_expired")), "is_usable": bool(invite.get("is_usable")), "created_at": invite.get("created_at"), "updated_at": invite.get("updated_at"), } def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, str, int | None, bool, str | None]: profile_id_raw = master_invite.get("profile_id") profile_id: int | None = None if isinstance(profile_id_raw, int): profile_id = profile_id_raw elif profile_id_raw not in (None, ""): try: profile_id = int(profile_id_raw) except (TypeError, ValueError): profile_id = None role_value = str(master_invite.get("role") or "").strip().lower() role = role_value if role_value in {"user", "admin"} else "user" max_uses_raw = master_invite.get("max_uses") try: max_uses = int(max_uses_raw) if max_uses_raw is not None else None except (TypeError, ValueError): max_uses = None enabled = bool(master_invite.get("enabled", True)) expires_at_value = master_invite.get("expires_at") expires_at = str(expires_at_value).strip() if isinstance(expires_at_value, str) and str(expires_at_value).strip() else None return profile_id, role, max_uses, enabled, expires_at @router.post("/login") async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: _enforce_login_rate_limit(request, form_data.username) logger.info( "login attempt provider=local username=%s client=%s", _login_rate_key_user(form_data.username), _auth_client_ip(request), ) # Provider placeholder passwords must never be accepted by the local-login endpoint. if form_data.password in {"jellyfin-user", "jellyseerr-user"}: _record_login_failure(request, form_data.username) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") matching_users = get_users_by_username_ci(form_data.username) has_external_match = any( str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users ) if has_external_match: logger.warning( "login rejected provider=local username=%s reason=external-account client=%s", _login_rate_key_user(form_data.username), _auth_client_ip(request), ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="This account uses external sign-in. Use the external sign-in option.", ) 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": logger.warning( "login rejected provider=local username=%s reason=wrong-provider client=%s", _login_rate_key_user(form_data.username), _auth_client_ip(request), ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, 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"]) _clear_login_failures(request, form_data.username) set_last_login(user["username"]) logger.info( "login success provider=local username=%s role=%s client=%s", user["username"], user["role"], _auth_client_ip(request), ) return { "access_token": token, "token_type": "bearer", "user": {"username": user["username"], "role": user["role"]}, } @router.post("/jellyfin/login") async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: _enforce_login_rate_limit(request, form_data.username) logger.info( "login attempt provider=jellyfin username=%s client=%s", _login_rate_key_user(form_data.username), _auth_client_ip(request), ) runtime = get_runtime_settings() client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not client.configured(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured") jellyseerr_users = get_cached_jellyseerr_users() candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) username = form_data.username password = form_data.password ci_matches = get_users_by_username_ci(username) 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") _clear_login_failures(request, username) set_last_login(canonical_username) logger.info( "login success provider=jellyfin username=%s source=cache client=%s", canonical_username, _auth_client_ip(request), ) return { "access_token": token, "token_type": "bearer", "user": {"username": canonical_username, "role": "user"}, } try: response = await client.authenticate_by_name(username, password) except Exception as exc: logger.exception( "login upstream error provider=jellyfin username=%s client=%s", _login_rate_key_user(username), _auth_client_ip(request), ) 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") if not preferred_match: 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" and str(user.get("auth_provider") or "local").strip().lower() != "jellyfin" ): 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: users = await client.get_users() if isinstance(users, list): save_jellyfin_users_cache(users) except Exception: pass sync_jellyfin_password_state(canonical_username, password) if user and user.get("jellyseerr_user_id") is None and candidate_map: 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") _clear_login_failures(request, username) set_last_login(canonical_username) logger.info( "login success provider=jellyfin username=%s linked_seerr_id=%s client=%s", canonical_username, get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None, _auth_client_ip(request), ) return { "access_token": token, "token_type": "bearer", "user": {"username": canonical_username, "role": "user"}, } @router.post("/seerr/login") @router.post("/jellyseerr/login") async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: _enforce_login_rate_limit(request, form_data.username) logger.info( "login attempt provider=seerr username=%s client=%s", _login_rate_key_user(form_data.username), _auth_client_ip(request), ) runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) if not client.configured(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured") try: response = await client.login_local(form_data.username, form_data.password) except Exception as exc: logger.exception( "login upstream error provider=seerr username=%s client=%s", _login_rate_key_user(form_data.username), _auth_client_ip(request), ) 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 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 if not preferred_match: create_user_if_missing( 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) logger.info( "login success provider=seerr username=%s seerr_user_id=%s client=%s", canonical_username, jellyseerr_user_id, _auth_client_ip(request), ) return { "access_token": token, "token_type": "bearer", "user": {"username": canonical_username, "role": "user"}, } @router.get("/me") 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()) if not invite: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") profile = None profile_id = invite.get("profile_id") if profile_id is not None: profile = get_user_profile(int(profile_id)) if profile and not profile.get("is_active", True): invite = {**invite, "is_usable": False} return {"invite": _public_invite_payload(invite, profile)} @router.post("/signup") async def signup(payload: dict) -> dict: if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") invite_code = str(payload.get("invite_code") or "").strip() username = str(payload.get("username") or "").strip() password = str(payload.get("password") or "") if not invite_code: 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") 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( "signup attempt username=%s invite_code=%s", username, invite_code, ) invite = get_signup_invite_by_code(invite_code) if not invite: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") if not invite.get("enabled"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite is disabled") if invite.get("is_expired"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has expired") remaining_uses = invite.get("remaining_uses") if remaining_uses is not None and int(remaining_uses) <= 0: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses") profile = None profile_id = invite.get("profile_id") if profile_id is not None: profile = get_user_profile(int(profile_id)) if not profile: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite profile not found") if not profile.get("is_active", True): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite profile is disabled") invite_role = invite.get("role") profile_role = profile.get("role") if profile else None role = invite_role if invite_role in {"user", "admin"} else profile_role if role not in {"user", "admin"}: role = "user" auto_search_enabled = ( bool(profile.get("auto_search_enabled", True)) if profile is not None else True ) expires_at = None account_expires_days = profile.get("account_expires_days") if profile else None if isinstance(account_expires_days, int) and account_expires_days > 0: expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat() runtime = get_runtime_settings() auth_provider = "local" local_password_value = password_value matched_jellyseerr_user_id: int | None = None jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if jellyfin_client.configured(): logger.info("signup provisioning jellyfin username=%s", username) auth_provider = "jellyfin" local_password_value = password_value try: await jellyfin_client.create_user_with_password(username, password_value) except httpx.HTTPStatusError as exc: status_code = exc.response.status_code if exc.response is not None else None duplicate_like = status_code in {400, 409} if duplicate_like: try: response = await jellyfin_client.authenticate_by_name(username, password_value) except Exception as auth_exc: detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc) raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Jellyfin account already exists and could not be authenticated: {detail}", ) from exc if not isinstance(response, dict) or not response.get("User"): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Jellyfin account already exists for that username.", ) from exc else: detail = _extract_http_error_detail(exc) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Jellyfin account provisioning failed: {detail}", ) from exc except Exception as exc: detail = _extract_http_error_detail(exc) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Jellyfin account provisioning failed: {detail}", ) from exc await _refresh_jellyfin_user_cache(jellyfin_client) jellyseerr_users = get_cached_jellyseerr_users() candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) if candidate_map: matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map) try: create_user( 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, profile_id=int(profile_id) if profile_id is not None else None, expires_at=expires_at, invited_by_code=invite.get("code"), ) except Exception as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc increment_signup_invite_use(int(invite["id"])) created_user = get_user_by_username(username) if auth_provider == "jellyfin": sync_jellyfin_password_state(username, password_value) if ( created_user and created_user.get("jellyseerr_user_id") is None and matched_jellyseerr_user_id is not None ): set_user_jellyseerr_id(username, matched_jellyseerr_user_id) created_user = get_user_by_username(username) if created_user: try: await send_templated_email( "welcome", invite=invite, user=created_user, ) except Exception as exc: # 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) set_last_login(username) logger.info( "signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%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"), ) return { "access_token": token, "token_type": "bearer", "user": { "username": username, "role": role, "auth_provider": created_user.get("auth_provider") if created_user else auth_provider, "profile_id": created_user.get("profile_id") if created_user else None, "expires_at": created_user.get("expires_at") if created_user else None, }, } @router.post("/password/forgot") async def forgot_password(payload: dict, request: Request) -> dict: if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") 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: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Password reset email is unavailable: {detail}", ) 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) try: reset_result = await request_password_reset( identifier, requested_by_ip=client_ip, requested_user_agent=_requested_user_agent(request), ) if reset_result.get("issued"): logger.info( "password reset issued username=%s provider=%s recipient=%s client=%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, ) except Exception as exc: logger.warning( "password reset email dispatch failed identifier=%s client=%s detail=%s", safe_identifier, client_ip, str(exc), ) return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE} @router.get("/password/reset/verify") async def password_reset_verify(token: str) -> dict: if not isinstance(token, str) or not token.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.") try: return verify_password_reset_token(token.strip()) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @router.post("/password/reset") async def password_reset(payload: dict) -> dict: if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") token = payload.get("token") 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): 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_clean) except PasswordResetUnavailableError as exc: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc except Exception as exc: detail = _extract_http_error_detail(exc) logger.warning("password reset failed token_present=%s detail=%s", bool(token), detail) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Password reset failed: {detail}", ) from exc logger.info( "password reset completed username=%s provider=%s", result.get("username"), result.get("provider"), ) return result @router.get("/profile") async def profile(current_user: dict = Depends(get_current_user)) -> dict: username = current_user.get("username") or "" username_norm = _normalize_username(username) if username else "" stats = get_user_request_stats(username_norm, current_user.get("jellyseerr_user_id")) global_total = get_global_request_total() share = (stats.get("total", 0) / global_total) if global_total else 0 activity_summary = get_user_activity_summary(username) if username else {} activity_recent = get_user_activity(username, limit=5) if username else [] stats_payload = { **stats, "share": share, "global_total": global_total, } if current_user.get("role") == "admin": stats_payload["most_active_user"] = get_global_request_leader() return { "user": current_user, "stats": stats_payload, "activity": { **activity_summary, "recent": activity_recent, }, } @router.get("/profile/invites") async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict: username = str(current_user.get("username") or "").strip() if not username: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user") master_invite = _get_self_service_master_invite() invite_access_enabled = _self_service_invite_access_enabled(current_user) invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)] return { "invites": invites, "count": len(invites), "invite_access": { "enabled": invite_access_enabled, "managed_by_master": bool(master_invite), }, "master_invite": _serialize_self_service_master_invite(master_invite), } @router.post("/profile/invites") async def create_profile_invite(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") _require_self_service_invite_access(current_user) username = str(current_user.get("username") or "").strip() if not username: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user") requested_code = payload.get("code") if isinstance(requested_code, str) and requested_code.strip(): code = _normalize_invite_code(requested_code) existing = get_signup_invite_by_code(code) if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists") else: code = "" for _ in range(20): candidate = _generate_invite_code() if not get_signup_invite_by_code(candidate): code = candidate break if not code: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not generate invite code") label = payload.get("label") description = payload.get("description") recipient_email = payload.get("recipient_email") if label is not None: label = str(label).strip() or None if description is not None: description = str(description).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 master_invite = _get_self_service_master_invite() if master_invite: if not bool(master_invite.get("enabled")) or bool(master_invite.get("is_expired")) or master_invite.get("is_usable") is False: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Self-service invites are temporarily unavailable (master invite template is disabled or expired).", ) profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite) if profile_id is not None and not get_user_profile(profile_id): profile_id = None role = "user" else: max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") expires_at = _parse_optional_expires_at(payload.get("expires_at")) enabled = bool(payload.get("enabled", True)) profile_id = current_user.get("profile_id") if not isinstance(profile_id, int) or profile_id <= 0: profile_id = None role = "user" invite = create_signup_invite( code=code, label=label, description=description, profile_id=profile_id, role=role, max_uses=max_uses, enabled=enabled, expires_at=expires_at, recipient_email=recipient_email, created_by=username, ) email_result = None email_error = None if send_email: try: email_result = await send_templated_email( "invited", invite=invite, user=current_user, recipient_email=recipient_email, message=delivery_message, ) except Exception as exc: email_error = str(exc) status_value = "partial" if email_error else "ok" return { "status": status_value, "invite": _serialize_self_invite(invite), "email": ( {"status": "ok", **email_result} if email_result else {"status": "error", "detail": email_error} if email_error else None ), } @router.put("/profile/invites/{invite_id}") async def update_profile_invite( invite_id: int, payload: dict, current_user: dict = Depends(get_current_user) ) -> dict: if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") _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(): code = _normalize_invite_code(requested_code) else: code = str(existing.get("code") or "").strip() if not code: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required") duplicate = get_signup_invite_by_code(code) if duplicate and int(duplicate.get("id") or 0) != int(existing.get("id") or 0): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists") label = payload.get("label", existing.get("label")) description = payload.get("description", existing.get("description")) recipient_email = payload.get("recipient_email", existing.get("recipient_email")) if label is not None: label = str(label).strip() or None if description is not None: description = str(description).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 master_invite = _get_self_service_master_invite() if master_invite: profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite) if profile_id is not None and not get_user_profile(profile_id): profile_id = None role = "user" else: max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses") expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at"))) enabled_raw = payload.get("enabled", existing.get("enabled")) enabled = bool(enabled_raw) profile_id = existing.get("profile_id") role = existing.get("role") invite = update_signup_invite( invite_id, code=code, label=label, description=description, profile_id=profile_id, role=role, max_uses=max_uses, enabled=enabled, expires_at=expires_at, recipient_email=recipient_email, ) if not invite: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") email_result = None email_error = None if send_email: try: email_result = await send_templated_email( "invited", invite=invite, user=current_user, recipient_email=recipient_email, message=delivery_message, ) except Exception as exc: email_error = str(exc) status_value = "partial" if email_error else "ok" return { "status": status_value, "invite": _serialize_self_invite(invite), "email": ( {"status": "ok", **email_result} if email_result else {"status": "error", "detail": email_error} if email_error else None ), } @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) _get_owned_invite(invite_id, current_user) deleted = delete_signup_invite(invite_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") return {"status": "ok"} @router.post("/password") async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: current_password = payload.get("current_password") if isinstance(payload, dict) else None 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") 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") 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) if auth_provider == "local": user = verify_user_password(username, current_password) if not user: logger.warning("password change rejected username=%s provider=local reason=invalid-current-password", username) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect") set_user_password(username, new_password_clean) logger.info("password change completed username=%s provider=local", username) return {"status": "ok", "provider": "local"} if auth_provider == "jellyfin": runtime = get_runtime_settings() client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not client.configured(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin is not configured for password passthrough.", ) try: auth_result = await client.authenticate_by_name(username, current_password) if not isinstance(auth_result, dict) or not auth_result.get("User"): logger.warning("password change rejected username=%s provider=jellyfin reason=invalid-current-password", username) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect" ) except HTTPException: raise except Exception as exc: detail = _extract_http_error_detail(exc) logger.warning("password change validation failed username=%s provider=jellyfin detail=%s", username, detail) if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect" ) from exc raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Jellyfin password validation failed: {detail}", ) from exc try: jf_user = await client.find_user_by_name(username) user_id = client._extract_user_id(jf_user) if not user_id: raise RuntimeError("Jellyfin user ID not found") await client.set_user_password(user_id, new_password_clean) except Exception as exc: detail = _extract_http_error_detail(exc) logger.warning("password change update failed username=%s provider=jellyfin detail=%s", username, detail) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Jellyfin password update failed: {detail}", ) from exc # Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin. sync_jellyfin_password_state(username, new_password_clean) logger.info("password change completed username=%s provider=jellyfin", username) return {"status": "ok", "provider": "jellyfin"} raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Password changes are not available for this sign-in provider.", )