J4ckey
J4ckey
发布于 2025-10-29 / 28 阅读
0

Laravel实现接口加密与接口签名

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-jsspark-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