Browse Source

统一启用 mini 端 API 密文传输。

- 后端加密中间件覆盖 /api/mini/*
- 小程序端请求封装支持 enc/ts/nonce/edata 及响应解密,并兼容 H5 构建环境变量注入不稳定

Made-with: Cursor
zlwu 6 tháng trước cách đây
mục cha
commit
4152e64659
3 tập tin đã thay đổi với 293 bổ sung và 11 xóa
  1. 5 3
      backend/app/core/api_crypto_middleware.py
  2. 10 0
      sqxp-wx/.env.example
  3. 278 8
      sqxp-wx/utils/request.js

+ 5 - 3
backend/app/core/api_crypto_middleware.py

@@ -34,8 +34,10 @@ def _should_skip_path(path: str) -> bool:
     return path in excludes
 
 
-def _is_web_api(path: str) -> bool:
-    return path.startswith(f"{settings.API_V1_PREFIX}/web/")
+def _is_crypto_api(path: str) -> bool:
+    """仅对运营端与小程序业务 API 应用统一密文协议。"""
+    prefix = settings.API_V1_PREFIX
+    return path.startswith(f"{prefix}/web/") or path.startswith(f"{prefix}/mini/")
 
 
 def _claims_from_auth(auth_header: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
@@ -110,7 +112,7 @@ class ApiCryptoMiddleware(BaseHTTPMiddleware):
                 return await call_next(request)
             return _json_error_response(500, "服务端加密配置缺失")
         path = request.url.path
-        if not _is_web_api(path) or _should_skip_path(path):
+        if not _is_crypto_api(path) or _should_skip_path(path):
             return await call_next(request)
 
         content_type = (request.headers.get("content-type") or "").lower()

+ 10 - 0
sqxp-wx/.env.example

@@ -16,3 +16,13 @@
 # - 不要用 Date.now() 每次变化,否则会导致完全无法命中缓存
 # VITE_STATIC_ASSETS_VERSION=20260423
 # VUE_APP_STATIC_ASSETS_VERSION=20260423
+
+# API 应用层加密固定密钥(必须与后端 API_ENCRYPT_FIXED_SECRET 完全一致)
+# VITE_ENABLE_API_CRYPTO=true
+# VUE_APP_ENABLE_API_CRYPTO=true
+# VITE_API_ENCRYPT_FAIL_OPEN=false
+# VUE_APP_API_ENCRYPT_FAIL_OPEN=false
+# VITE_API_ENCRYPT_FIXED_SECRET=REPLACE_WITH_BACKEND_FIXED_SECRET
+# VUE_APP_API_ENCRYPT_FIXED_SECRET=REPLACE_WITH_BACKEND_FIXED_SECRET
+# VITE_API_ENCRYPT_EXCLUDE_PATHS=/mini/auth/login-rsa-public-key,/mini/auth/login,/mini/auth/sms-login,/mini/common/china-region-tree
+# VUE_APP_API_ENCRYPT_EXCLUDE_PATHS=/mini/auth/login-rsa-public-key,/mini/auth/login,/mini/auth/sms-login,/mini/common/china-region-tree

+ 278 - 8
sqxp-wx/utils/request.js

@@ -1,6 +1,33 @@
 import { API_BASE, TOKEN_KEY, REFRESH_TOKEN_KEY, USER_INFO_KEY } from '@/config/api'
 
 let clearingAuth = false
+function readEnv(key, fallback = '') {
+  // HBuilderX / uni-app H5 有时不会注入自定义 env,这里做兜底
+  const v = process.env && Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : undefined
+  if (v == null || v === '') return fallback
+  return v
+}
+
+// HBuilderX/H5 构建时 env 注入不稳定:这里默认开启,避免发布到浏览器后退回明文。
+// 若确需关闭,可在环境变量中显式设置 VUE_APP_ENABLE_API_CRYPTO=false
+const API_ENCRYPT_ENABLED =
+  String(readEnv('VUE_APP_ENABLE_API_CRYPTO', readEnv('VITE_ENABLE_API_CRYPTO', 'true'))) === 'true'
+const API_ENCRYPT_FAIL_OPEN =
+  String(readEnv('VUE_APP_API_ENCRYPT_FAIL_OPEN', readEnv('VITE_API_ENCRYPT_FAIL_OPEN', 'false'))) ===
+  'true'
+const API_ENCRYPT_FIXED_SECRET = String(
+  readEnv(
+    'VUE_APP_API_ENCRYPT_FIXED_SECRET',
+    readEnv('VITE_API_ENCRYPT_FIXED_SECRET', 'sqxp-web-api-crypto-fixed-secret')
+  )
+).trim()
+const API_ENCRYPT_EXCLUDE_PATHS = String(
+  readEnv('VUE_APP_API_ENCRYPT_EXCLUDE_PATHS', readEnv('VITE_API_ENCRYPT_EXCLUDE_PATHS', '')) ||
+    '/mini/auth/login-rsa-public-key,/mini/auth/login,/mini/auth/sms-login,/mini/auth/wechat-login,/mini/auth/code-openid,/mini/common/china-region-tree'
+)
+  .split(',')
+  .map((item) => item.trim())
+  .filter(Boolean)
 
 function appendTimestampQuery(url) {
   const base = String(url || '')
@@ -62,6 +89,151 @@ function isBizUnauthorized(data) {
   return data.code === 401
 }
 
+function randomNonce() {
+  const bytes = new Uint8Array(12)
+  const c = globalThis.crypto
+  if (!c || typeof c.getRandomValues !== 'function') {
+    throw new Error('当前环境不支持随机数生成')
+  }
+  c.getRandomValues(bytes)
+  return Array.from(bytes)
+    .map((b) => b.toString(16).padStart(2, '0'))
+    .join('')
+}
+
+function hexToBytes(hex) {
+  const out = new Uint8Array(hex.length / 2)
+  for (let i = 0; i < out.length; i += 1) {
+    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)
+  }
+  return out
+}
+
+function bytesToHex(bytes) {
+  return Array.from(bytes)
+    .map((b) => b.toString(16).padStart(2, '0'))
+    .join('')
+}
+
+function bytesToBase64(bytes) {
+  let binary = ''
+  bytes.forEach((b) => {
+    binary += String.fromCharCode(b)
+  })
+  return btoa(binary)
+}
+
+function base64ToBytes(base64) {
+  const binary = atob(base64)
+  const out = new Uint8Array(binary.length)
+  for (let i = 0; i < binary.length; i += 1) {
+    out[i] = binary.charCodeAt(i)
+  }
+  return out
+}
+
+function toBase64Url(bytes) {
+  return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
+}
+
+function fromBase64Url(base64url) {
+  const base64 = String(base64url || '').replace(/-/g, '+').replace(/_/g, '/')
+  const padding = '='.repeat((4 - (base64.length % 4)) % 4)
+  return base64ToBytes(base64 + padding)
+}
+
+async function sha256Hex(text) {
+  const c = globalThis.crypto
+  if (!c || !c.subtle || typeof c.subtle.digest !== 'function') {
+    throw new Error('当前环境不支持摘要算法')
+  }
+  const data = new TextEncoder().encode(String(text || ''))
+  const digest = await c.subtle.digest('SHA-256', data)
+  return bytesToHex(new Uint8Array(digest))
+}
+
+async function deriveKey(fixed, userId, ts, nonce) {
+  return sha256Hex(`${fixed}:${userId}:${ts}:${nonce}`)
+}
+
+async function encryptJson(payload, keyHex) {
+  const c = globalThis.crypto
+  if (!c || !c.subtle || typeof c.subtle.importKey !== 'function') {
+    throw new Error('当前环境不支持加密算法')
+  }
+  const iv = c.getRandomValues(new Uint8Array(12))
+  const key = await c.subtle.importKey('raw', hexToBytes(keyHex), 'AES-GCM', false, ['encrypt'])
+  const plain = new TextEncoder().encode(JSON.stringify(payload || {}))
+  const encrypted = await c.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain)
+  const merged = new Uint8Array(iv.length + encrypted.byteLength)
+  merged.set(iv, 0)
+  merged.set(new Uint8Array(encrypted), iv.length)
+  return toBase64Url(merged)
+}
+
+async function decryptJson(edata, keyHex) {
+  const c = globalThis.crypto
+  if (!c || !c.subtle || typeof c.subtle.decrypt !== 'function') {
+    throw new Error('当前环境不支持解密算法')
+  }
+  const raw = fromBase64Url(edata)
+  if (raw.length < 13) {
+    throw new Error('响应密文非法')
+  }
+  const iv = raw.slice(0, 12)
+  const cipher = raw.slice(12)
+  const key = await c.subtle.importKey('raw', hexToBytes(keyHex), 'AES-GCM', false, ['decrypt'])
+  const decrypted = await c.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher)
+  const text = new TextDecoder().decode(new Uint8Array(decrypted))
+  return JSON.parse(text)
+}
+
+function parseUserIdFromToken(token) {
+  const raw = String(token || '')
+  const parts = raw.split('.')
+  if (parts.length < 2) return ''
+  try {
+    const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
+    const pad = '='.repeat((4 - (b64.length % 4)) % 4)
+    const json = atob(b64 + pad)
+    const obj = JSON.parse(json)
+    return String(obj?.sub || '')
+  } catch (error) {
+    return ''
+  }
+}
+
+function normalizeRequestData(data) {
+  if (!data) return {}
+  if (typeof data === 'string') {
+    try {
+      const parsed = JSON.parse(data)
+      return parsed && typeof parsed === 'object' ? parsed : {}
+    } catch (error) {
+      return {}
+    }
+  }
+  return typeof data === 'object' ? data : {}
+}
+
+function shouldEncryptRequest(rawUrl, userId, data) {
+  if (!API_ENCRYPT_ENABLED || !API_ENCRYPT_FIXED_SECRET) return false
+  if (!rawUrl || !String(rawUrl).includes('/mini/')) return false
+  if (!userId) return false
+  if (data instanceof FormData) return false
+  return !API_ENCRYPT_EXCLUDE_PATHS.some((prefix) => String(rawUrl).startsWith(prefix))
+}
+
+function getEncryptSkipReason(rawUrl, userId, data) {
+  if (!API_ENCRYPT_ENABLED) return 'encrypt-disabled'
+  if (!API_ENCRYPT_FIXED_SECRET) return 'missing-fixed-secret'
+  if (!rawUrl || !String(rawUrl).includes('/mini/')) return 'non-mini-path'
+  if (!userId) return 'missing-user-id'
+  if (data instanceof FormData) return 'form-data'
+  if (API_ENCRYPT_EXCLUDE_PATHS.some((prefix) => String(rawUrl).startsWith(prefix))) return 'exclude-path'
+  return ''
+}
+
 export function request(options) {
   if (clearingAuth) {
     return Promise.reject(createUnauthorizedError(options?.url))
@@ -69,22 +241,120 @@ export function request(options) {
   const rawToken = uni.getStorageSync(TOKEN_KEY)
   const token = typeof rawToken === 'string' ? rawToken.trim() : ''
   const rawUrl = options.url || ''
+  const method = String(options.method || 'GET').toUpperCase()
+  const userIdFromStorage = (() => {
+    const rawUserInfo = uni.getStorageSync(USER_INFO_KEY)
+    if (rawUserInfo && typeof rawUserInfo === 'object') {
+      return String(rawUserInfo.id || rawUserInfo.userId || '')
+    }
+    if (typeof rawUserInfo === 'string') {
+      try {
+        const parsed = JSON.parse(rawUserInfo)
+        return String(parsed?.id || parsed?.userId || '')
+      } catch (error) {
+        return ''
+      }
+    }
+    return ''
+  })()
+  const userId = userIdFromStorage || parseUserIdFromToken(token)
   const normalizedUrl = rawUrl.startsWith('http')
     ? rawUrl
     : `${API_BASE}${rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`}`
   const url = appendTimestampQuery(normalizedUrl)
+  const shouldEncrypt = shouldEncryptRequest(rawUrl, userId, options.data)
+  const skipReason = shouldEncrypt ? '' : getEncryptSkipReason(rawUrl, userId, options.data)
+
+  return new Promise(async (resolve, reject) => {
+    const originalMethod = method
+    const originalData = options.data
+    const originalParams = options.params
+
+    let reqMethod = method
+    let reqData = options.data
+    const reqHeader = {
+      'Content-Type': 'application/json',
+      ...(token ? { Authorization: `Bearer ${token}` } : {}),
+      ...(options.header || {})
+    }
+    // 便于在 HBuilderX/H5 的 Network 面板直接判断是否走了密文
+    reqHeader['X-Api-Crypto'] = shouldEncrypt ? 'on' : `off:${skipReason || 'unknown'}`
+    try {
+      if (shouldEncrypt) {
+        if (reqMethod === 'GET') {
+          reqHeader['X-HTTP-Method-Override'] = 'GET'
+          reqMethod = 'POST'
+          reqData = options.data || options.params || {}
+        }
+        const ts = Date.now()
+        const nonce = randomNonce()
+        const keyHex = await deriveKey(API_ENCRYPT_FIXED_SECRET, userId, ts, nonce)
+        const plainData = normalizeRequestData(reqData)
+        const edata = await encryptJson({ data: plainData }, keyHex)
+        reqData = {
+          enc: 1,
+          ts,
+          nonce,
+          edata
+        }
+      }
+    } catch (error) {
+      if (!API_ENCRYPT_FAIL_OPEN) {
+        reject(error)
+        return
+      }
+    }
 
-  return new Promise((resolve, reject) => {
     uni.request({
       ...options,
+      method: reqMethod,
+      data: reqData,
       url,
-      header: {
-        'Content-Type': 'application/json',
-        ...(token ? { Authorization: `Bearer ${token}` } : {}),
-        ...(options.header || {})
-      },
-      success: (res) => {
-        if (res.statusCode === 401 || isBizUnauthorized(res.data)) {
+      header: reqHeader,
+      success: async (res) => {
+        // 后端未启用密文中间件(或未支持 method override)时,GET->POST 传输会触发 405;
+        // 这里仅对“原始 GET + override”场景做一次降级回 GET,保证 H5 本地联调可用。
+        if (
+          res.statusCode === 405 &&
+          originalMethod === 'GET' &&
+          String(reqHeader['X-HTTP-Method-Override'] || '').toUpperCase() === 'GET'
+        ) {
+          const fallbackHeader = {
+            ...reqHeader
+          }
+          delete fallbackHeader['X-HTTP-Method-Override']
+          fallbackHeader['X-Api-Crypto'] = 'off:405-fallback'
+          uni.request({
+            ...options,
+            method: 'GET',
+            data: originalData || originalParams,
+            url,
+            header: fallbackHeader,
+            success: (res2) => resolve(res2),
+            fail: (err2) => reject(err2)
+          })
+          return
+        }
+
+        let payload = res.data
+        if (shouldEncrypt && payload && payload.encrypted && payload.edata) {
+          try {
+            const respKeyHex = await deriveKey(
+              API_ENCRYPT_FIXED_SECRET,
+              userId,
+              Number(payload.ts),
+              String(payload.nonce || '')
+            )
+            payload = await decryptJson(payload.edata, respKeyHex)
+            res.data = payload
+          } catch (error) {
+            if (!API_ENCRYPT_FAIL_OPEN) {
+              reject(error)
+              return
+            }
+          }
+        }
+        if (res.statusCode === 401 || isBizUnauthorized(payload)) {
           onHttpUnauthorized()
           reject(createUnauthorizedError(rawUrl))
           return