fix: P1安全加固 — 全局JWT保护 + 审计日志补全 + 敏感字段过滤

Settings API:
- 所有端点添加JWT认证(get_current_admin)
- 不可改项(secret_key/api_key/encryption_key/database_url)禁止PUT修改
- 敏感字段GET响应自动脱敏(前8位+...)
- Schedules/Presets/Retry CRUD添加JWT+审计日志

Servers API:
- POST/PUT/DELETE添加JWT认证
- 服务器增删改写审计日志

Assets API:
- Platform/Node写操作添加JWT认证
- Platform创建添加审计日志
This commit is contained in:
Your Name
2026-05-22 08:46:09 +08:00
parent b9139093f1
commit 55b7cbe904
3 changed files with 220 additions and 25 deletions
+44 -7
View File
@@ -1,13 +1,16 @@
"""Nexus — Platform & Node API Routes (Asset organization layer) """Nexus — Platform & Node API Routes (Asset organization layer)
All write operations require JWT authentication.
""" """
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.dependencies import get_db from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import PlatformCreate, PlatformUpdate, NodeCreate, NodeUpdate from server.api.schemas import PlatformCreate, PlatformUpdate, NodeCreate, NodeUpdate
from server.infrastructure.database.platform_node_repo import PlatformRepositoryImpl, NodeRepositoryImpl from server.infrastructure.database.platform_node_repo import PlatformRepositoryImpl, NodeRepositoryImpl
from server.domain.models import Platform, Node from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import Platform, Node, Admin, AuditLog
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,16 +38,33 @@ async def get_platform(id: int, db: AsyncSession = Depends(get_db)):
@router.post("/platforms", response_model=dict, status_code=201) @router.post("/platforms", response_model=dict, status_code=201)
async def create_platform(payload: PlatformCreate, db: AsyncSession = Depends(get_db)): async def create_platform(
payload: PlatformCreate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new platform template""" """Create a new platform template"""
repo = PlatformRepositoryImpl(db) repo = PlatformRepositoryImpl(db)
platform = Platform(**payload.model_dump(exclude_none=True)) platform = Platform(**payload.model_dump(exclude_none=True))
created = await repo.create(platform) created = await repo.create(platform)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_platform",
target_type="platform", target_id=created.id,
detail=f"name={created.name}", ip_address="",
))
return _platform_to_dict(created) return _platform_to_dict(created)
@router.put("/platforms/{id}", response_model=dict) @router.put("/platforms/{id}", response_model=dict)
async def update_platform(id: int, payload: PlatformUpdate, db: AsyncSession = Depends(get_db)): async def update_platform(
id: int,
payload: PlatformUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a platform template""" """Update a platform template"""
repo = PlatformRepositoryImpl(db) repo = PlatformRepositoryImpl(db)
platform = await repo.get_by_id(id) platform = await repo.get_by_id(id)
@@ -58,7 +78,11 @@ async def update_platform(id: int, payload: PlatformUpdate, db: AsyncSession = D
@router.delete("/platforms/{id}", status_code=204) @router.delete("/platforms/{id}", status_code=204)
async def delete_platform(id: int, db: AsyncSession = Depends(get_db)): async def delete_platform(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a platform template""" """Delete a platform template"""
repo = PlatformRepositoryImpl(db) repo = PlatformRepositoryImpl(db)
result = await repo.delete(id) result = await repo.delete(id)
@@ -91,7 +115,11 @@ async def get_node_tree(db: AsyncSession = Depends(get_db)):
@router.post("/nodes", response_model=dict, status_code=201) @router.post("/nodes", response_model=dict, status_code=201)
async def create_node(payload: NodeCreate, db: AsyncSession = Depends(get_db)): async def create_node(
payload: NodeCreate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new node""" """Create a new node"""
repo = NodeRepositoryImpl(db) repo = NodeRepositoryImpl(db)
node = Node(**payload.model_dump(exclude_none=True)) node = Node(**payload.model_dump(exclude_none=True))
@@ -100,7 +128,12 @@ async def create_node(payload: NodeCreate, db: AsyncSession = Depends(get_db)):
@router.put("/nodes/{id}", response_model=dict) @router.put("/nodes/{id}", response_model=dict)
async def update_node(id: int, payload: NodeUpdate, db: AsyncSession = Depends(get_db)): async def update_node(
id: int,
payload: NodeUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a node""" """Update a node"""
repo = NodeRepositoryImpl(db) repo = NodeRepositoryImpl(db)
node = await repo.get_by_id(id) node = await repo.get_by_id(id)
@@ -114,7 +147,11 @@ async def update_node(id: int, payload: NodeUpdate, db: AsyncSession = Depends(g
@router.delete("/nodes/{id}", status_code=204) @router.delete("/nodes/{id}", status_code=204)
async def delete_node(id: int, db: AsyncSession = Depends(get_db)): async def delete_node(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a node""" """Delete a node"""
repo = NodeRepositoryImpl(db) repo = NodeRepositoryImpl(db)
result = await repo.delete(id) result = await repo.delete(id)
+44 -1
View File
@@ -2,6 +2,7 @@
Presentation layer — receives HTTP requests, delegates to ServerService/SyncService. Presentation layer — receives HTTP requests, delegates to ServerService/SyncService.
Live server status comes from Redis (real-time), base info from MySQL. Live server status comes from Redis (real-time), base info from MySQL.
All write operations require JWT authentication and produce audit logs.
""" """
import json import json
@@ -15,8 +16,9 @@ from server.api.auth_jwt import get_current_admin
from server.api.schemas import ServerCreate, ServerUpdate, ServerPush, ServerCheck from server.api.schemas import ServerCreate, ServerUpdate, ServerPush, ServerCheck
from server.application.services.server_service import ServerService from server.application.services.server_service import ServerService
from server.application.services.sync_service import SyncService from server.application.services.sync_service import SyncService
from server.domain.models import Server, SyncLog, Admin from server.domain.models import Server, SyncLog, Admin, AuditLog
from server.infrastructure.redis.client import get_redis from server.infrastructure.redis.client import get_redis
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -158,11 +160,24 @@ async def get_server(
@router.post("/", response_model=dict, status_code=201) @router.post("/", response_model=dict, status_code=201)
async def create_server( async def create_server(
payload: ServerCreate, payload: ServerCreate,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service), service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
): ):
"""Create a new server""" """Create a new server"""
server = Server(**payload.model_dump(exclude_none=True)) server = Server(**payload.model_dump(exclude_none=True))
created = await service.create_server(server) created = await service.create_server(server)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_server",
target_type="server",
target_id=created.id,
detail=f"name={created.name} domain={created.domain}",
ip_address="",
))
return _server_to_dict(created) return _server_to_dict(created)
@@ -170,7 +185,9 @@ async def create_server(
async def update_server( async def update_server(
id: int, id: int,
payload: ServerUpdate, payload: ServerUpdate,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service), service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
): ):
"""Update an existing server""" """Update an existing server"""
server = await service.get_server(id) server = await service.get_server(id)
@@ -180,19 +197,45 @@ async def update_server(
if hasattr(server, key) and key != "id": if hasattr(server, key) and key != "id":
setattr(server, key, value) setattr(server, key, value)
updated = await service.update_server(server) updated = await service.update_server(server)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_server",
target_type="server",
target_id=id,
detail=f"name={updated.name}",
ip_address="",
))
return _server_to_dict(updated) return _server_to_dict(updated)
@router.delete("/{id}", status_code=204) @router.delete("/{id}", status_code=204)
async def delete_server( async def delete_server(
id: int, id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service), service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
): ):
"""Delete a server""" """Delete a server"""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
result = await service.delete_server(id) result = await service.delete_server(id)
if not result: if not result:
raise HTTPException(status_code=404, detail="Server not found") raise HTTPException(status_code=404, detail="Server not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_server",
target_type="server",
target_id=id,
detail=f"name={server.name}",
ip_address="",
))
# ── Health Check ── # ── Health Check ──
+132 -17
View File
@@ -1,48 +1,86 @@
"""Nexus — Settings & Schedules API Routes """Nexus — Settings & Schedules API Routes
Presentation layer — system settings + push schedules + password presets + audit logs. Presentation layer — system settings + push schedules + password presets + audit logs.
Security: All write operations require JWT authentication.
Immutable settings (secret_key, api_key, encryption_key, database_url) cannot be modified via API.
Sensitive values are masked in GET responses.
""" """
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from server.api.dependencies import get_db from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, SettingUpdatePayload from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, SettingUpdatePayload
from server.infrastructure.database.setting_repo import SettingRepositoryImpl from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog, Admin
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
# Settings that cannot be modified via API (encryption consistency)
IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
# Settings whose values are masked in GET responses
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key", "redis_url"}
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
# ── System Settings ── # ── System Settings ──
@router.get("/", response_model=list) @router.get("/", response_model=list)
async def list_settings(db: AsyncSession = Depends(get_db)): async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all system settings""" """List all system settings (sensitive values masked)"""
repo = SettingRepositoryImpl(db) repo = SettingRepositoryImpl(db)
settings = await repo.get_all() settings = await repo.get_all()
return [{"key": s.key, "value": s.value, "updated_at": str(s.updated_at)} for s in settings] result = []
for s in settings:
val = s.value
if s.key in SENSITIVE_KEYS and val:
val = val[:8] + "..." if len(val) > 8 else "***"
result.append({"key": s.key, "value": val, "updated_at": str(s.updated_at)})
return result
@router.get("/{key}", response_model=dict) @router.get("/{key}", response_model=dict)
async def get_setting(key: str, db: AsyncSession = Depends(get_db)): async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""Get a single setting by key""" """Get a single setting by key (sensitive values masked)"""
repo = SettingRepositoryImpl(db) repo = SettingRepositoryImpl(db)
value = await repo.get(key) value = await repo.get(key)
if value is None: if value is None:
raise HTTPException(status_code=404, detail="Setting not found") raise HTTPException(status_code=404, detail="Setting not found")
if key in SENSITIVE_KEYS and value:
value = value[:8] + "..." if len(value) > 8 else "***"
return {"key": key, "value": value} return {"key": key, "value": value}
@router.put("/{key}", response_model=dict) @router.put("/{key}", response_model=dict)
async def set_setting(key: str, payload: SettingUpdatePayload, db: AsyncSession = Depends(get_db)): async def set_setting(
"""Set a system setting value""" key: str,
payload: SettingUpdatePayload,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Set a system setting value (immutable keys are rejected)"""
if key in IMMUTABLE_KEYS:
raise HTTPException(status_code=403, detail=f"Setting '{key}' is immutable and cannot be modified via API")
repo = SettingRepositoryImpl(db) repo = SettingRepositoryImpl(db)
setting = await repo.set(key, str(payload.value)) setting = await repo.set(key, str(payload.value))
# Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_setting",
target_type="setting",
detail=f"key={key}",
ip_address="",
))
return {"key": setting.key, "value": setting.value} return {"key": setting.key, "value": setting.value}
@@ -52,7 +90,7 @@ schedule_router = APIRouter(prefix="/api/schedules", tags=["schedules"])
@schedule_router.get("/", response_model=list) @schedule_router.get("/", response_model=list)
async def list_schedules(db: AsyncSession = Depends(get_db)): async def list_schedules(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all push schedules""" """List all push schedules"""
repo = PushScheduleRepositoryImpl(db) repo = PushScheduleRepositoryImpl(db)
schedules = await repo.get_all() schedules = await repo.get_all()
@@ -60,16 +98,36 @@ async def list_schedules(db: AsyncSession = Depends(get_db)):
@schedule_router.post("/", response_model=dict, status_code=201) @schedule_router.post("/", response_model=dict, status_code=201)
async def create_schedule(payload: ScheduleCreate, db: AsyncSession = Depends(get_db)): async def create_schedule(
payload: ScheduleCreate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new push schedule""" """Create a new push schedule"""
repo = PushScheduleRepositoryImpl(db) repo = PushScheduleRepositoryImpl(db)
schedule = PushSchedule(**payload.model_dump()) schedule = PushSchedule(**payload.model_dump())
created = await repo.create(schedule) created = await repo.create(schedule)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_schedule",
target_type="schedule",
target_id=created.id,
detail=f"name={created.name}",
ip_address="",
))
return _schedule_to_dict(created) return _schedule_to_dict(created)
@schedule_router.put("/{id}", response_model=dict) @schedule_router.put("/{id}", response_model=dict)
async def update_schedule(id: int, payload: ScheduleUpdate, db: AsyncSession = Depends(get_db)): async def update_schedule(
id: int,
payload: ScheduleUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a push schedule""" """Update a push schedule"""
repo = PushScheduleRepositoryImpl(db) repo = PushScheduleRepositoryImpl(db)
schedule = await repo.get_by_id(id) schedule = await repo.get_by_id(id)
@@ -79,17 +137,41 @@ async def update_schedule(id: int, payload: ScheduleUpdate, db: AsyncSession = D
if hasattr(schedule, key) and key != "id": if hasattr(schedule, key) and key != "id":
setattr(schedule, key, value) setattr(schedule, key, value)
updated = await repo.update(schedule) updated = await repo.update(schedule)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_schedule",
target_type="schedule",
target_id=id,
detail=f"name={updated.name}",
ip_address="",
))
return _schedule_to_dict(updated) return _schedule_to_dict(updated)
@schedule_router.delete("/{id}", status_code=204) @schedule_router.delete("/{id}", status_code=204)
async def delete_schedule(id: int, db: AsyncSession = Depends(get_db)): async def delete_schedule(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a push schedule""" """Delete a push schedule"""
repo = PushScheduleRepositoryImpl(db) repo = PushScheduleRepositoryImpl(db)
result = await repo.delete(id) result = await repo.delete(id)
if not result: if not result:
raise HTTPException(status_code=404, detail="Schedule not found") raise HTTPException(status_code=404, detail="Schedule not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_schedule",
target_type="schedule",
target_id=id,
ip_address="",
))
# ── Password Presets ── # ── Password Presets ──
@@ -97,7 +179,7 @@ preset_router = APIRouter(prefix="/api/presets", tags=["presets"])
@preset_router.get("/", response_model=list) @preset_router.get("/", response_model=list)
async def list_presets(db: AsyncSession = Depends(get_db)): async def list_presets(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all password presets""" """List all password presets"""
repo = PasswordPresetRepositoryImpl(db) repo = PasswordPresetRepositoryImpl(db)
presets = await repo.get_all() presets = await repo.get_all()
@@ -105,24 +187,52 @@ async def list_presets(db: AsyncSession = Depends(get_db)):
@preset_router.post("/", response_model=dict, status_code=201) @preset_router.post("/", response_model=dict, status_code=201)
async def create_preset(payload: PresetCreate, db: AsyncSession = Depends(get_db)): async def create_preset(
payload: PresetCreate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a password preset (password will be encrypted)""" """Create a password preset (password will be encrypted)"""
from server.infrastructure.database.crypto import encrypt_value from server.infrastructure.database.crypto import encrypt_value
repo = PasswordPresetRepositoryImpl(db) repo = PasswordPresetRepositoryImpl(db)
encrypted = encrypt_value(payload.encrypted_pw) encrypted = encrypt_value(payload.encrypted_pw)
preset = PasswordPreset(name=payload.name, encrypted_pw=encrypted) preset = PasswordPreset(name=payload.name, encrypted_pw=encrypted)
created = await repo.create(preset) created = await repo.create(preset)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_preset",
target_type="preset",
target_id=created.id,
detail=f"name={created.name}",
ip_address="",
))
return {"id": created.id, "name": created.name} return {"id": created.id, "name": created.name}
@preset_router.delete("/{id}", status_code=204) @preset_router.delete("/{id}", status_code=204)
async def delete_preset(id: int, db: AsyncSession = Depends(get_db)): async def delete_preset(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a password preset""" """Delete a password preset"""
repo = PasswordPresetRepositoryImpl(db) repo = PasswordPresetRepositoryImpl(db)
result = await repo.delete(id) result = await repo.delete(id)
if not result: if not result:
raise HTTPException(status_code=404, detail="Preset not found") raise HTTPException(status_code=404, detail="Preset not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_preset",
target_type="preset",
target_id=id,
ip_address="",
))
# ── Audit Logs ── # ── Audit Logs ──
@@ -133,6 +243,7 @@ audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
async def list_audit_logs( async def list_audit_logs(
action: Optional[str] = None, action: Optional[str] = None,
limit: int = 200, limit: int = 200,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""List recent audit logs, optionally filtered by action""" """List recent audit logs, optionally filtered by action"""
@@ -150,7 +261,11 @@ retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
@retry_router.get("/", response_model=list) @retry_router.get("/", response_model=list)
async def list_retry_jobs(limit: int = 100, db: AsyncSession = Depends(get_db)): async def list_retry_jobs(
limit: int = 100,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List pending retry jobs""" """List pending retry jobs"""
repo = PushRetryJobRepositoryImpl(db) repo = PushRetryJobRepositoryImpl(db)
jobs = await repo.get_pending(limit) jobs = await repo.get_pending(limit)
@@ -199,4 +314,4 @@ def _retry_to_dict(job: PushRetryJob) -> dict:
"next_retry_at": str(job.next_retry_at) if job.next_retry_at else None, "next_retry_at": str(job.next_retry_at) if job.next_retry_at else None,
"last_error": job.last_error, "last_error": job.last_error,
"created_at": str(job.created_at) if job.created_at else None, "created_at": str(job.created_at) if job.created_at else None,
} }