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:
2026-08-02 08:51:21 +08:00
parent 015cb0dc78
commit eb33755f23
16 changed files with 1178 additions and 10 deletions
+3
View File
@@ -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
+3
View File
@@ -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()
})
})
})
+110
View File
@@ -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 }
+62 -10
View File
@@ -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
// 不覆盖 transformRequestAxios 将正常 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')) {
+1
View File
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_SIGNATURE_SECRET: string
readonly VITE_ENCRYPTION_SECRET: string
readonly VITE_API_BASE_URL?: string
}