Files
Magent/backend/app/routers/auth.py

1308 lines
52 KiB
Python

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_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 create_access_token, 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,
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.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)
def _auth_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if isinstance(forwarded, str) and forwarded.strip():
return forwarded.split(",", 1)[0].strip()
real = request.headers.get("x-real-ip")
if isinstance(real, str) and real.strip():
return real.strip()
if request.client and request.client.host:
return str(request.client.host)
return "unknown"
def _login_rate_key_user(username: str) -> str:
return (username or "").strip().lower()[:256] or "<empty>"
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
cutoff = now - window_seconds
while bucket and bucket[0] < cutoff:
bucket.popleft()
def _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 _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_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)
_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", 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)
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)
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",
auth_provider="jellyseerr",
jellyseerr_user_id=jellyseerr_user_id,
)
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)
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")
if len(password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters.",
)
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()
password_value = password.strip()
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,
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.")
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) or len(new_password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters.",
)
try:
result = await apply_password_reset(token.strip(), new_password.strip())
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
if recipient_email is not None:
recipient_email = str(recipient_email).strip() or None
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
if recipient_email is not None:
recipient_email = str(recipient_email).strip() or None
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")
if len(new_password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
)
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)
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.",
)