267 lines
6.9 KiB
JavaScript
267 lines
6.9 KiB
JavaScript
import axios from 'axios';
|
|
import { appConfig } from '@/config';
|
|
|
|
const AES_KEY = 'xczx2026!@#secKe';
|
|
const ENCRYPTION_EXCLUDE_PATHS = ['/avatar/', '/f/', '/amr2mp3', '/appVersion/check'];
|
|
const AUTH_STORAGE_KEY = 'qmt-web-im-app-auth-v1';
|
|
const AUTH_ERROR_CODE = 13;
|
|
const REFRESH_ERROR_CODE = 20045;
|
|
|
|
let importedCryptoKeyPromise = null;
|
|
let refreshPromise = null;
|
|
|
|
function storageScope() {
|
|
return appConfig.appServer || '';
|
|
}
|
|
|
|
function readAuthStore() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) || '{}');
|
|
} catch (error) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeAuthStore(store) {
|
|
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(store));
|
|
}
|
|
|
|
function normalizeAuthBundle(bundle = {}) {
|
|
const accessToken = String(bundle.accessToken || '').trim();
|
|
const refreshToken = String(bundle.refreshToken || '').trim();
|
|
if (!accessToken || !refreshToken) {
|
|
return null;
|
|
}
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
tokenType: String(bundle.tokenType || 'Bearer').trim() || 'Bearer',
|
|
accessTokenExpireIn: Number(bundle.accessTokenExpireIn || 0) || 0,
|
|
accessTokenExpireAt: Number(bundle.accessTokenExpireAt || 0) || 0,
|
|
refreshTokenExpireIn: Number(bundle.refreshTokenExpireIn || 0) || 0,
|
|
refreshTokenExpireAt: Number(bundle.refreshTokenExpireAt || 0) || 0
|
|
};
|
|
}
|
|
|
|
export function getStoredAppAuth() {
|
|
const store = readAuthStore();
|
|
return normalizeAuthBundle(store[storageScope()]);
|
|
}
|
|
|
|
export function persistAppAuth(bundle) {
|
|
const normalized = normalizeAuthBundle(bundle);
|
|
const store = readAuthStore();
|
|
if (!normalized) {
|
|
delete store[storageScope()];
|
|
writeAuthStore(store);
|
|
return null;
|
|
}
|
|
store[storageScope()] = normalized;
|
|
writeAuthStore(store);
|
|
return normalized;
|
|
}
|
|
|
|
export function clearStoredAppAuth() {
|
|
persistAppAuth(null);
|
|
}
|
|
|
|
function hasFormData(value) {
|
|
return typeof FormData !== 'undefined' && value instanceof FormData;
|
|
}
|
|
|
|
function absoluteUrl(url) {
|
|
return new URL(url, window.location.origin);
|
|
}
|
|
|
|
function shouldEncrypt(url) {
|
|
const pathname = absoluteUrl(url).pathname;
|
|
return !ENCRYPTION_EXCLUDE_PATHS.some(path => pathname === path || pathname.startsWith(path));
|
|
}
|
|
|
|
function shouldAttachAuth(config) {
|
|
return !config.skipAuth;
|
|
}
|
|
|
|
function textEncoder() {
|
|
return new TextEncoder();
|
|
}
|
|
|
|
function textDecoder() {
|
|
return new TextDecoder();
|
|
}
|
|
|
|
function bytesToBase64(bytes) {
|
|
let binary = '';
|
|
bytes.forEach(byte => {
|
|
binary += String.fromCharCode(byte);
|
|
});
|
|
return btoa(binary);
|
|
}
|
|
|
|
function base64ToBytes(base64) {
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let index = 0; index < binary.length; index += 1) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
async function getCryptoKey() {
|
|
if (!importedCryptoKeyPromise) {
|
|
importedCryptoKeyPromise = window.crypto.subtle.importKey(
|
|
'raw',
|
|
textEncoder().encode(AES_KEY),
|
|
{ name: 'AES-CBC' },
|
|
false,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
return importedCryptoKeyPromise;
|
|
}
|
|
|
|
async function encryptPayload(plainText) {
|
|
const iv = window.crypto.getRandomValues(new Uint8Array(16));
|
|
const key = await getCryptoKey();
|
|
const encrypted = await window.crypto.subtle.encrypt(
|
|
{ name: 'AES-CBC', iv },
|
|
key,
|
|
textEncoder().encode(plainText)
|
|
);
|
|
return `${bytesToBase64(iv)}:${bytesToBase64(new Uint8Array(encrypted))}`;
|
|
}
|
|
|
|
async function decryptPayload(cipherText) {
|
|
const [ivBase64, encryptedBase64] = String(cipherText || '').split(':', 2);
|
|
if (!ivBase64 || !encryptedBase64) {
|
|
throw new Error('Encrypted payload format is invalid');
|
|
}
|
|
const key = await getCryptoKey();
|
|
const decrypted = await window.crypto.subtle.decrypt(
|
|
{ name: 'AES-CBC', iv: base64ToBytes(ivBase64) },
|
|
key,
|
|
base64ToBytes(encryptedBase64)
|
|
);
|
|
return textDecoder().decode(decrypted);
|
|
}
|
|
|
|
function normalizeBusinessResponse(payload) {
|
|
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
return payload;
|
|
}
|
|
return {
|
|
code: 0,
|
|
message: 'success',
|
|
data: payload
|
|
};
|
|
}
|
|
|
|
async function decodeResponsePayload(response) {
|
|
const encrypted = String(response.headers?.['x-encrypted'] || '').toLowerCase() === 'true';
|
|
if (encrypted && typeof response.data === 'string') {
|
|
const plainText = await decryptPayload(response.data);
|
|
response.data = normalizeBusinessResponse(JSON.parse(plainText));
|
|
return response;
|
|
}
|
|
response.data = normalizeBusinessResponse(response.data);
|
|
return response;
|
|
}
|
|
|
|
const client = axios.create({
|
|
withCredentials: false
|
|
});
|
|
|
|
client.interceptors.request.use(async config => {
|
|
const nextConfig = { ...config };
|
|
const url = absoluteUrl(nextConfig.url || '').toString();
|
|
|
|
if (shouldAttachAuth(nextConfig)) {
|
|
const auth = getStoredAppAuth();
|
|
if (auth?.accessToken) {
|
|
nextConfig.headers = {
|
|
...(nextConfig.headers || {}),
|
|
Authorization: `${auth.tokenType || 'Bearer'} ${auth.accessToken}`
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!hasFormData(nextConfig.data) && nextConfig.method?.toLowerCase() !== 'get' && shouldEncrypt(url)) {
|
|
const plainBody = JSON.stringify(nextConfig.data ?? {});
|
|
nextConfig.data = await encryptPayload(plainBody);
|
|
nextConfig.headers = {
|
|
...(nextConfig.headers || {}),
|
|
'Content-Type': 'text/plain;charset=UTF-8',
|
|
'X-Timestamp': `${Math.floor(Date.now() / 1000)}`
|
|
};
|
|
}
|
|
|
|
return nextConfig;
|
|
});
|
|
|
|
client.interceptors.response.use(async response => decodeResponsePayload(response));
|
|
|
|
async function refreshAccessToken() {
|
|
if (refreshPromise) {
|
|
return refreshPromise;
|
|
}
|
|
const auth = getStoredAppAuth();
|
|
if (!auth?.refreshToken) {
|
|
clearStoredAppAuth();
|
|
throw new Error('登录状态已失效,请重新登录');
|
|
}
|
|
|
|
refreshPromise = client.post(`${appConfig.appServer}/auth/refreshToken`, {
|
|
refreshToken: auth.refreshToken
|
|
}, {
|
|
skipAuth: true
|
|
}).then(response => {
|
|
const payload = response.data || {};
|
|
if (Number(payload.code) !== 0 || !payload.data) {
|
|
if (Number(payload.code) === REFRESH_ERROR_CODE) {
|
|
clearStoredAppAuth();
|
|
}
|
|
throw new Error(payload.message || '刷新登录状态失败');
|
|
}
|
|
persistAppAuth(payload.data);
|
|
return payload.data;
|
|
}).finally(() => {
|
|
refreshPromise = null;
|
|
});
|
|
|
|
return refreshPromise;
|
|
}
|
|
|
|
async function request(config) {
|
|
const response = await client.request(config);
|
|
const payload = response.data || {};
|
|
if (Number(payload.code) === AUTH_ERROR_CODE && shouldAttachAuth(config) && !config._retriedAfterRefresh) {
|
|
await refreshAccessToken();
|
|
return request({
|
|
...config,
|
|
_retriedAfterRefresh: true
|
|
});
|
|
}
|
|
return response;
|
|
}
|
|
|
|
export async function appServerRequest(config) {
|
|
return request(config);
|
|
}
|
|
|
|
export async function appServerGet(url, config = {}) {
|
|
return request({
|
|
...config,
|
|
method: 'get',
|
|
url
|
|
});
|
|
}
|
|
|
|
export async function appServerPost(url, data = {}, config = {}) {
|
|
return request({
|
|
...config,
|
|
method: 'post',
|
|
url,
|
|
data
|
|
});
|
|
}
|