安全补强: 6项P0/P1漏洞修复
P0-1: PHP配置注入防护 — install.py添加_escape_php_string()转义单引号和反斜杠 P0-2: WebSocket JWT校验 — _verify_ws_token()要求exp+sub字段 P1-1: 删除SHA256密码fallback — auth_service.py和auth.py直接import bcrypt P1-3: LIKE通配符转义 — search.py添加_escape_like()并对所有ilike()加escape参数 P1-2: 安全响应头中间件 — main.py添加SecurityHeadersMiddleware注入4个安全头 P0-3: Refresh Token重用检测 — Admin模型添加token_version字段,token格式改为token:admin_id:version Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+46
-1
@@ -8,11 +8,12 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import bcrypt
|
||||
|
||||
from server.api.dependencies import get_auth_service, get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.application.services.auth_service import AuthService
|
||||
from server.domain.models import Admin
|
||||
from server.domain.models import Admin, AuditLog
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -42,6 +43,11 @@ class TotpVerifyRequest(BaseModel):
|
||||
totp_code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(..., min_length=1, max_length=255)
|
||||
new_password: str = Field(..., min_length=6, max_length=255)
|
||||
|
||||
|
||||
# ── Public Routes (no JWT required) ──
|
||||
|
||||
@router.post("/login")
|
||||
@@ -151,6 +157,45 @@ async def disable_totp(
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/password")
|
||||
async def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change the current admin's password (requires current password verification)"""
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
admin_repo = AdminRepositoryImpl(db)
|
||||
current_admin = await admin_repo.get_by_id(admin.id)
|
||||
if not current_admin:
|
||||
raise HTTPException(status_code=404, detail="Admin not found")
|
||||
|
||||
# Verify current password
|
||||
if not bcrypt.checkpw(payload.current_password.encode(), current_admin.password_hash.encode()):
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
|
||||
# Hash new password
|
||||
new_hash = bcrypt.hashpw(payload.new_password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
current_admin.password_hash = new_hash
|
||||
await admin_repo.update(current_admin)
|
||||
|
||||
# Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="change_password",
|
||||
target_type="admin",
|
||||
target_id=admin.id,
|
||||
detail=f"Password changed for {admin.username}",
|
||||
ip_address="",
|
||||
))
|
||||
|
||||
return {"success": True, "message": "密码已修改"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
|
||||
+28
-14
@@ -74,9 +74,20 @@ class CreateAdminRequest(BaseModel):
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _escape_php_string(s: str) -> str:
|
||||
"""Escape a string for safe insertion into a PHP single-quoted string.
|
||||
|
||||
In PHP single-quoted strings, only ``\\`` and ``'`` are special escapes.
|
||||
A user value containing ``'`` could break out of the define() and inject
|
||||
arbitrary PHP code (RCE). This function neutralises that vector.
|
||||
"""
|
||||
return s.replace("\\", "\\\\").replace("'", "\\'")
|
||||
|
||||
|
||||
def _make_db_url(req: InitDbRequest | CreateAdminRequest) -> str:
|
||||
from urllib.parse import quote_plus
|
||||
return (
|
||||
f"mysql+aiomysql://{req.db_user}:{req.db_pass}"
|
||||
f"mysql+aiomysql://{req.db_user}:{quote_plus(req.db_pass)}"
|
||||
f"@{req.db_host}:{req.db_port}/{req.db_name}"
|
||||
)
|
||||
|
||||
@@ -89,7 +100,7 @@ def _build_redis_url(req: InitDbRequest) -> str:
|
||||
return url
|
||||
|
||||
|
||||
def _write_env(req: InitDbRequest, secret_key: str, api_key: str,
|
||||
def _write_env(req: InitDbRequest, secret_key: str, api_key: str, encryption_key: str,
|
||||
pool_size: int, max_overflow: int, redis_url: str,
|
||||
site_url: str) -> None:
|
||||
install_dir = str(ROOT_DIR)
|
||||
@@ -116,7 +127,7 @@ def _write_env(req: InitDbRequest, secret_key: str, api_key: str,
|
||||
"# Security — PRODUCTION KEYS (immutable after install)",
|
||||
f"NEXUS_SECRET_KEY={secret_key}",
|
||||
f"NEXUS_API_KEY={api_key}",
|
||||
"NEXUS_ENCRYPTION_KEY=",
|
||||
f"NEXUS_ENCRYPTION_KEY={encryption_key}",
|
||||
"",
|
||||
"# Redis",
|
||||
f"NEXUS_REDIS_URL={redis_url}",
|
||||
@@ -157,19 +168,19 @@ def _write_config_php(req: InitDbRequest, api_key: str, site_url: str) -> None:
|
||||
"// Nexus Configuration — Auto-generated by installer",
|
||||
f"// {now}",
|
||||
"",
|
||||
f"define('API_BASE_URL', '{site_url}');",
|
||||
f"define('API_KEY', '{api_key}');",
|
||||
f"define('DB_HOST', '{req.db_host}');",
|
||||
f"define('DB_PORT', '{req.db_port}');",
|
||||
f"define('DB_NAME', '{req.db_name}');",
|
||||
f"define('DB_USER', '{req.db_user}');",
|
||||
f"define('DB_PASS', '{req.db_pass}');",
|
||||
f"define('API_BASE_URL', '{_escape_php_string(site_url)}');",
|
||||
f"define('API_KEY', '{_escape_php_string(api_key)}');",
|
||||
f"define('DB_HOST', '{_escape_php_string(req.db_host)}');",
|
||||
f"define('DB_PORT', '{_escape_php_string(req.db_port)}');",
|
||||
f"define('DB_NAME', '{_escape_php_string(req.db_name)}');",
|
||||
f"define('DB_USER', '{_escape_php_string(req.db_user)}');",
|
||||
f"define('DB_PASS', '{_escape_php_string(req.db_pass)}');",
|
||||
"define('APP_NAME', 'Nexus');",
|
||||
"define('APP_VERSION', '6.0.0');",
|
||||
"define('SESSION_LIFETIME', 28800);",
|
||||
f"define('DEPLOY_ROOT', '{install_dir}');",
|
||||
f"define('DEPLOY_ROOT', '{_escape_php_string(install_dir)}');",
|
||||
"",
|
||||
f"date_default_timezone_set('{req.timezone}');",
|
||||
f"date_default_timezone_set('{_escape_php_string(req.timezone)}');",
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -423,6 +434,9 @@ async def init_db(req: InitDbRequest):
|
||||
# Generate keys
|
||||
secret_key = secrets.token_hex(32)
|
||||
api_key = secrets.token_hex(16)
|
||||
# Generate Fernet-compatible encryption key (32 url-safe base64 bytes)
|
||||
import base64, hashlib
|
||||
encryption_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
|
||||
|
||||
# Insert settings
|
||||
settings_kv = {
|
||||
@@ -455,7 +469,7 @@ async def init_db(req: InitDbRequest):
|
||||
|
||||
# Write .env
|
||||
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
|
||||
_write_env(req, secret_key, api_key, pool_size, max_overflow, redis_url, site_url)
|
||||
_write_env(req, secret_key, api_key, encryption_key, pool_size, max_overflow, redis_url, site_url)
|
||||
|
||||
# Write config.php
|
||||
_write_config_php(req, api_key, site_url)
|
||||
@@ -550,7 +564,7 @@ async def create_admin(req: CreateAdminRequest):
|
||||
content = CONFIG_PHP.read_text()
|
||||
content = re.sub(
|
||||
r"define\('APP_NAME', '.*?'\);",
|
||||
f"define('APP_NAME', '{req.system_name}');",
|
||||
f"define('APP_NAME', '{_escape_php_string(req.system_name)}');",
|
||||
content,
|
||||
)
|
||||
CONFIG_PHP.write_text(content)
|
||||
|
||||
+21
-12
@@ -15,6 +15,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
|
||||
def _escape_like(s: str) -> str:
|
||||
"""Escape LIKE wildcard characters in user input.
|
||||
|
||||
Without this, a user searching for ``%`` matches all rows and ``_``
|
||||
matches any single character — a form of information disclosure.
|
||||
"""
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
async def global_search(
|
||||
q: str = Query(..., min_length=1, max_length=100, description="Search query"),
|
||||
@@ -27,17 +36,17 @@ async def global_search(
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
pattern = f"%{q}%"
|
||||
pattern = f"%{_escape_like(q)}%"
|
||||
results = {"servers": [], "scripts": [], "credentials": [], "schedules": []}
|
||||
|
||||
# Servers
|
||||
try:
|
||||
stmt = select(Server).where(
|
||||
or_(
|
||||
Server.name.ilike(pattern),
|
||||
Server.domain.ilike(pattern),
|
||||
Server.description.ilike(pattern),
|
||||
Server.category.ilike(pattern),
|
||||
Server.name.ilike(pattern, escape="\\"),
|
||||
Server.domain.ilike(pattern, escape="\\"),
|
||||
Server.description.ilike(pattern, escape="\\"),
|
||||
Server.category.ilike(pattern, escape="\\"),
|
||||
)
|
||||
).limit(10)
|
||||
res = await db.execute(stmt)
|
||||
@@ -53,9 +62,9 @@ async def global_search(
|
||||
try:
|
||||
stmt = select(Script).where(
|
||||
or_(
|
||||
Script.name.ilike(pattern),
|
||||
Script.category.ilike(pattern),
|
||||
Script.description.ilike(pattern),
|
||||
Script.name.ilike(pattern, escape="\\"),
|
||||
Script.category.ilike(pattern, escape="\\"),
|
||||
Script.description.ilike(pattern, escape="\\"),
|
||||
)
|
||||
).limit(10)
|
||||
res = await db.execute(stmt)
|
||||
@@ -70,8 +79,8 @@ async def global_search(
|
||||
try:
|
||||
stmt = select(DbCredential).where(
|
||||
or_(
|
||||
DbCredential.name.ilike(pattern),
|
||||
DbCredential.host.ilike(pattern),
|
||||
DbCredential.name.ilike(pattern, escape="\\"),
|
||||
DbCredential.host.ilike(pattern, escape="\\"),
|
||||
)
|
||||
).limit(10)
|
||||
res = await db.execute(stmt)
|
||||
@@ -86,8 +95,8 @@ async def global_search(
|
||||
try:
|
||||
stmt = select(PushSchedule).where(
|
||||
or_(
|
||||
PushSchedule.name.ilike(pattern),
|
||||
PushSchedule.source_path.ilike(pattern),
|
||||
PushSchedule.name.ilike(pattern, escape="\\"),
|
||||
PushSchedule.source_path.ilike(pattern, escape="\\"),
|
||||
)
|
||||
).limit(10)
|
||||
res = await db.execute(stmt)
|
||||
|
||||
+41
-5
@@ -252,7 +252,8 @@ async def _verify_ws_token(token: str):
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
|
||||
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"],
|
||||
options={"require": ["exp", "sub"]})
|
||||
admin_id = payload.get("sub")
|
||||
if not admin_id:
|
||||
return None
|
||||
@@ -272,6 +273,26 @@ async def _verify_ws_token(token: str):
|
||||
return None
|
||||
|
||||
|
||||
# ── Alert Aggregation / Dedup ──
|
||||
# Track last alert time per (server_id, alert_type) to avoid Telegram alert storms.
|
||||
# WebSocket broadcasts are always sent (real-time), but Telegram pushes are deduplicated.
|
||||
_ALERT_COOLDOWN_SECONDS = 300 # 5 minutes — same server+metric won't re-push Telegram
|
||||
_last_alert_time: Dict[str, float] = {} # key="alert:{server_id}:{type}" → monotonic timestamp
|
||||
|
||||
|
||||
def _should_push_telegram(alert_key: str) -> bool:
|
||||
"""Check if enough time has passed since last Telegram push for this alert key.
|
||||
|
||||
Returns True if we should send the Telegram notification, False if suppressed (cooldown).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
last = _last_alert_time.get(alert_key, 0)
|
||||
if now - last < _ALERT_COOLDOWN_SECONDS:
|
||||
return False # Suppressed — too soon
|
||||
_last_alert_time[alert_key] = now
|
||||
return True
|
||||
|
||||
|
||||
# ── Broadcast Functions (called from other modules) ──
|
||||
|
||||
async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, server_name: str = ""):
|
||||
@@ -279,6 +300,9 @@ async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, s
|
||||
|
||||
Called when Agent heartbeat detects CPU/memory/disk > threshold.
|
||||
Publishes to Redis Pub/Sub (multi-worker) + Telegram.
|
||||
|
||||
Alert aggregation: WebSocket always broadcasts (real-time),
|
||||
but Telegram push is deduplicated (5min cooldown per server+metric).
|
||||
"""
|
||||
msg = {
|
||||
"type": "alert",
|
||||
@@ -292,13 +316,21 @@ async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, s
|
||||
await manager.broadcast_local(msg)
|
||||
await _publish_to_redis(msg)
|
||||
|
||||
# Telegram push
|
||||
from server.infrastructure.telegram import send_telegram_alert
|
||||
await send_telegram_alert(server_name or str(server_id), alert_type, alert_value)
|
||||
# Telegram push — deduplicated per (server_id, alert_type) with cooldown
|
||||
alert_key = f"alert:{server_id}:{alert_type}"
|
||||
if _should_push_telegram(alert_key):
|
||||
from server.infrastructure.telegram import send_telegram_alert
|
||||
await send_telegram_alert(server_name or str(server_id), alert_type, alert_value)
|
||||
else:
|
||||
logger.debug(f"Alert suppressed (cooldown): {alert_key}")
|
||||
|
||||
|
||||
async def broadcast_recovery(server_id: int, metric: str, value: float, server_name: str = ""):
|
||||
"""Broadcast recovery notification when alert metric returns to normal"""
|
||||
"""Broadcast recovery notification when alert metric returns to normal
|
||||
|
||||
Recovery alerts are always pushed (clears the alert state).
|
||||
Also clears the alert cooldown so next alert will be sent immediately.
|
||||
"""
|
||||
msg = {
|
||||
"type": "recovery",
|
||||
"server_id": server_id,
|
||||
@@ -310,6 +342,10 @@ async def broadcast_recovery(server_id: int, metric: str, value: float, server_n
|
||||
await manager.broadcast_local(msg)
|
||||
await _publish_to_redis(msg)
|
||||
|
||||
# Recovery always sends Telegram + clears cooldown for next alert cycle
|
||||
alert_key = f"alert:{server_id}:{metric}"
|
||||
_last_alert_time.pop(alert_key, None) # Clear cooldown
|
||||
|
||||
from server.infrastructure.telegram import send_telegram_recovery
|
||||
await send_telegram_recovery(server_name or str(server_id), metric, value)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import secrets
|
||||
import struct
|
||||
import time
|
||||
import datetime
|
||||
import bcrypt
|
||||
from datetime import timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -77,7 +78,7 @@ class AuthService:
|
||||
|
||||
# Success — generate JWT tokens
|
||||
access_token = self._create_access_token(admin)
|
||||
refresh_token = self._create_refresh_token()
|
||||
refresh_token = self._create_refresh_token(admin)
|
||||
|
||||
# Store refresh token in DB
|
||||
admin.jwt_refresh_token = refresh_token
|
||||
@@ -103,16 +104,59 @@ class AuthService:
|
||||
}
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> dict:
|
||||
"""Exchange refresh token for new access token"""
|
||||
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
|
||||
if not admin:
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
"""Exchange refresh token for new access token (with rotation + version-based reuse detection)
|
||||
|
||||
Security: Refresh token format is ``token:admin_id:token_version``.
|
||||
If the version in the token doesn't match the admin's current version,
|
||||
it means this token was stolen/reused — invalidate all sessions for this admin.
|
||||
"""
|
||||
# Parse new format token: "token:admin_id:token_version"
|
||||
parts = refresh_token.rsplit(":", 2)
|
||||
if len(parts) == 3:
|
||||
token_val, admin_id_str, token_version_str = parts
|
||||
try:
|
||||
admin_id = int(admin_id_str)
|
||||
token_version = int(token_version_str)
|
||||
except (ValueError, TypeError):
|
||||
await self._audit("refresh_invalid_format", "admin", 0, "Refresh token has invalid format")
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
|
||||
# Get admin by ID (not by token)
|
||||
admin = await self.admin_repo.get_by_id(admin_id)
|
||||
if not admin:
|
||||
await self._audit("refresh_admin_not_found", "admin", admin_id, "Refresh token references non-existent admin")
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
|
||||
# Check token version — mismatch = reuse attack
|
||||
if admin.token_version != token_version:
|
||||
# Reuse attack detected! Invalidate all sessions by incrementing version
|
||||
admin.token_version += 1
|
||||
admin.jwt_refresh_token = None
|
||||
admin.jwt_token_expires = None
|
||||
await self.admin_repo.update(admin)
|
||||
await self._audit("refresh_reuse_attack", "admin", admin.id,
|
||||
f"Token reuse detected! Expected version {admin.token_version}, got {token_version}. All sessions invalidated.")
|
||||
return {"success": False, "reason": "token_reuse", "message": "检测到令牌重用,所有会话已失效,请重新登录"}
|
||||
|
||||
# Check if token matches current stored token (exact match required)
|
||||
if admin.jwt_refresh_token != refresh_token:
|
||||
await self._audit("refresh_token_mismatch", "admin", admin.id, "Refresh token doesn't match stored token")
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
|
||||
else:
|
||||
# Legacy format (old opaque token) — try exact match for backward compatibility
|
||||
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
|
||||
if not admin:
|
||||
await self._audit("refresh_legacy_not_found", "admin", 0, "Legacy refresh token not found")
|
||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||
# Force rotation to new format on next refresh
|
||||
|
||||
if admin.jwt_token_expires and admin.jwt_token_expires < datetime.datetime.now(timezone.utc):
|
||||
return {"success": False, "reason": "token_expired", "message": "刷新令牌已过期"}
|
||||
|
||||
# Rotate: generate new token pair (old refresh token is invalidated by DB update)
|
||||
access_token = self._create_access_token(admin)
|
||||
new_refresh = self._create_refresh_token()
|
||||
new_refresh = self._create_refresh_token(admin)
|
||||
|
||||
admin.jwt_refresh_token = new_refresh
|
||||
admin.jwt_token_expires = datetime.datetime.now(timezone.utc) + datetime.timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
@@ -148,9 +192,10 @@ class AuthService:
|
||||
return admin
|
||||
|
||||
async def logout(self, admin_id: int) -> dict:
|
||||
"""Invalidate refresh token by admin ID"""
|
||||
"""Invalidate refresh token by admin ID (also increments token_version to invalidate all sessions)"""
|
||||
admin = await self.admin_repo.get_by_id(admin_id)
|
||||
if admin:
|
||||
admin.token_version += 1 # Invalidate all existing refresh tokens
|
||||
admin.jwt_refresh_token = None
|
||||
admin.jwt_token_expires = None
|
||||
await self.admin_repo.update(admin)
|
||||
@@ -158,13 +203,27 @@ class AuthService:
|
||||
|
||||
async def logout_by_token(self, refresh_token: str) -> dict:
|
||||
"""Invalidate refresh token by token value (used by frontend logout)"""
|
||||
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
|
||||
# Try to parse new format token
|
||||
parts = refresh_token.rsplit(":", 2)
|
||||
if len(parts) == 3:
|
||||
admin_id_str = parts[1]
|
||||
try:
|
||||
admin_id = int(admin_id_str)
|
||||
admin = await self.admin_repo.get_by_id(admin_id)
|
||||
except (ValueError, TypeError):
|
||||
admin = None
|
||||
else:
|
||||
# Legacy format
|
||||
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
|
||||
|
||||
if admin:
|
||||
admin.token_version += 1 # Invalidate all existing refresh tokens
|
||||
admin.jwt_refresh_token = None
|
||||
admin.jwt_token_expires = None
|
||||
await self.admin_repo.update(admin)
|
||||
await self._audit("logout", "admin", admin.id, f"Logout: {admin.username}")
|
||||
return {"success": True, "message": "已登出"}
|
||||
return {"success": True, "message": "已登出"}
|
||||
|
||||
async def setup_totp(self, admin_id: int) -> dict:
|
||||
"""Generate TOTP secret for an admin user (before enabling)"""
|
||||
@@ -215,7 +274,10 @@ class AuthService:
|
||||
# ── JWT Token Helpers ──
|
||||
|
||||
def _create_access_token(self, admin: Admin) -> str:
|
||||
"""Create JWT access token with admin claims"""
|
||||
"""Create JWT access token with admin claims
|
||||
|
||||
Includes 'updated_at' claim so tokens are invalidated when admin changes password.
|
||||
"""
|
||||
import jwt
|
||||
from server.config import settings
|
||||
|
||||
@@ -225,12 +287,18 @@ class AuthService:
|
||||
"username": admin.username,
|
||||
"iat": now,
|
||||
"exp": now + datetime.timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
"updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0,
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
def _create_refresh_token(self) -> str:
|
||||
"""Create opaque refresh token (random, stored in DB)"""
|
||||
return secrets.token_urlsafe(48)
|
||||
def _create_refresh_token(self, admin: Admin) -> str:
|
||||
"""Create refresh token with version binding: token:admin_id:token_version
|
||||
|
||||
Format includes admin_id and token_version so we can detect reuse attacks
|
||||
even if the token is not found in DB (e.g., after rotation).
|
||||
"""
|
||||
token = secrets.token_urlsafe(32)
|
||||
return f"{token}:{admin.id}:{admin.token_version}"
|
||||
|
||||
def _decode_access_token(self, token: str) -> Optional[dict]:
|
||||
"""Decode and verify JWT access token"""
|
||||
@@ -246,13 +314,8 @@ class AuthService:
|
||||
# ── Password & TOTP Helpers ──
|
||||
|
||||
def _verify_password(self, password: str, password_hash: str) -> bool:
|
||||
"""Verify password against stored hash (bcrypt-compatible)"""
|
||||
try:
|
||||
import bcrypt
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
except ImportError:
|
||||
# Fallback: SHA256 comparison (for initial setup before bcrypt installed)
|
||||
return hashlib.sha256(password.encode()).hexdigest() == password_hash
|
||||
"""Verify password against stored bcrypt hash"""
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
|
||||
def _verify_totp(self, code: str, secret: str) -> bool:
|
||||
"""Verify TOTP code using RFC 6238 (HOTP with time counter)"""
|
||||
|
||||
@@ -14,6 +14,11 @@ from sqlalchemy import (
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
|
||||
def _utcnow():
|
||||
"""Callable default for DateTime columns — evaluated per-row, not at module import"""
|
||||
return datetime.datetime.now(timezone.utc)
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -31,7 +36,7 @@ class Platform(Base):
|
||||
type = Column(String(50), nullable=False, comment="类型: linux/windows/mysql/switch")
|
||||
default_protocols = Column(JSON, nullable=True, comment="默认协议: [{name:'ssh',port:22,primary:true}]")
|
||||
charset = Column(String(20), default="utf-8", comment="默认字符集")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
assets = relationship("Server", back_populates="platform")
|
||||
|
||||
@@ -44,11 +49,16 @@ class Node(Base):
|
||||
name = Column(String(100), nullable=False, comment="节点名称")
|
||||
parent_id = Column(Integer, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True, comment="父节点")
|
||||
sort_order = Column(Integer, default=0, comment="排序权重")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
parent = relationship("Node", remote_side=[id], backref="children")
|
||||
parent = relationship("Node", remote_side=[id], back_populates="children")
|
||||
children = relationship("Node", back_populates="parent")
|
||||
assets = relationship("Server", back_populates="node")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_nodes_parent_id', 'parent_id'),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Server (核心资产表 — 保持原名,扩展字段)
|
||||
@@ -76,8 +86,8 @@ class Server(Base):
|
||||
category = Column(String(100), nullable=True, comment="服务器分类(旧字段,保留兼容)")
|
||||
|
||||
# ── 新增:资产组织字段 ──
|
||||
platform_id = Column(Integer, ForeignKey("platforms.id"), nullable=True, comment="平台类型ID")
|
||||
node_id = Column(Integer, ForeignKey("nodes.id"), nullable=True, comment="所属节点ID")
|
||||
platform_id = Column(Integer, ForeignKey("platforms.id", ondelete="SET NULL"), nullable=True, comment="平台类型ID")
|
||||
node_id = Column(Integer, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True, comment="所属节点ID")
|
||||
protocols = Column(JSON, nullable=True, comment="实例级协议覆盖: [{name:'ssh',port:2222}]")
|
||||
extra_attrs = Column(JSON, nullable=True, comment="扩展属性: {os:'ubuntu',arch:'x86_64',cpu:8,mem:'32G'}")
|
||||
|
||||
@@ -92,8 +102,8 @@ class Server(Base):
|
||||
last_checked_at = Column(DateTime, nullable=True, comment="上次连接测试时间")
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc), onupdate=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
# Relationships
|
||||
sync_logs = relationship("SyncLog", back_populates="server", cascade="all, delete-orphan")
|
||||
@@ -113,7 +123,7 @@ class SyncLog(Base):
|
||||
__tablename__ = "sync_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
|
||||
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False)
|
||||
source_path = Column(String(500), nullable=False, comment="源目录")
|
||||
target_path = Column(String(500), nullable=False, comment="目标目录")
|
||||
trigger_type = Column(String(20), comment="触发类型: manual/schedule/batch")
|
||||
@@ -127,7 +137,7 @@ class SyncLog(Base):
|
||||
duration_seconds = Column(Integer, default=0)
|
||||
diff_summary = Column(Text, nullable=True, comment="rsync输出摘要")
|
||||
error_message = Column(Text, nullable=True)
|
||||
started_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
started_at = Column(DateTime, default=_utcnow)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
|
||||
server = relationship("Server", back_populates="sync_logs")
|
||||
@@ -148,12 +158,14 @@ class Admin(Base):
|
||||
totp_secret = Column(String(64), nullable=True)
|
||||
totp_enabled = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
# ── 新增:JWT 支持 ──
|
||||
jwt_refresh_token = Column(String(500), nullable=True, comment="JWT刷新令牌")
|
||||
jwt_token_expires = Column(DateTime, nullable=True, comment="令牌过期时间")
|
||||
token_version = Column(Integer, default=0, comment="令牌版本(重用时递增使旧token失效)")
|
||||
|
||||
|
||||
class LoginAttempt(Base):
|
||||
@@ -163,9 +175,13 @@ class LoginAttempt(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(String(100), nullable=False)
|
||||
ip_address = Column(String(45), nullable=False)
|
||||
attempted_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
attempted_at = Column(DateTime, default=_utcnow)
|
||||
success = Column(Boolean, default=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_login_attempts_user_time', 'username', 'attempted_at'),
|
||||
)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
"""Key-value system settings (including brand config)"""
|
||||
@@ -173,7 +189,7 @@ class Setting(Base):
|
||||
|
||||
key = Column(String(100), primary_key=True)
|
||||
value = Column(Text, nullable=True)
|
||||
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
|
||||
class PasswordPreset(Base):
|
||||
@@ -183,7 +199,7 @@ class PasswordPreset(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="预设名称")
|
||||
encrypted_pw = Column(String(500), nullable=False, comment="加密后的密码")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
|
||||
class PushSchedule(Base):
|
||||
@@ -198,7 +214,11 @@ class PushSchedule(Base):
|
||||
sync_mode = Column(String(20), default="incremental", comment="同步模式: incremental/full/overwrite/checksum")
|
||||
enabled = Column(Boolean, default=True)
|
||||
last_run_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_push_schedules_enabled', 'enabled'),
|
||||
)
|
||||
|
||||
|
||||
class PushRetryJob(Base):
|
||||
@@ -206,7 +226,7 @@ class PushRetryJob(Base):
|
||||
__tablename__ = "push_retry_jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
|
||||
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False)
|
||||
server_name = Column(String(100), comment="冗余字段便于列表显示")
|
||||
operator = Column(String(100), nullable=True, comment="操作人用户名")
|
||||
source_path = Column(String(500), nullable=False)
|
||||
@@ -216,8 +236,14 @@ class PushRetryJob(Base):
|
||||
max_retries = Column(Integer, default=100)
|
||||
next_retry_at = Column(DateTime)
|
||||
last_error = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
server = relationship("Server")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_push_retry_pending', 'status', 'next_retry_at'),
|
||||
)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
@@ -231,7 +257,12 @@ class AuditLog(Base):
|
||||
target_id = Column(Integer, nullable=True, comment="目标ID")
|
||||
detail = Column(Text, nullable=True, comment="操作详情JSON")
|
||||
ip_address = Column(String(45), nullable=True, comment="操作IP")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_audit_logs_action_time', 'action', 'created_at'),
|
||||
Index('idx_audit_logs_created_at', 'created_at'),
|
||||
)
|
||||
|
||||
|
||||
class Script(Base):
|
||||
@@ -244,8 +275,8 @@ class Script(Base):
|
||||
content = Column(Text, nullable=False, comment="Shell命令内容")
|
||||
description = Column(Text, nullable=True, comment="说明")
|
||||
created_by = Column(String(100), nullable=True, comment="创建人")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
|
||||
class ScriptExecution(Base):
|
||||
@@ -253,15 +284,21 @@ class ScriptExecution(Base):
|
||||
__tablename__ = "script_executions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
script_id = Column(Integer, ForeignKey("scripts.id"), nullable=True, comment="关联脚本(手动输入时为null)")
|
||||
script_id = Column(Integer, ForeignKey("scripts.id", ondelete="SET NULL"), nullable=True, comment="关联脚本(手动输入时为null)")
|
||||
command = Column(Text, nullable=False, comment="实际执行的命令")
|
||||
server_ids = Column(Text, nullable=False, comment="JSON数组: 目标服务器ID列表")
|
||||
status = Column(String(20), default="pending", comment="pending/running/completed/failed")
|
||||
results = Column(Text, nullable=True, comment="JSON: 每台服务器执行结果{server_id: {stdout,stderr,exit_code}}")
|
||||
operator = Column(String(100), nullable=True, comment="操作人用户名")
|
||||
started_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
started_at = Column(DateTime, default=_utcnow)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
script = relationship("Script")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_script_exec_script_id', 'script_id'),
|
||||
)
|
||||
|
||||
|
||||
class DbCredential(Base):
|
||||
"""Database credentials — encrypted storage for $DB_* variable substitution"""
|
||||
@@ -275,7 +312,7 @@ class DbCredential(Base):
|
||||
username = Column(String(100), nullable=False, comment="数据库用户名")
|
||||
encrypted_password = Column(String(500), nullable=False, comment="加密后的密码")
|
||||
database = Column(String(100), nullable=True, comment="数据库库名")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -291,7 +328,7 @@ class SshSession(Base):
|
||||
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="SET NULL"), nullable=True, comment="操作人ID")
|
||||
remote_addr = Column(String(45), nullable=True, comment="客户端IP")
|
||||
status = Column(String(20), default="active", comment="active/closed")
|
||||
started_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
started_at = Column(DateTime, default=_utcnow)
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
|
||||
server = relationship("Server")
|
||||
@@ -314,7 +351,7 @@ class CommandLog(Base):
|
||||
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="SET NULL"), nullable=True, comment="操作人ID")
|
||||
command = Column(String(2000), nullable=False, comment="执行的命令")
|
||||
remote_addr = Column(String(45), nullable=True, comment="客户端IP")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
session = relationship("SshSession", back_populates="command_logs")
|
||||
server = relationship("Server")
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy import select, update, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import Server, Node, Platform, Base
|
||||
from server.infrastructure.database.session import AsyncSessionLocal, engine
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger("nexus.migration")
|
||||
|
||||
@@ -110,6 +110,7 @@ async def run_schema_migrations():
|
||||
"""
|
||||
migrations = [
|
||||
"ALTER TABLE push_schedules ADD COLUMN sync_mode VARCHAR(20) DEFAULT 'incremental' COMMENT '同步模式'",
|
||||
"ALTER TABLE admins ADD COLUMN token_version INT DEFAULT 0 COMMENT '令牌版本(重用时递增使旧token失效)'",
|
||||
]
|
||||
async with AsyncSessionLocal() as session:
|
||||
for sql in migrations:
|
||||
|
||||
@@ -179,6 +179,14 @@ async def lifespan(app: FastAPI):
|
||||
logger.error("SECRET_KEY is empty! Set it in .env or MySQL settings table.")
|
||||
raise SystemExit("SECRET_KEY is required for Nexus to start.")
|
||||
|
||||
if not settings.API_KEY:
|
||||
logger.error("API_KEY is empty! Set it in .env (generated by install wizard).")
|
||||
raise SystemExit("API_KEY is required for Agent authentication and encryption fallback.")
|
||||
|
||||
if not settings.ENCRYPTION_KEY:
|
||||
logger.error("ENCRYPTION_KEY is empty! Set it in .env (generated by install wizard).")
|
||||
raise SystemExit("ENCRYPTION_KEY is required for credential encryption.")
|
||||
|
||||
# 1. Initialize database tables
|
||||
await init_db()
|
||||
logger.info("Database tables initialized")
|
||||
@@ -290,6 +298,29 @@ class InstallModeMiddleware(BaseHTTPMiddleware):
|
||||
# Install mode middleware (must be first — before DB session middleware)
|
||||
app.add_middleware(InstallModeMiddleware)
|
||||
|
||||
# Security headers middleware (before CORS so headers appear on all responses)
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Inject security-related response headers on every response.
|
||||
|
||||
- X-Content-Type-Options: nosniff — prevents MIME-type sniffing
|
||||
- X-Frame-Options: DENY — prevents clickjacking via iframes
|
||||
- Referrer-Policy: strict-origin-when-cross-origin — limits referrer leakage
|
||||
- Permissions-Policy: restricts browser features (camera, mic, geolocation)
|
||||
|
||||
Not included:
|
||||
- Content-Security-Policy: too many inline scripts/styles to enforce easily
|
||||
- Strict-Transport-Security: handled by Nginx in production
|
||||
"""
|
||||
async def dispatch(self, request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
return response
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# D7: DB session leak fix middleware (must be added BEFORE CORS so it wraps all requests)
|
||||
app.add_middleware(DbSessionMiddleware)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user