Implement Bellhop: Matrix-authenticated *arr request portal
- FastAPI backend with async SQLite session storage - Matrix homeserver authentication (login/logout/session validation via whoami) - Server-side proxy for Radarr, Sonarr, and Lidarr search & add APIs - Fire-and-forget Matrix audit log bot for request tracking - Rate-limited login endpoint (5 req/min per IP via slowapi) - Single-page Alpine.js frontend with dark theme, media type tabs, search grid - Dockerfile for single-container deployment - Secure session cookies (httponly, samesite=strict, secure) https://claude.ai/code/session_018iMt5qm4j9bk8wzytLC3E6
This commit is contained in:
131
app/auth.py
Normal file
131
app/auth.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import MATRIX_HOMESERVER_URL
|
||||
from app.database import create_session, delete_session, get_session
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
SESSION_COOKIE = "bellhop_session"
|
||||
MAX_SESSION_AGE = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(request: Request) -> Response:
|
||||
body = await request.json()
|
||||
username: str = body.get("username", "").strip()
|
||||
password: str = body.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
return JSONResponse({"error": "Username and password are required"}, status_code=400)
|
||||
|
||||
# Build the Matrix user identifier
|
||||
if username.startswith("@"):
|
||||
user_identifier = username
|
||||
else:
|
||||
user_identifier = username # let the homeserver resolve it
|
||||
|
||||
login_url = f"{MATRIX_HOMESERVER_URL.rstrip('/')}/_matrix/client/v3/login"
|
||||
payload = {
|
||||
"type": "m.login.password",
|
||||
"identifier": {"type": "m.id.user", "user": user_identifier},
|
||||
"password": password,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.post(login_url, json=payload, timeout=15.0)
|
||||
except httpx.RequestError:
|
||||
return JSONResponse({"error": "Could not reach Matrix homeserver"}, status_code=502)
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_msg = resp.json().get("error", "Authentication failed")
|
||||
return JSONResponse({"error": error_msg}, status_code=401)
|
||||
|
||||
data = resp.json()
|
||||
matrix_user_id: str = data["user_id"]
|
||||
matrix_access_token: str = data["access_token"]
|
||||
|
||||
session_id = await create_session(matrix_user_id, matrix_access_token)
|
||||
|
||||
response = JSONResponse({"user_id": matrix_user_id})
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE,
|
||||
value=session_id,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
secure=True,
|
||||
max_age=MAX_SESSION_AGE,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request) -> Response:
|
||||
session_id = request.cookies.get(SESSION_COOKIE)
|
||||
if session_id:
|
||||
await delete_session(session_id)
|
||||
response = JSONResponse({"ok": True})
|
||||
response.delete_cookie(key=SESSION_COOKIE)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(request: Request) -> Response:
|
||||
session_id = request.cookies.get(SESSION_COOKIE)
|
||||
if not session_id:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
|
||||
session = await get_session(session_id)
|
||||
if not session:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
|
||||
# Verify token is still valid with the homeserver
|
||||
whoami_url = f"{MATRIX_HOMESERVER_URL.rstrip('/')}/_matrix/client/v3/account/whoami"
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(
|
||||
whoami_url,
|
||||
headers={"Authorization": f"Bearer {session['matrix_access_token']}"},
|
||||
timeout=10.0,
|
||||
)
|
||||
except httpx.RequestError:
|
||||
# If homeserver is unreachable, trust the local session
|
||||
return JSONResponse({"user_id": session["matrix_user_id"]})
|
||||
|
||||
if resp.status_code != 200:
|
||||
await delete_session(session_id)
|
||||
response = JSONResponse({"error": "Session expired"}, status_code=401)
|
||||
response.delete_cookie(key=SESSION_COOKIE)
|
||||
return response
|
||||
|
||||
return JSONResponse({"user_id": session["matrix_user_id"]})
|
||||
|
||||
|
||||
async def require_session(request: Request) -> dict | None:
|
||||
"""Utility: extract and validate session from a request. Returns session dict or None."""
|
||||
session_id = request.cookies.get(SESSION_COOKIE)
|
||||
if not session_id:
|
||||
return None
|
||||
session = await get_session(session_id)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
# Verify token is still valid
|
||||
whoami_url = f"{MATRIX_HOMESERVER_URL.rstrip('/')}/_matrix/client/v3/account/whoami"
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(
|
||||
whoami_url,
|
||||
headers={"Authorization": f"Bearer {session['matrix_access_token']}"},
|
||||
timeout=10.0,
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return session # trust local session if homeserver unreachable
|
||||
|
||||
if resp.status_code != 200:
|
||||
await delete_session(session_id)
|
||||
return None
|
||||
|
||||
return session
|
||||
Reference in New Issue
Block a user