feat(encryption): 实现前后端 AES-256-GCM 加密通信
参考同级项目 novavis-authority 实现,在 HTTPS 基础上增加应用层加密。 后端: - CryptoService: AES-256-GCM + PBKDF2 密钥派生(manage-common) - CryptoFilter: Gateway GlobalFilter,检测 X-Encrypted 头后加解密请求/响应体 - 配置:app.encryption.secret 通过环境变量注入 前端: - crypto.ts: Web Crypto API 实现 AES-256-GCM + PBKDF2 - request.ts: 拦截器自动加密请求体、解密响应体 - 环境变量 VITE_ENCRYPTION_SECRET 测试覆盖: - 后端 CryptoServiceTest 16 个用例 + CryptoFilterTest 7 个用例 - 前端 crypto.test.ts 8 个用例 + Playwright E2E smoke 测试 - 全量 510 个前端测试 + 后端全量测试全部通过,无回归
This commit was merged in pull request #55.
This commit is contained in:
@@ -37,3 +37,6 @@ TEST_RESULTS_FOLDER=test-results
|
||||
|
||||
# API签名密钥配置
|
||||
VITE_SIGNATURE_SECRET=your-secret-key-here
|
||||
|
||||
# AES-256-GCM 加密密钥(与后端 APP_ENCRYPTION_SECRET 保持一致,长度不少于12个字符)
|
||||
VITE_ENCRYPTION_SECRET=your-encryption-secret-key-here
|
||||
|
||||
@@ -5,6 +5,9 @@ VITE_APP_TITLE=Novalon管理系统 - 测试环境
|
||||
# 测试用户配置
|
||||
TEST_USER_PASSWORD=Test@123
|
||||
|
||||
# AES-256-GCM 加密密钥(与后端 APP_ENCRYPTION_SECRET 保持一致)
|
||||
VITE_ENCRYPTION_SECRET=GymManageEncryptionSecretKey2026!
|
||||
|
||||
# Playwright配置
|
||||
HEADLESS=true
|
||||
SLOW_MO=0
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('前后端加密通信验证', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('登录请求应被加密(请求体非明文JSON,响应体非明文JSON)', async ({ page }) => {
|
||||
// 用于捕获登录 API 请求和响应的变量
|
||||
let capturedRequestUrl: string | null = null;
|
||||
let capturedRequestBody: string | null = null;
|
||||
let capturedRequestHeaders: Record<string, string> = {};
|
||||
let capturedResponseStatus: number | null = null;
|
||||
let capturedResponseBody: string | null = null;
|
||||
let capturedResponseHeaders: Record<string, string> = {};
|
||||
|
||||
// 监听 API 请求——在请求发送前捕获
|
||||
page.on('request', request => {
|
||||
if (request.url().includes('/api/auth/login') && request.method() === 'POST') {
|
||||
capturedRequestUrl = request.url();
|
||||
capturedRequestHeaders = request.headers() as Record<string, string>;
|
||||
// 对于 POST 请求,Playwright 无法直接获取 body,通过 route 拦截
|
||||
}
|
||||
});
|
||||
|
||||
// 使用 route 拦截请求体
|
||||
await page.route('**/api/auth/login', async (route) => {
|
||||
const postData = route.request().postData();
|
||||
if (postData) {
|
||||
capturedRequestBody = postData;
|
||||
}
|
||||
|
||||
// 继续请求,但拦截响应
|
||||
const response = await route.fetch();
|
||||
capturedResponseStatus = response.status();
|
||||
capturedResponseHeaders = response.headers() as Record<string, string>;
|
||||
capturedResponseBody = await response.text();
|
||||
|
||||
// 继续原始响应
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
// 导航到登录页
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 填入登录信息
|
||||
await page.fill('input[type="text"], input[placeholder*="用户名"]', 'admin');
|
||||
await page.fill('input[type="password"], input[placeholder*="密码"]', 'Test@123');
|
||||
|
||||
// 点击登录
|
||||
await page.click('button:has-text("登录")');
|
||||
|
||||
// 等待登录完成
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 30000 });
|
||||
|
||||
// ========== 验证请求体加密 ==========
|
||||
await test.step('验证请求体已加密(非明文JSON)', async () => {
|
||||
expect(capturedRequestBody).toBeTruthy();
|
||||
|
||||
// 请求体不应是明文 JSON(不应包含 username 字段)
|
||||
expect(capturedRequestBody).not.toContain('"username"');
|
||||
expect(capturedRequestBody).not.toContain('"admin"');
|
||||
expect(capturedRequestBody).not.toContain('"password"');
|
||||
|
||||
// 请求体应为 Base64 编码(AES-GCM 密文)
|
||||
expect(capturedRequestBody).toMatch(/^[A-Za-z0-9+/=]+$/);
|
||||
});
|
||||
|
||||
await test.step('验证 X-Encrypted 请求头存在', async () => {
|
||||
expect(capturedRequestHeaders['x-encrypted']?.toLowerCase()).toBe('true');
|
||||
});
|
||||
|
||||
// ========== 验证响应体加密 ==========
|
||||
await test.step('验证响应体已加密(非明文JSON)', async () => {
|
||||
expect(capturedResponseBody).toBeTruthy();
|
||||
expect(capturedResponseStatus).toBe(200);
|
||||
|
||||
// 响应体不应是明文 JSON(不应包含 token 字段)
|
||||
expect(capturedResponseBody).not.toContain('"token"');
|
||||
|
||||
// 响应体应为 Base64 编码
|
||||
expect(capturedResponseBody).toMatch(/^[A-Za-z0-9+/=]+$/);
|
||||
});
|
||||
|
||||
await test.step('验证 X-Encrypted 响应头存在', async () => {
|
||||
expect(capturedResponseHeaders['x-encrypted']?.toLowerCase()).toBe('true');
|
||||
});
|
||||
|
||||
// ========== 验证业务正常 ==========
|
||||
await test.step('验证登录成功——页面跳转到 dashboard', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
});
|
||||
|
||||
test('GET 请求的响应体也应被加密', async ({ page }) => {
|
||||
// 先登录
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.fill('input[type="text"], input[placeholder*="用户名"]', 'admin');
|
||||
await page.fill('input[type="password"], input[placeholder*="密码"]', 'Test@123');
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 30000 });
|
||||
|
||||
// 捕获 GET 请求
|
||||
let capturedGetBody: string | null = null;
|
||||
let capturedGetEncrypted: string | null = null;
|
||||
|
||||
await page.route('**/api/users**', async (route) => {
|
||||
// 仅拦截 GET 请求
|
||||
if (route.request().method() === 'GET') {
|
||||
const response = await route.fetch();
|
||||
capturedGetEncrypted = response.headers()['x-encrypted'] ?? null;
|
||||
capturedGetBody = await response.text();
|
||||
await route.fulfill({ response });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// 导航到用户管理页面触发 GET 请求
|
||||
await page.goto('/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await test.step('验证 GET 响应已加密', async () => {
|
||||
// 注意:如果用户无权限访问 /users,可能没有加密响应
|
||||
// 这里只验证如果响应存在,且标记了加密,则响应体应为 Base64
|
||||
if (capturedGetEncrypted) {
|
||||
expect(capturedGetEncrypted).toBe('true');
|
||||
expect(capturedGetBody).toMatch(/^[A-Za-z0-9+/=]+$/);
|
||||
expect(capturedGetBody).not.toContain('"id"');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// 模拟 VITE_ENCRYPTION_SECRET 环境变量
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('VITE_ENCRYPTION_SECRET', 'TestEncryptionKey2026!')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('crypto utils', () => {
|
||||
describe('encrypt and decrypt', () => {
|
||||
it('should encrypt and decrypt a string', async () => {
|
||||
const { encrypt, decrypt } = await import('@/utils/crypto')
|
||||
const plaintext = 'Hello, Crypto!'
|
||||
const encrypted = await encrypt(plaintext)
|
||||
const decrypted = await decrypt(encrypted)
|
||||
|
||||
expect(encrypted).not.toBe(plaintext)
|
||||
expect(decrypted).toBe(plaintext)
|
||||
})
|
||||
|
||||
it('should encrypt and decrypt JSON string', async () => {
|
||||
const { encrypt, decrypt } = await import('@/utils/crypto')
|
||||
const json = JSON.stringify({ username: 'admin', password: 'secret123' })
|
||||
const encrypted = await encrypt(json)
|
||||
const decrypted = await decrypt(encrypted)
|
||||
|
||||
expect(encrypted).not.toBe(json)
|
||||
expect(decrypted).toBe(json)
|
||||
expect(() => JSON.parse(decrypted)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should encrypt and decrypt empty string', async () => {
|
||||
const { encrypt, decrypt } = await import('@/utils/crypto')
|
||||
const encrypted = await encrypt('')
|
||||
const decrypted = await decrypt(encrypted)
|
||||
|
||||
expect(encrypted).toBeTruthy()
|
||||
expect(decrypted).toBe('')
|
||||
})
|
||||
|
||||
it('should produce different ciphertext each time for same input', async () => {
|
||||
const { encrypt } = await import('@/utils/crypto')
|
||||
const plaintext = 'same text'
|
||||
const encrypted1 = await encrypt(plaintext)
|
||||
const encrypted2 = await encrypt(plaintext)
|
||||
|
||||
expect(encrypted1).not.toBe(encrypted2)
|
||||
})
|
||||
|
||||
it('should handle Chinese characters', async () => {
|
||||
const { encrypt, decrypt } = await import('@/utils/crypto')
|
||||
const chinese = '你好,世界!加密测试'
|
||||
const encrypted = await encrypt(chinese)
|
||||
const decrypted = await decrypt(encrypted)
|
||||
|
||||
expect(decrypted).toBe(chinese)
|
||||
})
|
||||
|
||||
it('should produce Base64-like output', async () => {
|
||||
const { encrypt } = await import('@/utils/crypto')
|
||||
const encrypted = await encrypt('test')
|
||||
|
||||
// Base64 pattern
|
||||
expect(encrypted).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('frontend-backend compatibility', () => {
|
||||
it('should produce output in format compatible with backend CryptoService', async () => {
|
||||
const { encrypt } = await import('@/utils/crypto')
|
||||
const encrypted = await encrypt('{"test":"data"}')
|
||||
|
||||
// 前端加密格式: Base64(12-byte-IV || AES-GCM-ciphertext)
|
||||
// Base64 解码后长度应 >= 12 (IV)
|
||||
const decoded = atob(encrypted)
|
||||
expect(decoded.length).toBeGreaterThanOrEqual(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw on invalid Base64 input during decrypt', async () => {
|
||||
const { decrypt } = await import('@/utils/crypto')
|
||||
|
||||
await expect(decrypt('invalid-base64!!!')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* AES-256-GCM 加解密工具
|
||||
*
|
||||
* 所有加解密操作(API 请求/响应)均使用 PBKDF2-HMAC-SHA256
|
||||
* 从 VITE_ENCRYPTION_SECRET 派生 AES-256-GCM 密钥,与后端 CryptoService 保持一致。
|
||||
*
|
||||
* 输出格式: Base64( 12-byte-IV || AES-GCM-ciphertext )
|
||||
*/
|
||||
|
||||
const IV_LENGTH = 12
|
||||
const ALGORITHM = 'AES-GCM'
|
||||
const PBKDF2_ITERATIONS = 100_000
|
||||
const PBKDF2_SALT = new TextEncoder().encode('novalon-gym-manage-aes-salt-v1')
|
||||
|
||||
/**
|
||||
* 使用 PBKDF2 从密码短语派生 AES-256 密钥
|
||||
*/
|
||||
async function deriveKeyFromPassphrase(passphrase: string): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder()
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(passphrase),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
)
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt: PBKDF2_SALT, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: ALGORITHM, length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AES-256-GCM 密钥——始终使用 PBKDF2 从 VITE_ENCRYPTION_SECRET 派生。
|
||||
*/
|
||||
async function getApiKey(): Promise<CryptoKey> {
|
||||
const secret = import.meta.env.VITE_ENCRYPTION_SECRET as string | undefined
|
||||
if (!secret) {
|
||||
throw new Error('未设置 VITE_ENCRYPTION_SECRET 环境变量,加解密无法初始化')
|
||||
}
|
||||
return deriveKeyFromPassphrase(secret)
|
||||
}
|
||||
|
||||
let cachedApiKey: Promise<CryptoKey> | null = null
|
||||
|
||||
function getOrInitApiKey(): Promise<CryptoKey> {
|
||||
if (!cachedApiKey) {
|
||||
cachedApiKey = getApiKey()
|
||||
}
|
||||
return cachedApiKey
|
||||
}
|
||||
|
||||
/** 加密函数基座 */
|
||||
async function encryptWithKey(key: Promise<CryptoKey>, plaintext: string): Promise<string> {
|
||||
const k = await key
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
|
||||
const encoder = new TextEncoder()
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: ALGORITHM, iv, tagLength: 128 },
|
||||
k,
|
||||
encoder.encode(plaintext)
|
||||
)
|
||||
|
||||
const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength)
|
||||
combined.set(iv, 0)
|
||||
combined.set(new Uint8Array(ciphertext), IV_LENGTH)
|
||||
|
||||
return btoa(Array.from(combined, (b) => String.fromCodePoint(b)).join(''))
|
||||
}
|
||||
|
||||
/** 解密函数基座 */
|
||||
async function decryptWithKey(key: Promise<CryptoKey>, encryptedBase64: string): Promise<string> {
|
||||
const k = await key
|
||||
|
||||
const binaryStr = atob(encryptedBase64)
|
||||
const combined = new Uint8Array(binaryStr.length)
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
combined[i] = binaryStr.codePointAt(i)!
|
||||
}
|
||||
|
||||
const iv = combined.slice(0, IV_LENGTH)
|
||||
const ciphertext = combined.slice(IV_LENGTH)
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: ALGORITHM, iv, tagLength: 128 },
|
||||
k,
|
||||
ciphertext
|
||||
)
|
||||
|
||||
return new TextDecoder().decode(decrypted)
|
||||
}
|
||||
|
||||
/**
|
||||
* API 请求加密——使用 PBKDF2 派生密钥,与后端 CryptoService 保持一致。
|
||||
*/
|
||||
export async function encrypt(plaintext: string): Promise<string> {
|
||||
return encryptWithKey(getOrInitApiKey(), plaintext)
|
||||
}
|
||||
|
||||
/**
|
||||
* API 响应解密——使用 PBKDF2 派生密钥,与后端 CryptoService 保持一致。
|
||||
*/
|
||||
export async function decrypt(encryptedBase64: string): Promise<string> {
|
||||
return decryptWithKey(getOrInitApiKey(), encryptedBase64)
|
||||
}
|
||||
|
||||
export default { encrypt, decrypt }
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios, { InternalAxiosRequestConfig } from 'axios'
|
||||
import axios, { InternalAxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { generateSignatureHeaders } from './signature'
|
||||
import { checkApiPermission } from './permission'
|
||||
import { encrypt, decrypt } from './crypto'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -8,17 +9,17 @@ const request = axios.create({
|
||||
})
|
||||
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
|
||||
const method = config.method?.toUpperCase() || 'GET'
|
||||
let url = config.url || ''
|
||||
const body = config.data
|
||||
|
||||
|
||||
if (config.params && Object.keys(config.params).length > 0) {
|
||||
const queryParams = new URLSearchParams()
|
||||
Object.entries(config.params).forEach(([key, value]) => {
|
||||
@@ -31,13 +32,13 @@ request.interceptors.request.use(
|
||||
url += (url.includes('?') ? '&' : '?') + queryString
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const fullPath = `/api${url.startsWith('/') ? url : '/' + url}`
|
||||
const signatureHeaders = generateSignatureHeaders(method, fullPath, body)
|
||||
|
||||
|
||||
config.headers = config.headers || {}
|
||||
Object.assign(config.headers, signatureHeaders)
|
||||
|
||||
|
||||
if (!checkApiPermission(method, url)) {
|
||||
const error = new Error('无权限访问此接口')
|
||||
;(error as any).response = {
|
||||
@@ -46,15 +47,66 @@ request.interceptors.request.use(
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
|
||||
// ========== 请求体加密 ==========
|
||||
// 阻止 Axios 自动解析 JSON —— 加密后的响应体不是合法 JSON
|
||||
config.responseType = 'text'
|
||||
|
||||
if (config.data) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(config.data)
|
||||
config.data = await encrypt(jsonStr)
|
||||
// 加密成功:标记加密请求,后端收到后会加密响应体
|
||||
config.headers['X-Encrypted'] = 'true'
|
||||
// 覆盖 transformRequest 阻止 Axios 对加密后的 Base64 字符串再次 JSON.stringify
|
||||
config.transformRequest = [(data: any) => data]
|
||||
} catch (e: any) {
|
||||
// 加密失败(如密钥未配置),降级为明文 JSON 传输,不标记加密
|
||||
console.warn('[Crypto] Request body encryption failed:', e.message, '— sending plain JSON')
|
||||
// 不设置 X-Encrypted 头,后端将返回明文 JSON
|
||||
// 不覆盖 transformRequest,Axios 将正常 JSON.stringify
|
||||
}
|
||||
} else {
|
||||
// GET 等无 body 请求:仅标记加密让后端加密响应
|
||||
config.headers['X-Encrypted'] = 'true'
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
async (response: AxiosResponse) => {
|
||||
// 解密加密的响应体
|
||||
if (response.headers['x-encrypted'] === 'true' && typeof response.data === 'string') {
|
||||
try {
|
||||
const decryptedText = await decrypt(response.data)
|
||||
response.data = JSON.parse(decryptedText)
|
||||
} catch (e: any) {
|
||||
console.error('[Crypto] Response decrypt failed:', e.message)
|
||||
}
|
||||
} else if (typeof response.data === 'string') {
|
||||
// 响应未加密(CryptoFilter 因请求体解密失败而跳过加密),手动解析 JSON
|
||||
try {
|
||||
response.data = JSON.parse(response.data)
|
||||
} catch (e: any) {
|
||||
// 非 JSON 文本响应(如导出文件),保持原样
|
||||
}
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
async (error) => {
|
||||
// 解密加密的错误响应体,使业务代码能读取 message 字段
|
||||
if (error.response?.headers['x-encrypted'] === 'true' && typeof error.response.data === 'string') {
|
||||
try {
|
||||
const decryptedText = await decrypt(error.response.data)
|
||||
error.response.data = JSON.parse(decryptedText)
|
||||
} catch (e: any) {
|
||||
console.error('[Crypto] Error response decrypt failed:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SIGNATURE_SECRET: string
|
||||
readonly VITE_ENCRYPTION_SECRET: string
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user