Files
Nexus/server/api/auth_jwt.py
T

275 lines
9.1 KiB
Python

"""Nexus — JWT Authentication Middleware
Validates JWT access token on ALL protected API routes.
A1: JWT middleware deployed to all API routes.
Old API Key auth remains for /api/agent/* endpoints (Agent→Nexus communication).
"""
import logging
from pathlib import Path
from typing import Optional
_ROOT_DIR = Path(__file__).resolve().parent.parent.parent
def _is_install_mode() -> bool:
"""Check if the system is in install mode (no .env file).
Evaluated at call time (not import time) so that the install wizard
writing .env is detected immediately without requiring a restart.
"""
return not (_ROOT_DIR / ".env").exists()
from fastapi import Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from server.domain.models import Admin
from server.api.dependencies import _get_request_session
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
from server.config import settings
logger = logging.getLogger("nexus.auth_jwt")
security = HTTPBearer(auto_error=False)
# Routes that bypass JWT auth (Agent heartbeat uses API Key, login obviously doesn't need JWT)
PUBLIC_PREFIXES = (
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/logout", # invalidates refresh token from body, no access JWT required
"/api/agent/", # Agent uses X-API-Key header
"/api/install/", # install wizard (403 when already installed, except /status)
"/api/settings/bing-wallpapers", # login page wallpaper slideshow
"/api/settings/bing-wallpaper", # single wallpaper (backward compat)
"/health",
"/ws/",
"/docs",
"/openapi.json",
"/redoc",
)
def _is_public_path(path: str) -> bool:
"""Check if the request path is public (doesn't require JWT)"""
for prefix in PUBLIC_PREFIXES:
if path.startswith(prefix):
return True
return False
def _unauthorized_response(detail: str) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": detail},
headers={"WWW-Authenticate": "Bearer"},
)
def _extract_bearer_token(request: Request) -> Optional[str]:
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:].strip()
return token or None
class JwtAuthMiddleware:
"""Pure ASGI middleware: reject unauthenticated /api/* before route handlers run.
Must be registered inside DbSessionMiddleware so _verify_token can use request.state.db.
Route-level Depends(get_current_admin) remains for injecting Admin into handlers.
NOTE: Uses pure ASGI (not BaseHTTPMiddleware) to avoid MissingGreenlet errors
with SQLAlchemy async. BaseHTTPMiddleware's call_next() runs the endpoint in
a separate task, which breaks SQLAlchemy's async greenlet context.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
# Skip OPTIONS (CORS preflight)
# Read method from ASGI scope
method = scope.get("method", "")
if method == "OPTIONS":
await self.app(scope, receive, send)
return
if _is_install_mode():
await self.app(scope, receive, send)
return
if not path.startswith("/api/"):
await self.app(scope, receive, send)
return
if _is_public_path(path):
await self.app(scope, receive, send)
return
# Extract Bearer token from headers
token = None
for header_name, header_value in scope.get("headers", []):
if header_name == b"authorization":
auth_str = header_value.decode("latin-1", errors="replace")
if auth_str.startswith("Bearer "):
token = auth_str[7:].strip()
break
if not token:
response = _unauthorized_response("Missing Authorization header")
await response(scope, receive, send)
return
# Verify token — need a Request object for _verify_token
# Create a minimal Request from the ASGI scope
from starlette.requests import Request
request = Request(scope, receive, send)
admin = await _verify_token(token, request)
if not admin:
response = _unauthorized_response("Invalid or expired token")
await response(scope, receive, send)
return
# Store admin in scope state (accessible via request.state.admin)
scope.setdefault("state", {})
scope["state"]["admin"] = admin
await self.app(scope, receive, send)
async def get_current_admin(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> Optional[Admin]:
"""FastAPI dependency: extract JWT from Authorization header → return Admin or None
A1: Applied to all API routes except public ones.
For public routes, returns None if no credentials provided.
For protected routes, raises 401 if no/invalid credentials.
"""
# Skip JWT for public routes — return None (route handler decides what to do)
if _is_public_path(request.url.path):
if not credentials:
return None
cached = getattr(request.state, "admin", None)
if cached is not None:
return cached
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
admin = await _verify_token(credentials.credentials, request)
if not admin:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
return admin
async def _verify_token(token: str, request: Request) -> Optional[Admin]:
"""Verify JWT access token → return Admin or None"""
try:
import jwt as pyjwt
payload = pyjwt.decode(
token, settings.SECRET_KEY, algorithms=["HS256"],
options={"require": ["exp", "sub"]},
)
except pyjwt.ExpiredSignatureError:
logger.debug("JWT token expired")
return None
except pyjwt.InvalidTokenError as e:
logger.debug(f"JWT invalid: {e}")
return None
except Exception:
logger.debug("JWT verification unexpected error", exc_info=True)
return None
admin_id = payload.get("sub")
if not admin_id:
return None
try:
admin_id = int(admin_id)
except (ValueError, TypeError):
return None
try:
session = await _get_request_session(request)
admin_repo = AdminRepositoryImpl(session)
admin = await admin_repo.get_by_id(admin_id)
if admin and admin.is_active:
# Primary check: token_version must match exactly.
# Changed on password change, TOTP disable, or token reuse detection.
# This invalidates ALL access tokens immediately — no grace window.
# Normalize None → 0 for safety (DB may have NULL from direct inserts)
token_tv = payload.get("tv") or 0
admin_tv = admin.token_version or 0
if token_tv != admin_tv:
logger.debug(f"Token invalidated: token_version mismatch (token={token_tv}, db={admin.token_version})")
return None
return admin
except Exception:
logger.debug("JWT admin lookup (get_current_admin) failed", exc_info=True)
return None
async def get_optional_admin(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> Optional[Admin]:
"""Optional JWT auth — returns Admin if token valid, None otherwise.
Uses middleware session via request.state.db (no leaked sessions).
"""
if not credentials:
return None
try:
import jwt as pyjwt
payload = pyjwt.decode(
credentials.credentials, settings.SECRET_KEY, algorithms=["HS256"],
options={"require": ["exp", "sub"]},
)
admin_id = payload.get("sub")
if not admin_id:
return None
try:
admin_id = int(admin_id)
except (ValueError, TypeError):
return None
# Use middleware session instead of creating independent session
session = await _get_request_session(request)
admin_repo = AdminRepositoryImpl(session)
admin = await admin_repo.get_by_id(admin_id)
if not admin or not admin.is_active:
return None
# Primary check: token_version must match exactly
token_tv = payload.get("tv") or 0
admin_tv = admin.token_version or 0
if token_tv != admin_tv:
return None
return admin
except Exception:
logger.debug("JWT admin lookup failed", exc_info=True)
return None