feat: Phase 2 security audit fixes + script execution platform
Security (P0/P1/P2): - WebSSH: dedicated short-lived token, server_id binding, 4003 close code - Remove /api/agent/exec from control plane (RCE surface eliminated) - Global JWT middleware (JwtAuthMiddleware) with install-mode bypass - decrypt_value: failure returns None, never leaks ciphertext - Telegram: sanitize_external_message strips sensitive fields - Agent auth: startup enforces non-empty API key, compare_digest - Credentials: password_set bool flag, no plaintext in API responses - audit_log: writes admin_username + ip_address on all CUD ops - Install lock: all /api/install/* except GET /status return 403 post-install - WebSocket dedup: publish once, subscribers deliver locally Script execution platform: - script_jobs.py / script_job_callback.py: async batching and callback - script_execution_store.py / script_callback_rate.py: Redis-backed state - script_execution_flush.py: background flusher to MySQL - scripts.html / script-executions.html: full execution UI - agent_url.py: centralised URL builder Frontend: - All 13 pages migrated from CDN to /app/vendor/ (no external deps) - vendor/: alpinejs, tailwindcss-browser, xterm, xterm-addon-*, qrious - Dashboard WebSocket real-time alerts; 8h JWT session timeout Tests: - test_security_unit.py: 15 unit tests (JWT, sanitize, vendor, install 403) - test_api.py: env-configurable admin credentials Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+56
-17
@@ -4,6 +4,7 @@ Presentation layer — receives HTTP requests, delegates to AuthService.
|
||||
A2: TOTP endpoints now require JWT authentication via get_current_admin.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -17,6 +18,8 @@ from server.domain.models import Admin, AuditLog
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
logger = logging.getLogger("nexus.auth")
|
||||
|
||||
|
||||
# ── Request Models ──
|
||||
|
||||
@@ -38,6 +41,12 @@ class TotpSetupRequest(BaseModel):
|
||||
admin_id: int
|
||||
|
||||
|
||||
class TotpDisableRequest(BaseModel):
|
||||
admin_id: int
|
||||
current_password: str = Field(..., min_length=1, max_length=255)
|
||||
totp_code: Optional[str] = Field(None, min_length=6, max_length=6)
|
||||
|
||||
|
||||
class TotpVerifyRequest(BaseModel):
|
||||
admin_id: int
|
||||
totp_code: str = Field(..., min_length=6, max_length=6)
|
||||
@@ -48,6 +57,10 @@ class ChangePasswordRequest(BaseModel):
|
||||
new_password: str = Field(..., min_length=6, max_length=255)
|
||||
|
||||
|
||||
class WebsshTokenRequest(BaseModel):
|
||||
server_id: int = Field(..., ge=1)
|
||||
|
||||
|
||||
# ── Public Routes (no JWT required) ──
|
||||
|
||||
@router.post("/login")
|
||||
@@ -76,11 +89,13 @@ async def login(
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(
|
||||
request: Request,
|
||||
payload: RefreshRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""Exchange refresh token for new access + refresh token pair"""
|
||||
result = await service.refresh_token(payload.refresh_token)
|
||||
ip_address = request.client.host if request.client else ""
|
||||
result = await service.refresh_token(payload.refresh_token, ip_address=ip_address)
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=401, detail=result.get("message", "Invalid refresh token"))
|
||||
return result
|
||||
@@ -88,10 +103,12 @@ async def refresh_token(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
payload: LogoutRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""Invalidate refresh token (client should also discard access token)"""
|
||||
ip_address = request.client.host if request.client else ""
|
||||
if payload.refresh_token:
|
||||
result = await service.logout_by_token(payload.refresh_token)
|
||||
else:
|
||||
@@ -143,23 +160,27 @@ async def enable_totp(
|
||||
|
||||
@router.post("/totp/disable")
|
||||
async def disable_totp(
|
||||
payload: TotpSetupRequest,
|
||||
payload: TotpDisableRequest,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""Disable TOTP for admin user (requires JWT auth)
|
||||
|
||||
A2: JWT authentication required — admin can only disable TOTP for themselves.
|
||||
"""
|
||||
"""Disable TOTP — requires current password + TOTP code when enabled."""
|
||||
if payload.admin_id != admin.id:
|
||||
raise HTTPException(status_code=403, detail="Can only disable TOTP for yourself")
|
||||
|
||||
result = await service.disable_totp(admin.id)
|
||||
result = await service.disable_totp(
|
||||
admin.id,
|
||||
current_password=payload.current_password,
|
||||
totp_code=payload.totp_code,
|
||||
)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("message", "Disable failed"))
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/password")
|
||||
async def change_password(
|
||||
request: Request,
|
||||
payload: ChangePasswordRequest,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -187,13 +208,8 @@ async def change_password(
|
||||
current_admin.jwt_token_expires = None
|
||||
await admin_repo.update(current_admin)
|
||||
|
||||
# Audit log
|
||||
request_ip = ""
|
||||
try:
|
||||
from fastapi import Request
|
||||
# IP will be added by the caller context if available
|
||||
except Exception:
|
||||
pass
|
||||
# Audit log with client IP
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
@@ -201,12 +217,35 @@ async def change_password(
|
||||
target_type="admin",
|
||||
target_id=admin.id,
|
||||
detail=f"Password changed for {admin.username} (all sessions invalidated)",
|
||||
ip_address="",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return {"success": True, "message": "密码已修改,请重新登录"}
|
||||
|
||||
|
||||
@router.post("/webssh-token")
|
||||
async def issue_webssh_token(
|
||||
payload: WebsshTokenRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Issue a short-lived JWT bound to one server for WebSSH (P0 IDOR fix)."""
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
|
||||
server_repo = ServerRepositoryImpl(db)
|
||||
server = await server_repo.get_by_id(payload.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
if not server.domain:
|
||||
raise HTTPException(status_code=400, detail="Server has no domain configured")
|
||||
|
||||
ip_address = request.client.host if request.client else ""
|
||||
result = await service.create_webssh_token(admin, payload.server_id)
|
||||
return {**result, "server_name": server.name}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
@@ -219,8 +258,8 @@ async def get_me(
|
||||
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
|
||||
repo = SettingRepositoryImpl(db)
|
||||
system_name = await repo.get("system_name")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load system_name in /me: {e}")
|
||||
|
||||
return {
|
||||
"id": admin.id,
|
||||
|
||||
Reference in New Issue
Block a user