|
@@ -1,6 +1,33 @@
|
|
|
import { API_BASE, TOKEN_KEY, REFRESH_TOKEN_KEY, USER_INFO_KEY } from '@/config/api'
|
|
import { API_BASE, TOKEN_KEY, REFRESH_TOKEN_KEY, USER_INFO_KEY } from '@/config/api'
|
|
|
|
|
|
|
|
let clearingAuth = false
|
|
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) {
|
|
function appendTimestampQuery(url) {
|
|
|
const base = String(url || '')
|
|
const base = String(url || '')
|
|
@@ -62,6 +89,151 @@ function isBizUnauthorized(data) {
|
|
|
return data.code === 401
|
|
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) {
|
|
export function request(options) {
|
|
|
if (clearingAuth) {
|
|
if (clearingAuth) {
|
|
|
return Promise.reject(createUnauthorizedError(options?.url))
|
|
return Promise.reject(createUnauthorizedError(options?.url))
|
|
@@ -69,22 +241,120 @@ export function request(options) {
|
|
|
const rawToken = uni.getStorageSync(TOKEN_KEY)
|
|
const rawToken = uni.getStorageSync(TOKEN_KEY)
|
|
|
const token = typeof rawToken === 'string' ? rawToken.trim() : ''
|
|
const token = typeof rawToken === 'string' ? rawToken.trim() : ''
|
|
|
const rawUrl = options.url || ''
|
|
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')
|
|
const normalizedUrl = rawUrl.startsWith('http')
|
|
|
? rawUrl
|
|
? rawUrl
|
|
|
: `${API_BASE}${rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`}`
|
|
: `${API_BASE}${rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`}`
|
|
|
const url = appendTimestampQuery(normalizedUrl)
|
|
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({
|
|
uni.request({
|
|
|
...options,
|
|
...options,
|
|
|
|
|
+ method: reqMethod,
|
|
|
|
|
+ data: reqData,
|
|
|
url,
|
|
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()
|
|
onHttpUnauthorized()
|
|
|
reject(createUnauthorizedError(rawUrl))
|
|
reject(createUnauthorizedError(rawUrl))
|
|
|
return
|
|
return
|