Harden auth and outbound admin surfaces
This commit is contained in:
+81
-45
@@ -7,7 +7,7 @@ import time
|
||||
from threading import Lock
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from ..db import (
|
||||
@@ -47,8 +47,15 @@ from ..security import (
|
||||
verify_password,
|
||||
)
|
||||
from ..security import create_stream_token
|
||||
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider
|
||||
from ..auth import (
|
||||
clear_auth_cookies,
|
||||
get_current_user,
|
||||
normalize_user_auth_provider,
|
||||
resolve_user_auth_provider,
|
||||
set_auth_cookies,
|
||||
)
|
||||
from ..config import settings
|
||||
from ..network_security import request_trusts_forwarded_headers
|
||||
from ..services.user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
extract_jellyseerr_user_email,
|
||||
@@ -96,12 +103,14 @@ def _require_recipient_email(value: object) -> str:
|
||||
|
||||
|
||||
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()
|
||||
direct_host = request.client.host if request.client else None
|
||||
if request_trusts_forwarded_headers(direct_host):
|
||||
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"
|
||||
@@ -358,6 +367,15 @@ def _assert_user_can_login(user: dict | None) -> None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
||||
|
||||
|
||||
def _auth_success_response(response: Response, token: str, user_payload: dict) -> dict:
|
||||
set_auth_cookies(response, token)
|
||||
return {
|
||||
"authenticated": True,
|
||||
"token_type": "cookie",
|
||||
"user": user_payload,
|
||||
}
|
||||
|
||||
|
||||
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
||||
return {
|
||||
"code": invite.get("code"),
|
||||
@@ -580,7 +598,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
async def login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=local username=%s client=%s",
|
||||
@@ -629,15 +651,19 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
|
||||
user["role"],
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": user["username"], "role": user["role"]},
|
||||
}
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": user["username"], "role": user["role"]},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/jellyfin/login")
|
||||
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
async def jellyfin_login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=jellyfin username=%s client=%s",
|
||||
@@ -668,13 +694,13 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
canonical_username,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
try:
|
||||
response = await client.authenticate_by_name(username, password)
|
||||
auth_response = await client.authenticate_by_name(username, password)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"login upstream error provider=jellyfin username=%s client=%s",
|
||||
@@ -682,7 +708,7 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
_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"):
|
||||
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||
_record_login_failure(request, username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||
if not preferred_match:
|
||||
@@ -724,16 +750,20 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
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"},
|
||||
}
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/seerr/login")
|
||||
@router.post("/jellyseerr/login")
|
||||
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
async def jellyseerr_login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=seerr username=%s client=%s",
|
||||
@@ -745,7 +775,7 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
||||
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)
|
||||
auth_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",
|
||||
@@ -753,11 +783,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(response, dict):
|
||||
if not isinstance(auth_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)
|
||||
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
|
||||
jellyseerr_email = _extract_jellyseerr_response_email(auth_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
|
||||
@@ -791,11 +821,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
||||
jellyseerr_user_id,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": canonical_username, "role": "user"},
|
||||
}
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{"username": canonical_username, "role": "user"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@@ -803,6 +833,12 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response) -> dict:
|
||||
clear_auth_cookies(response)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/stream-token")
|
||||
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
token = create_stream_token(
|
||||
@@ -832,7 +868,7 @@ async def invite_details(code: str) -> dict:
|
||||
|
||||
|
||||
@router.post("/signup")
|
||||
async def signup(payload: dict) -> dict:
|
||||
async def signup(payload: dict, response: Response) -> 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()
|
||||
@@ -908,14 +944,14 @@ async def signup(payload: dict) -> dict:
|
||||
duplicate_like = status_code in {400, 409}
|
||||
if duplicate_like:
|
||||
try:
|
||||
response = await jellyfin_client.authenticate_by_name(username, password_value)
|
||||
auth_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"):
|
||||
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Jellyfin account already exists for that username.",
|
||||
@@ -987,17 +1023,17 @@ async def signup(payload: dict) -> dict:
|
||||
created_user.get("profile_id") if created_user else None,
|
||||
invite.get("code"),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
return _auth_success_response(
|
||||
response,
|
||||
token,
|
||||
{
|
||||
"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")
|
||||
|
||||
Reference in New Issue
Block a user