Introduce
该文章提供后端基于Laravel,前端基于Axios(TypeScript)实现的数据传输加密与参数签名。
后端方法
注意事项
验签中间件在加密中间件前面
核心工具类
<?php
/*
* Copyright (c) 2025. Jerry midsmr@qq.com.
*/
namespace App\Utils;
use Exception;
class AESUtil
{
/**
* AES 加密方法
*
* @param string $data 要加密的数据
* @param string $key AES密钥
* @return string 加密后的Base64字符串
* @throws Exception
*/
public static function encrypt(string $data, string $key): string
{
$key = self::formatKey($key);
$iv = random_bytes(16); // 生成16字节的随机IV
$encrypted = openssl_encrypt(
$data,
'AES-256-CBC',
$key,
OPENSSL_RAW_DATA,
$iv
);
if ($encrypted === false) {
throw new Exception('AES encryption failed');
}
// 将IV和加密数据拼接后进行Base64编码
return base64_encode($iv . $encrypted);
}
/**
* AES 解密方法
*
* @param string $encryptedData Base64编码的加密数据
* @param string $key AES密钥
* @return string 解密后的原始数据
* @throws Exception
*/
public static function decrypt(string $encryptedData, string $key): string
{
$key = self::formatKey($key);
$data = base64_decode($encryptedData);
if ($data === false || strlen($data) < 16) {
throw new Exception('Invalid encrypted data');
}
// 提取IV和加密数据
$iv = substr($data, 0, 16);
$encrypted = substr($data, 16);
$decrypted = openssl_decrypt(
$encrypted,
'AES-256-CBC',
$key,
OPENSSL_RAW_DATA,
$iv
);
if ($decrypted === false) {
throw new Exception('AES decryption failed');
}
return $decrypted;
}
/**
* 生成API签名
*
* @param array $params 请求参数
* @param string $timestamp 时间戳
* @param string $key AES密钥
* @return string 签名字符串
*/
public static function generateSignature(array $params, string $timestamp, string $key): string
{
// 移除signature字段(如果存在)
unset($params['signature']);
unset($params['timestamp']);
// 参数排序
ksort($params);
// 拼接参数
$paramString = '';
foreach ($params as $k => $v) {
// 跳过空值参数
if ($v === null || $v === '') {
continue;
}
if (is_array($v)) {
$v = json_encode($v);
}
$paramString .= $k . '=' . $v . '&';
}
// 添加时间戳和密钥
$paramString .= 'timestamp=' . $timestamp . '&key=' . $key;
// 生成签名(使用SHA256)
return hash('sha256', $paramString);
}
/**
* 验证API签名
*
* @param array $params 请求参数
* @param string $signature 客户端传来的签名
* @param string $timestamp 时间戳
* @param string $key AES密钥
* @param int $expireTime 签名过期时间(秒),默认300秒
* @return bool 是否验证通过
*/
public static function verifySignature(
array $params,
string $signature,
string $timestamp,
string $key,
int $expireTime = 300
): bool {
// 验证时间戳是否过期
if (abs(time() - $timestamp) > $expireTime) {
return false;
}
// 生成服务端签名
$serverSignature = self::generateSignature($params, $timestamp, $key);
// 对比签名
return hash_equals($serverSignature, $signature);
}
/**
* 格式化密钥为32字节
*
* @param string $key 原始密钥
* @return string 格式化后的密钥
*/
private static function formatKey(string $key): string
{
// 如果密钥长度不是32字节,使用SHA256哈希
if (strlen($key) !== 32) {
return hash('sha256', $key, true);
}
return $key;
}
/**
* 生成随机AES密钥
*
* @param int $length 密钥长度,默认32字节
* @return string Base64编码的密钥
* @throws Exception
*/
public static function generateKey(int $length = 32): string
{
return base64_encode(random_bytes($length));
}
}
加密中间件
<?php
/*
* Copyright (c) 2025. Jerry midsmr@qq.com.
*/
namespace App\Http\Middleware;
use App\Http\Responses\JsonResponse;
use App\Utils\AESUtil;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse as LaravelJsonResponse;
use Exception;
class EncryptRequestResponse
{
/**
* 处理传入的请求并加密响应
*
* @param Request $request
* @param Closure $next
*/
public function handle(Request $request, Closure $next)
{
$key = config('app.aes_key');
if (empty($key)) {
return JsonResponse::fail('Missing AES key')->setStatusCode(500);
}
// 解密请求数据
// 注意:文件上传时,文件字段不会被加密,只加密其他表单数据
if ($request->has('encrypted_data') && !$request->hasFile('file')) {
try {
$encryptedData = $request->input('encrypted_data');
$decryptedJson = AESUtil::decrypt($encryptedData, $key);
$decryptedData = json_decode($decryptedJson, true);
if (json_last_error() !== JSON_ERROR_NONE) {
// Invalid encrypted data format
return JsonResponse::fail('请求失败')->setStatusCode(400);
}
// 将解密后的数据合并到请求中
$request->merge($decryptedData);
} catch (Exception $e) {
//Failed to decrypt request data
return JsonResponse::fail('请求失败')->setStatusCode(400);
}
}
// 处理请求
$response = $next($request);
// 加密响应数据
// 注意:文件下载响应(BinaryFileResponse, StreamedResponse)不会被加密
if ($response instanceof LaravelJsonResponse) {
try {
$originalData = $response->getData(true);
$jsonData = json_encode($originalData);
$encryptedData = AESUtil::encrypt($jsonData, $key);
$response->setData([
'encrypted_data' => $encryptedData
]);
} catch (Exception $e) {
//Failed to encrypt response data
return JsonResponse::fail('请求失败')->setStatusCode(500);
}
}
// 对于文件下载响应(BinaryFileResponse, StreamedResponse等),直接返回不加密
return $response;
}
}
验签中间件
<?php
/*
* Copyright (c) 2025. Jerry midsmr@qq.com.
*/
namespace App\Http\Middleware;
use App\Http\Responses\JsonResponse;
use App\Utils\AESUtil;
use Closure;
use Illuminate\Http\Request;
class ApiSignatureAuth
{
/**
* 处理传入的请求,验证API签名
*
* @param Request $request
* @param Closure $next
*/
public function handle(Request $request, Closure $next)
{
$key = config('app.aes_key');
if (empty($key)) {
return JsonResponse::fail('Missing AES key')->setStatusCode(500);
}
// 获取签名和时间戳
$signature = $request->header('x-signature') ?? $request->input('signature');
$timestamp = $request->header('x-timestamp') ?? $request->input('timestamp');
if (empty($signature) || empty($timestamp)) {
// Missing signature or timestamp
return JsonResponse::fail('请求失败')->setStatusCode(400);
}
// 验证时间戳格式
if (!is_numeric($timestamp)) {
// Invalid timestamp format
return JsonResponse::fail('请求失败')->setStatusCode(400);
}
// 获取所有请求参数
$params = $request->all();
// 移除签名相关字段,这些字段不参与签名计算
unset($params['signature']);
unset($params['timestamp']);
// 处理文件上传的签名验证
// 文件上传时,将文件替换为文件的 MD5 哈希值参与签名
if ($request->hasFile('file')) {
$file = $request->file('file');
if ($file && $file->isValid()) {
$params['file'] = md5_file($file->getRealPath());
}
}
// 如果是加密传输,签名只针对加密后的数据
// 前端流程:原始数据 -> 加密 -> 得到encrypted_data -> 对{encrypted_data: "xxx"}生成签名
// 后端验证:使用{encrypted_data: "xxx"}验证签名 -> 验签通过后再解密
if ($request->has('encrypted_data')) {
$params = ['encrypted_data' => $request->input('encrypted_data')];
}
// 验证签名
$isValid = AESUtil::verifySignature(
$params,
$signature,
$timestamp,
$key,
300 // 签名有效期5分钟
);
if (!$isValid) {
// Invalid signature or signature expired
return JsonResponse::fail('请求失败')->setStatusCode(400);
}
// 签名验证通过,继续处理请求
return $next($request);
}
}
前端方法
注意事项
请安装crypto-js与spark-md5
核心工具类
/*
* Copyright (c) 2025. Jerry midsmr@qq.com.
*/
import { AES, enc, lib, mode, pad, SHA256 } from 'crypto-js'
import { ArrayBuffer as SparkMD5ArrayBuffer } from 'spark-md5'
/**
* 格式化密钥为 32 字节
* @param key - 原始密钥
* @returns 格式化后的密钥
*/
function formatKey(key: string): lib.WordArray {
// 先将字符串转换为 UTF-8 字节
const keyBytes = enc.Utf8.parse(key)
// 如果密钥长度正好是 32 字节,直接使用
if (keyBytes.sigBytes === 32) {
return keyBytes
}
// 否则使用 SHA256 哈希生成 32 字节密钥(与 PHP 的 hash('sha256', $key, true) 对应)
return SHA256(key)
}
/**
* AES 加密方法
* @param data - 要加密的数据(JSON 字符串)
* @returns Base64 编码的加密数据
*/
export function encrypt(data: string): string {
const aesKey = import.meta.env.VITE_APP_AES || ''
// 格式化密钥
const key = formatKey(aesKey)
// 生成随机 IV(16字节)
const iv = lib.WordArray.random(16)
// 执行加密
const encrypted = AES.encrypt(data, key, {
iv: iv,
mode: mode.CBC,
padding: pad.Pkcs7,
})
// 将 IV 和加密数据拼接
const combined = iv.concat(encrypted.ciphertext)
// 返回 Base64 编码
return enc.Base64.stringify(combined)
}
/**
* AES 解密方法
* @param encryptedData - Base64 编码的加密数据
* @returns 解密后的原始数据
*/
export function decrypt(encryptedData: string): string {
const aesKey = import.meta.env.VITE_APP_AES || ''
// 格式化密钥
const key = formatKey(aesKey)
// Base64 解码
const combined = enc.Base64.parse(encryptedData)
// 提取 IV(前16字节)
const iv = lib.WordArray.create(combined.words.slice(0, 4))
// 提取加密数据(16字节之后)
const ciphertext = lib.WordArray.create(combined.words.slice(4))
// 执行解密
const decrypted = AES.decrypt({ ciphertext: ciphertext } as any, key, {
iv: iv,
mode: mode.CBC,
padding: pad.Pkcs7,
})
// 返回 UTF-8 字符串
return decrypted.toString(enc.Utf8)
}
/**
* 生成 API 签名
* @param params - 请求参数对象
* @param timestamp - Unix 时间戳(秒)
* @returns SHA256 签名字符串
*/
export function generateSignature(params: Record<string, any>, timestamp: number): string {
const aesKey = import.meta.env.VITE_APP_AES || ''
// 移除 signature 字段(如果存在)
const cleanParams = { ...params }
delete cleanParams.signature
delete cleanParams.timestamp
// 参数按 key 排序
const sortedKeys = Object.keys(cleanParams).sort()
// 拼接参数字符串
let paramString = ''
sortedKeys.forEach((key) => {
let value = cleanParams[key]
// 跳过 null、undefined 和空字符串
if (value === null || value === undefined || value === '') {
return
}
// 如果是对象或数组,转为 JSON 字符串
if (typeof value === 'object') {
value = JSON.stringify(value)
}
paramString += `${key}=${value}&`
})
// 添加时间戳和密钥
paramString += `timestamp=${timestamp}&key=${aesKey}`
// 生成 SHA256 签名
return SHA256(paramString).toString()
}
/**
* 计算文件的 MD5 值
* @param file - 文件对象
* @returns MD5 哈希值
*/
export function calculateFileMD5(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const blobSlice = File.prototype.slice || (File.prototype as any).mozSlice || (File.prototype as any).webkitSlice
const chunkSize = 2097152 // 2MB 分块
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5ArrayBuffer()
const fileReader = new FileReader()
fileReader.onload = function (e) {
spark.append(e.target?.result as ArrayBuffer)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
fileReader.onerror = function () {
reject(new Error('Failed to read file'))
}
function loadNext() {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}
loadNext()
})
}
Axios拦截器
/*
* Copyright (c) 2025. Jerry midsmr@qq.com.
*/
import axios, { type AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import HttpException from '../exceptions/HttpException.ts'
import { getSessionId, setSessionId } from '../utils'
import { calculateFileMD5, decrypt, encrypt, generateSignature } from '../utils/aes'
const request = axios.create({
baseURL: '/api/v1',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
})
/**
* 判断是否为文件上传请求
*/
function isFileUploadRequest(config: InternalAxiosRequestConfig): boolean {
return config.data instanceof FormData
}
/**
* 判断是否为文件下载请求
*/
function isFileDownloadRequest(config: InternalAxiosRequestConfig): boolean {
return config.responseType === 'blob' || config.responseType === 'arraybuffer'
}
request.interceptors.request.use(async function (config) {
if (getSessionId()) {
config.headers['x-session-id'] = getSessionId()
}
// 生成时间戳(秒)
const timestamp = Math.floor(Date.now() / 1000)
config.headers['x-timestamp'] = timestamp.toString()
// 文件上传:只签名,不加密
if (isFileUploadRequest(config)) {
const formData = config.data as FormData
const signParams: Record<string, any> = {}
// 计算文件的 MD5 值
const fileEntries: [string, File][] = []
formData.forEach((value, key) => {
if (value instanceof File) {
fileEntries.push([key, value])
} else {
signParams[key] = value
}
})
// 为每个文件计算 MD5
for (const [key, file] of fileEntries) {
const fileMD5 = await calculateFileMD5(file)
signParams[key] = fileMD5
}
// 生成签名
const signature = generateSignature(signParams, timestamp)
config.headers['X-Signature'] = signature
return config
}
// 文件下载:只签名,不加密
if (isFileDownloadRequest(config)) {
const params = config.params || {}
const signature = generateSignature(params, timestamp)
config.headers['x-signature'] = signature
return config
}
// 普通请求:加密 + 签名
if (config.data && Object.keys(config.data).length > 0) {
// 将数据转为 JSON 字符串
const jsonData = JSON.stringify(config.data)
// 加密数据
const encryptedData = encrypt(jsonData)
// 准备请求参数(用于签名)
const params = { encrypted_data: encryptedData }
// 生成签名
const signature = generateSignature(params, timestamp)
config.headers['x-signature'] = signature
// 替换请求体为加密数据
config.data = params
} else {
// 没有数据的请求也需要签名(如 GET 请求)
const params = config.params || {}
const signature = generateSignature(params, timestamp)
config.headers['x-signature'] = signature
}
return config
})
interface ParamsErrorResponse {
errors: {
[key: string]: string[]
}
}
request.interceptors.response.use(
function (response: AxiosResponse) {
if (response.headers['x-session-id']) {
setSessionId(response.headers['x-session-id'])
}
// 文件下载响应:直接返回,不解密
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
return Promise.resolve(response)
}
// 解密响应数据
if (response.data && response.data.encrypted_data) {
try {
const decryptedJson = decrypt(response.data.encrypted_data)
response.data = JSON.parse(decryptedJson)
} catch {
return Promise.reject(HttpException.of(500, '解密响应数据失败'))
}
}
return Promise.resolve(response)
},
function (error: AxiosError<ParamsErrorResponse>): Promise<HttpException> {
if (error.response) {
if (error.response.status === 422) {
const errorBag = error.response.data.errors
return Promise.reject(HttpException.of(422, errorBag[Object.keys(errorBag)[0]][0]))
}
if (error.response.headers['x-session-id']) {
setSessionId(error.response.headers['x-session-id'])
}
const errorMessage: Record<number, string> = {
401: '登录已过期,请重新登录',
403: '没有权限进行该操作',
404: '请求的资源不存在',
500: '服务器内部错误,请稍后再试',
}
return Promise.reject(HttpException.of(error.response.status, errorMessage[error.response.status] || '请求失败,请稍后再试'))
}
if (error.request) {
return Promise.reject(HttpException.of(0, '服务器未响应,请稍后再试'))
}
return Promise.reject(HttpException.of(0, '网络异常,请稍后再试'))
},
)
export default request