cdd0be328a
Critical runtime bugs:
- terminal.html WebSSH完全不可用(URL前缀/JSON解析/Content-Type三处错误)
- servers.py路由遮蔽:/logs被/{id}拦截,3个前端页面同步日志查询失败
- scripts.html startExecPoll()→startExecPolling(),长任务快速执行崩溃
- agent.py {value!r!s:.50}格式串非法,agent发非数值时ValueError
- alerts.html d.daily.reduce()无null检查,API返回空数据时TypeError
Resource leak / stability:
- websocket.py僵尸连接未关闭TCP,文件描述符泄漏
- websocket.py _last_alert_time字典无限增长(加1小时过期清理)
- asyncssh_pool.py全忙时超过MAX_CONNECTIONS无限增长
- self_monitor.py Telegram告警无冷却,宕机时每30秒刷屏
- schedule_runner.py一次性调度执行超60秒会重复触发
- 限速脚本EXPIRE每次重置窗口可绕过(改用Lua原子脚本)
Security:
- JWT access token加token_version声明,改密码后旧token立失效(零宽限)
- INSTALL_MODE导入时常量→动态函数,安装后JWT认证不再残留禁用
- install.py /lock端点加管理员存在性验证,防止阻断安装
- ServerUpdate schema移除connectivity只读字段,防止伪造连接状态
Frontend fixes:
- doExec()缺r.ok检查、commands.html null检查
- _server_to_dict()补last_checked_at+ssh_key_public
- _field_match()逗号cron表达式修复
- alerts类型显示、SSH会话名称、搜索高亮定位
- 一次性/循环定时任务(run_mode+fire_at+自动禁用)
Dead code removed (400+ lines):
- SyncService batch_push/_push_single等5个方法(零调用者)
- 5个未使用schema(SyncCommands/SyncConfig/SyncSftp/FileDeploy/PaginatedResponse)
- 6个零调用service方法、3个无前端API端点
- 4个未使用import
Schema migrations:
- push_schedules: run_mode + fire_at列,cron_expr改NULL
- servers: 7个新列 + ssh_key_private/public VARCHAR(500)→TEXT
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
251 lines
8.5 KiB
Python
251 lines
8.5 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 starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
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)
|
|
"/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(BaseHTTPMiddleware):
|
|
"""Defense-in-depth: 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.
|
|
"""
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
path = request.url.path
|
|
if request.method == "OPTIONS":
|
|
return await call_next(request)
|
|
if _is_install_mode():
|
|
return await call_next(request)
|
|
if not path.startswith("/api/"):
|
|
return await call_next(request)
|
|
if _is_public_path(path):
|
|
return await call_next(request)
|
|
|
|
token = _extract_bearer_token(request)
|
|
if not token:
|
|
return _unauthorized_response("Missing Authorization header")
|
|
|
|
admin = await _verify_token(token, request)
|
|
if not admin:
|
|
return _unauthorized_response("Invalid or expired token")
|
|
|
|
request.state.admin = admin
|
|
return await call_next(request)
|
|
|
|
|
|
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.
|
|
token_tv = payload.get("tv", -1)
|
|
if token_tv != admin.token_version:
|
|
logger.debug(f"Token invalidated: token_version mismatch (token={token_tv}, db={admin.token_version})")
|
|
return None
|
|
|
|
# Secondary check: updated_at timestamp (defense in depth).
|
|
# Catches any admin update not covered by token_version.
|
|
token_updated = payload.get("updated", 0)
|
|
if token_updated and admin.updated_at:
|
|
try:
|
|
admin_updated_ts = int(admin.updated_at.timestamp())
|
|
if admin_updated_ts > token_updated + 5: # 5s grace for clock skew
|
|
logger.debug(f"Token invalidated: admin updated after token issued")
|
|
return None
|
|
except (ValueError, OSError):
|
|
pass
|
|
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", -1)
|
|
if token_tv != admin.token_version:
|
|
return None
|
|
|
|
# Secondary check: updated_at timestamp (defense in depth)
|
|
token_updated = payload.get("updated", 0)
|
|
if token_updated and admin.updated_at:
|
|
try:
|
|
admin_updated_ts = int(admin.updated_at.timestamp())
|
|
if admin_updated_ts > token_updated + 5: # 5s grace for clock skew
|
|
return None
|
|
except (ValueError, OSError):
|
|
pass
|
|
|
|
return admin
|
|
except Exception:
|
|
logger.debug("JWT admin lookup failed", exc_info=True)
|
|
return None |