1414 lines
56 KiB
Python
1414 lines
56 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_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 "<empty>"
|
|
|
|
|
|
def _password_reset_rate_key_identifier(identifier: str) -> str:
|
|
return (identifier or "").strip().lower()[:256] or "<empty>"
|
|
|
|
|
|
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
|
|
cutoff = now - window_seconds
|
|
while bucket and bucket[0] < cutoff:
|
|
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.",
|
|
)
|