230 lines
9.4 KiB
Python
230 lines
9.4 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, HTTPException, status, Depends
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
|
|
from ..db import (
|
|
verify_user_password,
|
|
create_user_if_missing,
|
|
set_last_login,
|
|
get_user_by_username,
|
|
set_user_password,
|
|
set_jellyfin_auth_cache,
|
|
set_user_jellyseerr_id,
|
|
get_user_activity,
|
|
get_user_activity_summary,
|
|
get_user_request_stats,
|
|
get_global_request_leader,
|
|
get_global_request_total,
|
|
)
|
|
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 ..auth import get_current_user
|
|
from ..services.user_cache import (
|
|
build_jellyseerr_candidate_map,
|
|
get_cached_jellyseerr_users,
|
|
match_jellyseerr_user_id,
|
|
save_jellyfin_users_cache,
|
|
)
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
|
|
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
|
|
|
|
|
|
@router.post("/login")
|
|
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
|
user = verify_user_password(form_data.username, form_data.password)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
if user.get("is_blocked"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
|
token = create_access_token(user["username"], user["role"])
|
|
set_last_login(user["username"])
|
|
return {
|
|
"access_token": token,
|
|
"token_type": "bearer",
|
|
"user": {"username": user["username"], "role": user["role"]},
|
|
}
|
|
|
|
|
|
@router.post("/jellyfin/login")
|
|
async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
|
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
|
|
user = get_user_by_username(username)
|
|
if user and user.get("is_blocked"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
|
if user and _has_valid_jellyfin_cache(user, password):
|
|
token = create_access_token(username, "user")
|
|
set_last_login(username)
|
|
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
|
|
try:
|
|
response = await client.authenticate_by_name(username, password)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
|
if not isinstance(response, dict) or not response.get("User"):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
|
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
|
|
user = get_user_by_username(username)
|
|
if user and user.get("is_blocked"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
|
try:
|
|
users = await client.get_users()
|
|
if isinstance(users, list):
|
|
save_jellyfin_users_cache(users)
|
|
for jellyfin_user in users:
|
|
if not isinstance(jellyfin_user, dict):
|
|
continue
|
|
name = jellyfin_user.get("Name")
|
|
if isinstance(name, str) and name:
|
|
create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin")
|
|
except Exception:
|
|
pass
|
|
set_jellyfin_auth_cache(username, password)
|
|
if user and user.get("jellyseerr_user_id") is None and candidate_map:
|
|
matched_id = match_jellyseerr_user_id(username, candidate_map)
|
|
if matched_id is not None:
|
|
set_user_jellyseerr_id(username, matched_id)
|
|
token = create_access_token(username, "user")
|
|
set_last_login(username)
|
|
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
|
|
|
|
|
|
@router.post("/jellyseerr/login")
|
|
async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
|
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="Jellyseerr not configured")
|
|
payload = {"email": form_data.username, "password": form_data.password}
|
|
try:
|
|
response = await client.post("/api/v1/auth/login", payload=payload)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
|
if not isinstance(response, dict):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
|
|
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
|
|
create_user_if_missing(
|
|
form_data.username,
|
|
"jellyseerr-user",
|
|
role="user",
|
|
auth_provider="jellyseerr",
|
|
jellyseerr_user_id=jellyseerr_user_id,
|
|
)
|
|
user = get_user_by_username(form_data.username)
|
|
if user and user.get("is_blocked"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
|
if jellyseerr_user_id is not None:
|
|
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
|
|
token = create_access_token(form_data.username, "user")
|
|
set_last_login(form_data.username)
|
|
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
|
|
|
|
|
|
@router.get("/me")
|
|
async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
|
return current_user
|
|
|
|
|
|
@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.post("/password")
|
|
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
|
if current_user.get("auth_provider") != "local":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Password changes are only available for local users.",
|
|
)
|
|
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."
|
|
)
|
|
user = verify_user_password(current_user["username"], current_password)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
|
set_user_password(current_user["username"], new_password.strip())
|
|
return {"status": "ok"}
|