fix(security): complete security and delivery compliance remediation

This commit is contained in:
2026-07-28 17:56:28 +08:00
parent 5b07ec6df0
commit 81ab82e9ba
32 changed files with 798 additions and 94 deletions
+42 -19
View File
@@ -1,33 +1,48 @@
"""认证 API"""
import base64
import json
from fastapi import APIRouter, Depends, HTTPException, Header
import hmac
import secrets
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.casdoor import casdoor_sdk
from app.core.security import create_access_token, verify_token
from app.core.errors import internal_error
from app.core.security import create_access_token, verify_casdoor_token, verify_token
from app.core.config import settings
from app.models.user import User
from app.schemas.auth import Token, UserInfo
from datetime import datetime
router = APIRouter(prefix="/api/auth", tags=["认证"])
OAUTH_STATE_COOKIE = "h3c_oauth_state"
OAUTH_STATE_TTL_SECONDS = 300
def decode_jwt_payload(token: str) -> dict:
"""直接解码 JWT payload,不验签(Casdoor 已完成认证)"""
payload_b64 = token.split(".")[1]
rem = len(payload_b64) % 4
if rem:
payload_b64 += "=" * (4 - rem)
return json.loads(base64.urlsafe_b64decode(payload_b64))
def _with_oauth_state(url: str, state: str) -> str:
"""Replace the SDK-generated state with the browser-bound state value."""
parts = urlsplit(url)
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key != "state"]
query.append(("state", state))
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
@router.get("/login")
def login():
def login(response: Response):
"""获取 Casdoor 登录 URL"""
return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)}
state = secrets.token_urlsafe(32)
response.set_cookie(
key=OAUTH_STATE_COOKIE,
value=state,
max_age=OAUTH_STATE_TTL_SECONDS,
httponly=True,
secure=not settings.DEBUG,
samesite="lax",
path="/api/auth",
)
login_url = casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)
return {"url": _with_oauth_state(login_url, state)}
class CallbackRequest(BaseModel):
@@ -36,18 +51,29 @@ class CallbackRequest(BaseModel):
@router.post("/callback", response_model=Token)
def callback(body: CallbackRequest, db: Session = Depends(get_db)):
def callback(
body: CallbackRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
"""Casdoor 登录回调"""
try:
expected_state = request.cookies.get(OAUTH_STATE_COOKIE)
response.delete_cookie(OAUTH_STATE_COOKIE, path="/api/auth")
if not expected_state or not hmac.compare_digest(body.state, expected_state):
raise HTTPException(status_code=400, detail="登录状态校验失败,请重新登录")
token_response = casdoor_sdk.get_oauth_token(code=body.code)
if isinstance(token_response, dict) and "error" in token_response:
raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"]))
access_token = token_response.get("access_token") if isinstance(token_response, dict) else token_response
identity_token = token_response.get("id_token") if isinstance(token_response, dict) else None
if not access_token:
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
casdoor_user = decode_jwt_payload(access_token)
casdoor_user = verify_casdoor_token(identity_token or access_token)
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
if not user:
@@ -76,10 +102,7 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
except HTTPException:
raise
except Exception as e:
import traceback
import logging
logging.getLogger(__name__).error("callback error: %s\n%s", e, traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Casdoor login callback", e)
@router.get("/permissions")
+4 -4
View File
@@ -11,6 +11,7 @@ from slowapi.util import get_remote_address
from app.tasks.check_tasks import check_all_devices
from app.core.celery_app import celery_app
from app.core.database import get_db
from app.core.errors import internal_error
from app.services.check_service import CheckService
from app.middleware.permission_middleware import require_permission
@@ -41,8 +42,7 @@ def trigger_check(request: Request, _: dict = Depends(require_permission('device
task = check_all_devices.delay()
return {"task_id": task.id, "status": "started"}
except Exception as e:
logger.error(f"触发状态检查失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}")
raise internal_error("Trigger device status check", e)
@router.get("/status/{task_id}")
@@ -84,7 +84,7 @@ def scan_olt(
result = asyncio.run(service.scan_olt(olt_id))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Scan OLT", e)
@router.post("/discover/{olt_id}")
@@ -99,4 +99,4 @@ def discover_olt(
result = service.scan_and_discover(olt_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Discover OLT devices", e)
+5 -4
View File
@@ -8,6 +8,7 @@ from sqlalchemy import asc, desc, distinct, or_
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.errors import internal_error
from app.middleware.permission_middleware import require_permission
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse
@@ -439,7 +440,7 @@ def refresh_device_status(
result = service.check_single_device(device_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Refresh device status", e)
@@ -641,7 +642,7 @@ def reboot_device(
result = IMCService().reboot_onu(device.mac_address)
return RebootResponse(**result)
except Exception as e:
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
raise internal_error("Reboot ONU", e)
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
@@ -694,7 +695,7 @@ def get_device_optical_power(
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
raise internal_error("Get optical power", e)
@router.get("/{device_id}/onu-events")
@@ -726,7 +727,7 @@ def get_onu_events(
"events": events,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
raise internal_error("Get ONU events", e)
@router.get("/{device_id}/optical-power-history")
+8 -6
View File
@@ -4,9 +4,11 @@ from sqlalchemy.orm import Session
from sqlalchemy import distinct
from pydantic import BaseModel
from app.core.database import get_db
from app.core.errors import internal_error
from app.core.config import settings
from app.middleware.permission_middleware import require_permission
from app.models.device import OLTDevice
from app.schemas.olt import serialize_olt
import pandas as pd
import io
@@ -64,7 +66,7 @@ def get_devices(
q = q.filter(OLTDevice.region.in_(areas))
else:
return []
return q.all()
return [serialize_olt(device) for device in q.all()]
@router.post("/devices")
@@ -148,8 +150,8 @@ async def import_devices(
df = pd.read_excel(io.BytesIO(content))
# 标准化列名
df.columns = [str(c).strip() for c in df.columns]
except Exception as e:
raise HTTPException(status_code=400, detail=f"文件解析失败: {str(e)}")
except Exception:
raise HTTPException(status_code=400, detail="文件解析失败,请确认文件格式")
required_cols = ['IP地址', '用户名', '密码']
missing = [c for c in required_cols if c not in df.columns]
@@ -267,7 +269,7 @@ def clear_onu_port(
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
ssh.clear_onu_port(body.port_id)
except Exception as e:
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
raise internal_error("Clear ONU port", e)
# 从 ports 列表移除已清除的端口
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
@@ -571,7 +573,7 @@ def get_olt_ports(
ports = ssh.get_olt_ports()
return {"ports": ports}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Get OLT ports", e)
@router.post("/devices/{olt_id}/ports/toggle")
@@ -588,5 +590,5 @@ def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Se
ssh.toggle_olt_port(port_name, body.action)
return {"message": f"端口 {port_name}{'关闭' if body.action == 'shutdown' else '开启'}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Toggle OLT port", e)
+18 -3
View File
@@ -1,6 +1,7 @@
"""应用配置"""
import os
from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings
# 项目根目录(config.py 位于 backend/app/core/parent.parent.parent 即 backend/
@@ -11,6 +12,7 @@ class Settings(BaseSettings):
APP_NAME: str = "H3C-ONU-MS"
DEBUG: bool = False
SECRET_KEY: str
CREDENTIAL_ENCRYPTION_KEY: str
DATABASE_URL: str
REDIS_URL: str
@@ -22,6 +24,7 @@ class Settings(BaseSettings):
CASDOOR_APP_NAME: str
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
CASDOOR_REDIRECT_URL: str = ""
CASDOOR_ISSUER: str = "" # 为空时使用 CASDOOR_ENDPOINT
SSH_TIMEOUT: int = 30
CHECK_INTERVAL: int = 1800
@@ -48,27 +51,39 @@ class Settings(BaseSettings):
WECHAT_PROXY_API_URL: str = ""
IMC_API_USERNAME: str = ""
IMC_API_PASSWORD: str = ""
IMC_API_VERIFY_SSL: bool = False
IMC_API_VERIFY_SSL: bool = True
IMC_CONNECT_TIMEOUT: float = 5.0
IMC_READ_TIMEOUT: float = 20.0
class Config:
env_file = str(PROJECT_ROOT / ".env")
@model_validator(mode="after")
def reject_insecure_imc_tls_in_production(self):
"""Prevent production deployments from silently disabling TLS verification."""
if self.IMC_API_URL and not self.DEBUG and not self.IMC_API_VERIFY_SSL:
raise ValueError("IMC_API_VERIFY_SSL must be true when DEBUG is false")
return self
@property
def casdoor_cert_content(self) -> str:
"""读取证书文件内容或直接返回证书字符串"""
cert = self.CASDOOR_CERTIFICATE
if not cert:
return ""
if "-----BEGIN" in cert:
return cert
cert_path = Path(cert)
if cert_path.is_absolute():
path = cert_path
else:
# 相对路径基于项目根目录解析
path = PROJECT_ROOT / cert
if path.is_file():
return path.read_text()
try:
if path.is_file():
return path.read_text()
except OSError:
pass
return cert # 直接是 PEM 内容
+51
View File
@@ -0,0 +1,51 @@
"""Encryption at rest for device credentials."""
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy.types import Text, TypeDecorator
from app.core.config import settings
CREDENTIAL_PREFIX = "enc:v1:"
def _fernet() -> Fernet:
key = settings.CREDENTIAL_ENCRYPTION_KEY.strip()
if not key:
raise RuntimeError("Credential encryption key is not configured")
try:
return Fernet(key.encode())
except (TypeError, ValueError) as error:
raise RuntimeError("Credential encryption key is invalid") from error
def encrypt_credential(value: str) -> str:
"""Encrypt a plaintext credential with the deployment-provided key."""
if value.startswith(CREDENTIAL_PREFIX):
return value
return CREDENTIAL_PREFIX + _fernet().encrypt(value.encode()).decode()
def decrypt_credential(value: str) -> str:
"""Decrypt an encrypted credential; retain legacy plaintext only for migration."""
if not value.startswith(CREDENTIAL_PREFIX):
return value
try:
return _fernet().decrypt(value[len(CREDENTIAL_PREFIX):].encode()).decode()
except InvalidToken as error:
raise RuntimeError("Credential decryption failed") from error
class EncryptedCredential(TypeDecorator):
"""SQLAlchemy column type that stores credentials encrypted and reads plaintext."""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
return encrypt_credential(value)
def process_result_value(self, value, dialect):
if value is None:
return None
return decrypt_credential(value)
+26
View File
@@ -0,0 +1,26 @@
"""Safe error responses for API trust boundaries."""
import logging
from uuid import uuid4
from fastapi import HTTPException
logger = logging.getLogger(__name__)
def internal_error(context: str, error: Exception) -> HTTPException:
"""Log only a non-sensitive error classification and return a safe response."""
error_id = uuid4().hex
logger.error(
"%s failed [error_id=%s, error_type=%s]",
context,
error_id,
type(error).__name__,
)
return HTTPException(
status_code=500,
detail={
"code": "INTERNAL_ERROR",
"message": "服务器内部错误,请联系管理员并提供错误编号",
"error_id": error_id,
},
)
+26
View File
@@ -0,0 +1,26 @@
"""Logging helpers that prevent sensitive values from reaching application logs."""
import logging
import re
_SENSITIVE_KEY = r"(?:password|passwd|secret|token|authorization|credential|client_secret|corpsecret|code)"
_KEY_VALUE_PATTERN = re.compile(
rf"(?i)([\"']?{_SENSITIVE_KEY}[\"']?\s*[:=]\s*)([\"']?)([^\s,;\]\}}\"']+)([\"']?)"
)
_BEARER_PATTERN = re.compile(r"(?i)(authorization\s*[:=]\s*bearer\s+)[^\s,;]+")
_QUERY_PATTERN = re.compile(rf"(?i)([?&]{_SENSITIVE_KEY}=)[^&\s]+")
def redact_log_message(message: str) -> str:
"""Mask common secret formats while keeping enough context for operations."""
masked = _BEARER_PATTERN.sub(r"\1***", message)
masked = _QUERY_PATTERN.sub(r"\1***", masked)
return _KEY_VALUE_PATTERN.sub(r"\1\2***\4", masked)
class SensitiveDataFilter(logging.Filter):
"""Redact sensitive values after interpolation and before formatter output."""
def filter(self, record: logging.LogRecord) -> bool:
record.msg = redact_log_message(record.getMessage())
record.args = ()
return True
+30 -3
View File
@@ -1,6 +1,8 @@
"""JWT 安全配置"""
from datetime import datetime, timedelta
from jose import JWTError, jwt
import jwt as pyjwt
from jose import JWTError, jwt as jose_jwt
from app.core.config import settings
ALGORITHM = "HS256"
@@ -11,12 +13,37 @@ def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return jose_jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str):
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
payload = jose_jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
def verify_casdoor_token(token: str) -> dict:
"""Verify a Casdoor-issued JWT before using any identity claims."""
certificate = settings.casdoor_cert_content.strip()
if not certificate:
raise ValueError("Casdoor certificate is not configured")
issuer = (settings.CASDOOR_ISSUER or settings.CASDOOR_ENDPOINT).rstrip("/")
if not issuer:
raise ValueError("Casdoor issuer is not configured")
header = pyjwt.get_unverified_header(token)
algorithm = header.get("alg")
if algorithm not in {"RS256", "RS384", "RS512"}:
raise ValueError("Unsupported Casdoor token algorithm")
return pyjwt.decode(
token,
certificate,
algorithms=[algorithm],
audience=settings.CASDOOR_CLIENT_ID,
issuer=issuer,
options={"require": ["exp", "iat", "sub"]},
)
+2
View File
@@ -9,12 +9,14 @@ from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from app.core.config import settings
from app.core.logging_utils import SensitiveDataFilter
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit, wechat, ws, monitor
from app.middleware.audit_middleware import AuditMiddleware
# 结构化 JSON 日志
_handler = logging.StreamHandler()
_handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
_handler.addFilter(SensitiveDataFilter())
logging.getLogger().handlers = [_handler]
logging.getLogger().setLevel(logging.INFO)
logging.getLogger('uvicorn.access').handlers = [_handler]
+16 -5
View File
@@ -26,6 +26,21 @@ _LOG_GET_PATHS = {
"/api/auth/profile",
}
_SENSITIVE_PARAM_MARKERS = ("password", "passwd", "secret", "token", "authorization", "credential", "code")
def sanitize_audit_params(value):
"""Recursively redact sensitive request parameters before asynchronous logging."""
if isinstance(value, dict):
return {
key: "***" if any(marker in key.lower() for marker in _SENSITIVE_PARAM_MARKERS)
else sanitize_audit_params(item)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitize_audit_params(item) for item in value]
return value
def _should_log(method: str, path: str) -> bool:
for skip in _SKIP_PATHS:
@@ -62,11 +77,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
if body_bytes:
try:
request_params = json.loads(body_bytes)
# 脱敏:移除密码字段
if isinstance(request_params, dict):
for k in ("password", "passwd", "secret"):
if k in request_params:
request_params[k] = "***"
request_params = sanitize_audit_params(request_params)
except Exception:
pass
except Exception:
@@ -67,7 +67,7 @@ def require_permission(permission: str):
token = authorization[7:]
payload = verify_token(token)
if not payload:
logger.warning("auth rejected: token 验证失败 (permission=%s): token前20字符=%.20s...", permission, token[:20])
logger.warning("auth rejected: token 验证失败 (permission=%s)", permission)
raise HTTPException(status_code=401, detail="令牌无效或已过期")
role = payload.get('role', 'user')
+2 -1
View File
@@ -3,6 +3,7 @@ from sqlalchemy import Column, BigInteger, String, Integer, Float, Text, TIMESTA
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
from app.core.credentials import EncryptedCredential
class OLTDevice(Base):
@@ -11,7 +12,7 @@ class OLTDevice(Base):
id = Column(BigInteger, primary_key=True, index=True)
ip_address = Column(String(45), nullable=False)
username = Column(String(100), nullable=False)
password = Column(Text, nullable=False)
password = Column(EncryptedCredential(), nullable=False)
slot_command = Column(String(50), nullable=False)
region = Column(String(100), index=True)
location = Column(String(200))
+17
View File
@@ -0,0 +1,17 @@
"""OLT response serialization."""
from app.models.device import OLTDevice
def serialize_olt(device: OLTDevice) -> dict:
"""Return an OLT record without its device credential."""
return {
"id": device.id,
"ip_address": device.ip_address,
"username": device.username,
"slot_command": device.slot_command,
"region": device.region,
"location": device.location,
"description": device.description,
"created_at": device.created_at,
"updated_at": device.updated_at,
}
+1 -2
View File
@@ -1,5 +1,4 @@
"""告警相关 Celery 任务"""
import traceback
from datetime import datetime
from sqlalchemy import func, case
from app.core.celery_app import celery_app
@@ -77,6 +76,6 @@ def check_school_offline_alerts():
send_wechat_markdown(content)
return {"alerted": True, "schools": len(rows)}
except Exception as e:
return {"alerted": False, "error": str(e), "traceback": traceback.format_exc()}
return {"alerted": False, "error": "告警任务失败", "error_type": type(e).__name__}
finally:
db.close()
+1 -2
View File
@@ -1,5 +1,4 @@
"""审计日志 Celery 任务"""
import traceback
from app.core.celery_app import celery_app
from app.core.database import SessionLocal
@@ -57,6 +56,6 @@ def cleanup_audit_logs_task():
deleted = cleanup_old_logs(db)
return {'success': True, 'deleted': deleted}
except Exception as e:
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
return {'success': False, 'error': '审计日志任务失败', 'error_type': type(e).__name__}
finally:
db.close()
+7 -6
View File
@@ -1,6 +1,5 @@
"""状态检查任务"""
import time
import traceback
import redis as redis_lib
from datetime import datetime, timedelta
from sqlalchemy import func, case
@@ -86,13 +85,15 @@ def check_all_devices(self):
errors.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'error': str(e)
'error': 'OLT 状态检查失败',
'error_type': type(e).__name__,
})
results.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'success': False,
'error': str(e)
'error': 'OLT 状态检查失败',
'error_type': type(e).__name__,
})
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
@@ -118,8 +119,8 @@ def check_all_devices(self):
except Exception as e:
return {
'success': False,
'error': str(e),
'traceback': traceback.format_exc()
'error': '设备状态检查任务失败',
'error_type': type(e).__name__,
}
finally:
# 任务完成后记录时间、清除运行标记
@@ -181,7 +182,7 @@ def aggregate_daily_snapshot():
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
except Exception as e:
db.rollback()
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
return {'success': False, 'error': '设备状态检查任务失败', 'error_type': type(e).__name__}
finally:
db.close()