fix(security): complete security and delivery compliance remediation
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
APP_NAME=H3C-ONU-MS
|
||||
DEBUG=false
|
||||
SECRET_KEY=your-secret-key-change-this
|
||||
# 独立于 SECRET_KEY;使用 `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成
|
||||
CREDENTIAL_ENCRYPTION_KEY=
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
|
||||
@@ -17,6 +19,8 @@ CASDOOR_ORG_NAME=your_org
|
||||
CASDOOR_APP_NAME=h3c-onu-ms
|
||||
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
||||
CASDOOR_REDIRECT_URL=
|
||||
# 为空时使用 CASDOOR_ENDPOINT;JWT 的 iss 必须与此值一致
|
||||
CASDOOR_ISSUER=
|
||||
|
||||
# SSH配置
|
||||
SSH_TIMEOUT=30
|
||||
@@ -37,7 +41,7 @@ NTP_NEW_SERVER=172.16.1.252
|
||||
IMC_API_URL=
|
||||
IMC_API_USERNAME=
|
||||
IMC_API_PASSWORD=
|
||||
IMC_API_VERIFY_SSL=false
|
||||
IMC_API_VERIFY_SSL=true
|
||||
IMC_CONNECT_TIMEOUT=5
|
||||
IMC_READ_TIMEOUT=20
|
||||
|
||||
|
||||
+5
-6
@@ -1,13 +1,12 @@
|
||||
FROM python:3.11-slim
|
||||
ARG PYTHON_BASE_IMAGE=python:3.11-slim
|
||||
FROM ${PYTHON_BASE_IMAGE}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 使用清华镜像源
|
||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
|
||||
pip config set global.trusted-host https://pypi.tuna.tsinghua.edu.cn
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
ARG PIP_INDEX_URL
|
||||
RUN if [ -n "$PIP_INDEX_URL" ]; then pip config set global.index-url "$PIP_INDEX_URL"; fi \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
+42
-19
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 内容
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"]},
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,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,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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -20,6 +20,6 @@ python-json-logger==2.0.7
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.0
|
||||
casdoor==1.18.0
|
||||
aiohttp>=3.9.0
|
||||
PyJWT>=2.8.0
|
||||
requests>=2.31.0
|
||||
aiohttp==3.9.5
|
||||
PyJWT==2.8.0
|
||||
requests==2.31.0
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""One-time migration that encrypts legacy plaintext OLT credentials."""
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.device import OLTDevice
|
||||
from app.core.credentials import CREDENTIAL_PREFIX
|
||||
|
||||
|
||||
def migrate(batch_size: int = 100) -> int:
|
||||
"""Encrypt every legacy credential and return the number of migrated records."""
|
||||
db = SessionLocal()
|
||||
migrated = 0
|
||||
try:
|
||||
devices = db.query(OLTDevice).yield_per(batch_size)
|
||||
for device in devices:
|
||||
if device.password.startswith(CREDENTIAL_PREFIX):
|
||||
continue
|
||||
# Reading a legacy row yields plaintext. Mark it dirty so the column type encrypts it on flush.
|
||||
device.password = device.password
|
||||
flag_modified(device, "password")
|
||||
migrated += 1
|
||||
db.commit()
|
||||
return migrated
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Migrated {migrate()} OLT credential(s).")
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Security regression tests for P0 remediations."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.errors import internal_error
|
||||
from app.core.credentials import CREDENTIAL_PREFIX, decrypt_credential, encrypt_credential
|
||||
from app.core.logging_utils import redact_log_message
|
||||
from app.core.security import verify_casdoor_token
|
||||
from app.middleware.audit_middleware import sanitize_audit_params
|
||||
|
||||
|
||||
def test_production_rejects_disabled_imc_tls_verification():
|
||||
from app.core.config import Settings
|
||||
|
||||
with pytest.raises(ValidationError, match="IMC_API_VERIFY_SSL"):
|
||||
Settings(
|
||||
SECRET_KEY="test-secret",
|
||||
CREDENTIAL_ENCRYPTION_KEY="Q6NgOwoM3sna4ti4UeEo3Lo2cIzxc8wJMvMZ2cmw7lU=",
|
||||
DATABASE_URL="sqlite:///./test.db",
|
||||
REDIS_URL="redis://localhost:6379/15",
|
||||
CASDOOR_ENDPOINT="https://casdoor.example.test",
|
||||
CASDOOR_CLIENT_ID="test-client",
|
||||
CASDOOR_CLIENT_SECRET="test-client-secret",
|
||||
CASDOOR_ORG_NAME="test-org",
|
||||
CASDOOR_APP_NAME="test-app",
|
||||
IMC_API_URL="https://imc.example.test",
|
||||
IMC_API_VERIFY_SSL=False,
|
||||
DEBUG=False,
|
||||
)
|
||||
|
||||
|
||||
def test_audit_params_redacts_nested_sensitive_values():
|
||||
params = {
|
||||
"corpsecret": "corp-secret",
|
||||
"profile": {"access_token": "access-token", "name": "operator"},
|
||||
"items": [{"password": "device-password"}],
|
||||
"normal": "kept",
|
||||
}
|
||||
|
||||
assert sanitize_audit_params(params) == {
|
||||
"corpsecret": "***",
|
||||
"profile": {"access_token": "***", "name": "operator"},
|
||||
"items": [{"password": "***"}],
|
||||
"normal": "kept",
|
||||
}
|
||||
|
||||
|
||||
def test_internal_error_does_not_expose_exception_text():
|
||||
exception = internal_error("test operation", RuntimeError("database password=should-not-leak"))
|
||||
|
||||
assert isinstance(exception, HTTPException)
|
||||
assert exception.status_code == 500
|
||||
assert exception.detail["code"] == "INTERNAL_ERROR"
|
||||
assert "should-not-leak" not in str(exception.detail)
|
||||
assert exception.detail["error_id"]
|
||||
|
||||
|
||||
def test_log_redaction_masks_common_credential_formats():
|
||||
message = "Authorization: Bearer abc.def password=hunter2&access_token=token-value corpsecret: corp-secret"
|
||||
redacted = redact_log_message(message)
|
||||
|
||||
assert "abc.def" not in redacted
|
||||
assert "hunter2" not in redacted
|
||||
assert "token-value" not in redacted
|
||||
assert "corp-secret" not in redacted
|
||||
|
||||
|
||||
def test_olt_credential_encryption_round_trip(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "CREDENTIAL_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
encrypted = encrypt_credential("olt-device-password")
|
||||
|
||||
assert encrypted.startswith(CREDENTIAL_PREFIX)
|
||||
assert "olt-device-password" not in encrypted
|
||||
assert decrypt_credential(encrypted) == "olt-device-password"
|
||||
|
||||
|
||||
def test_olt_response_never_contains_device_password():
|
||||
from app.schemas.olt import serialize_olt
|
||||
from app.models.device import OLTDevice
|
||||
|
||||
device = OLTDevice(
|
||||
id=1,
|
||||
ip_address="10.0.0.1",
|
||||
username="operator",
|
||||
password="olt-device-password",
|
||||
slot_command="display onu slot",
|
||||
region="城区",
|
||||
)
|
||||
|
||||
response = serialize_olt(device)
|
||||
assert "password" not in response
|
||||
assert "olt-device-password" not in str(response)
|
||||
|
||||
|
||||
def test_olt_password_is_encrypted_in_database(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
from app.models.device import OLTDevice
|
||||
|
||||
monkeypatch.setattr(settings, "CREDENTIAL_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
session.add(
|
||||
OLTDevice(
|
||||
id=1,
|
||||
ip_address="10.0.0.1",
|
||||
username="operator",
|
||||
password="olt-device-password",
|
||||
slot_command="display onu slot",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
stored_password = session.execute(text("SELECT password FROM olt_devices")).scalar_one()
|
||||
assert stored_password.startswith(CREDENTIAL_PREFIX)
|
||||
assert "olt-device-password" not in stored_password
|
||||
|
||||
session.expire_all()
|
||||
assert session.query(OLTDevice).one().password == "olt-device-password"
|
||||
|
||||
|
||||
def test_verify_casdoor_token_requires_expected_signature_and_claims(monkeypatch):
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_pem = private_key.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "CASDOOR_CERTIFICATE", public_pem)
|
||||
monkeypatch.setattr(settings, "CASDOOR_ENDPOINT", "https://casdoor.example.test")
|
||||
monkeypatch.setattr(settings, "CASDOOR_ISSUER", "")
|
||||
monkeypatch.setattr(settings, "CASDOOR_CLIENT_ID", "h3c-client")
|
||||
|
||||
token = jwt.encode(
|
||||
{
|
||||
"sub": "user-1",
|
||||
"iss": "https://casdoor.example.test",
|
||||
"aud": "h3c-client",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=5),
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
)
|
||||
|
||||
assert verify_casdoor_token(token)["sub"] == "user-1"
|
||||
|
||||
invalid_token = jwt.encode(
|
||||
{
|
||||
"sub": "user-1",
|
||||
"iss": "https://casdoor.example.test",
|
||||
"aud": "other-client",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=5),
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
)
|
||||
with pytest.raises(jwt.InvalidAudienceError):
|
||||
verify_casdoor_token(invalid_token)
|
||||
Reference in New Issue
Block a user