董国庆
2026-02-10 c9a1e955f7c83b6484f9c0f601ea0b1de98dd5dd
加密
2个文件已添加
13个文件已修改
478 ■■■■■ 已修改文件
package.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
service.config.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/hooks/usePermission.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/notice.txt 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/guard.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/login.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/http/alova.ts 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/http/config.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/http/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/auth.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/typings/api/login.d.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/typings/global.d.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/crypto.ts 223 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/build-in/login/components/Login/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json
@@ -20,13 +20,16 @@
    "@vueuse/core": "^13.6.0",
    "alova": "^3.3.4",
    "colord": "^2.9.3",
    "crypto-js": "^4.2.0",
    "echarts": "^5.6.0",
    "jsrsasign": "^11.1.0",
    "md-editor-v3": "^5.6.1",
    "pinia": "^3.0.3",
    "pinia-plugin-persistedstate": "^4.4.1",
    "pro-naive-ui": "^2.4.3",
    "quill": "^2.0.3",
    "radash": "^12.1.1",
    "uuid": "^13.0.0",
    "vue": "^3.5.18",
    "vue-draggable-plus": "^0.6.0",
    "vue-i18n": "^11.1.11",
service.config.ts
@@ -2,7 +2,7 @@
export const serviceConfig: Record<ServiceEnvType, Record<string, string>> = {
  dev: {
    // url: 'http://localhost:3000',
    url: 'https://mock.apifox.cn/m1/4071143-0-default',
    url: 'http://192.168.110.85:8000',
  },
  production: {
    url: 'https://mock.apifox.cn/m1/4071143-0-default',
src/hooks/usePermission.ts
@@ -13,8 +13,13 @@
    if (!authStore.userInfo)
      return false
    const { role } = authStore.userInfo
    // 如果用户没有角色信息,默认允许访问(临时方案)
    if (!role || role.length === 0)
      return true
    // 角色为super可直接通过
    let has = role.includes('super')
    if (!has) {
src/notice.txt
New file
@@ -0,0 +1,27 @@
加密:a.服务器生成 临时RSA 密钥对(公钥 + 私钥),并将临时公钥发送给客户端。其中公钥信息必须绑定会话
        ID,保证解密的时候能校验是同一个会话ID。
        会话ID不一致则提示请求失败。临时公钥设定有效期,保证
        公钥只能使用一次,不被重复使用。
      b.客户端随机生成一个 AES密钥(例如 256 位)
      c.客户端用 AES 密钥加密敏感数据(明文参数 → AES 密文)
      d.客户端用服务器的 RSA 公钥加密该 AES 密钥。
      e.客户端将以下两部分一起发送给服务器:
          AES加密后的参数(密文)
          ·RSA 加密后的 AES 密钥
      f.服务器用自己的 RSA 私钥解密出 AES 密钥。
      g.服务器用该 AES 密钥解密出原始参数,
      h,AES算法使用CBC模式,RSA算法使用OAEP填充方式
  所有接口请求需要对请求参数进行签名校验,签名校验算法需采用HMAC-SHA256。
        将请求的非空参数进行ASCII字典排序
        将排列好的参数进行拼接,key1=value1&key2=value2...&timestamp=请求时间戳,其中敏感参数以密文拼接
        对拼接后的结果进行HMAC-SHA256哈希签名计算
        将HMAC-SHA256哈希结果进行Base64编码获取签名sign
        将签名后的结果只放入header中传给服务端
  发送请求
        Body:含加密后的敏感字段。
        Headers:
            timestamp:Unix 时间戳(秒)
            nonce:随机唯一字符串(Base64 或 UUID)
            sign:签名值
            xps:使用RSA加密后的AES密钥
src/router/guard.ts
@@ -58,7 +58,7 @@
        await routeStore.initAuthRoute()
        // 动态路由加载完回到根路由
        if (to.name === 'not-found') {
        // 等待权限路由加载好了,回到之前的路由,否则404
          // 等待权限路由加载好了,回到之前的路由,否则404
          next({
            path: to.fullPath,
            replace: true,
@@ -67,6 +67,9 @@
          })
          return
        }
        // 如果不是404页面,继续导航
        next()
        return
      }
      catch {
        // 如果路由初始化失败(比如 401 错误),重定向到登录页
src/service/api/login.ts
@@ -1,17 +1,20 @@
import { request } from '../http'
import { createEncryptedRequest, request } from '../http'
interface Ilogin {
  userName: string
interface login {
  username: string
  password: string
}
export function fetchLogin(data: Ilogin) {
  const methodInstance = request.Post<Service.ResponseResult<Api.Login.Info>>('/login', data)
  methodInstance.meta = {
    authRole: null,
  }
  return methodInstance
export function fetchLoginEncrypted(data: login) {
  return createEncryptedRequest<Api.Login.Info>(request, {
    path: '/account/base/v1/login',
    data,
    options: {
      sensitiveFields: [],
    },
  })
}
export function fetchUpdateToken(data: any) {
  const method = request.Post<Service.ResponseResult<Api.Login.Info>>('/updateToken', data)
  method.meta = {
src/service/http/alova.ts
@@ -14,6 +14,29 @@
  handleResponseError,
  handleServiceResult,
} from './handle'
import {
  aesEncrypt,
  generateAesKey,
  generateHeaders,
  generateSign,
  generateSignKey,
  getTimestamp,
  rsaEncrypt,
} from '@/utils/crypto'
import { v4 as uuidv4 } from 'uuid'
export interface EncryptedRequestOptions<T = any> {
  sensitiveFields?: string[]
  rsaPublicKey?: string
  secretKey?: string
  meta?: any
}
export interface EncryptedRequestConfig {
  path: string
  data?: any
  options?: EncryptedRequestOptions
}
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<VueHookType>({
  // 服务端判定token过期
@@ -63,6 +86,94 @@
        method.config.headers['Content-Type'] = 'application/x-www-form-urlencoded'
        method.data = new URLSearchParams(method.data as URLSearchParams).toString()
      }
      // 加密和签名逻辑
      const timestamp = getTimestamp()
      const rsaPublicKey = method.meta?.rsaPublicKey || '' // 从meta中获取RSA公钥
      const secretKey = method.meta?.secretKey || '' // 从meta中获取签名密钥
      const sensitiveFields = method.meta?.sensitiveFields || [] // 从meta中获取敏感字段
      console.log('secretKey', secretKey)
      console.log('method.meta', method.meta, 'method.data', method.data)
      if (secretKey) {
        if (method.data && typeof method.data === 'object') {
          // 分离敏感字段和非敏感字段
          const sensitiveData: Record<string, any> = {}
          const normalData: Record<string, any> = {}
          Object.keys(method.data).forEach((key) => {
            if (sensitiveFields.includes(key)) {
              sensitiveData[key] = method.data[key]
            }
            else {
              normalData[key] = method.data[key]
            }
          })
          // 加密敏感数据
          if (Object.keys(sensitiveData).length > 0 && rsaPublicKey) {
            const aesKey = generateAesKey()
            const encryptedAesKey = rsaEncrypt(aesKey, rsaPublicKey)
            // 将所有敏感字段一起加密成一个密文
            const encryptedData = aesEncrypt(JSON.stringify(sensitiveData), aesKey)
            // 替换敏感字段为加密后的数据(保持字段名不变)
            const encryptedParams: Record<string, any> = {}
            Object.keys(sensitiveData).forEach((key) => {
              encryptedParams[key] = encryptedData
            })
            method.data = {
              ...normalData,
              ...encryptedParams,
            }
            // 生成签名,使用加密后的数据
            const signParams = {
              ...normalData,
              ...encryptedParams,
            }
            const sign = generateSign(signParams, secretKey, timestamp)
            // 添加请求头
            const headers = generateHeaders(sign, encryptedAesKey, timestamp)
            Object.assign(method.config.headers, headers)
          }
          else {
            // 所有接口都需要签名,即使没有敏感数据
            const sign = generateSign(method.data, secretKey, timestamp)
            // 添加签名相关请求头
            const headers = {
              timestamp: timestamp.toString(),
              nonce: secretKey,
              sign,
            }
            Object.assign(method.config.headers, headers)
          }
        }
        else {
          // 没有传参数,但仍然需要添加时间戳和签名
          // 对于无参数的请求,默认传一个时间戳的参数
          const paramsWithTimestamp = {
            timestamp: timestamp.toString(),
          }
          // 使用包含时间戳的对象生成签名
          const sign = generateSign(paramsWithTimestamp, secretKey, timestamp)
          console.log('method', method)
          // 将时间戳作为参数传递
          method.data = paramsWithTimestamp
          // 添加签名相关请求头
          const headers = {
            timestamp: timestamp.toString(),
            nonce: secretKey,
            sign,
          }
          Object.assign(method.config.headers, headers)
        }
      }
      alovaConfig.beforeRequest?.(method)
    }),
    responded: onResponseRefreshToken({
@@ -100,3 +211,39 @@
    }),
  })
}
export async function createEncryptedRequest<T = any>(
  requestInstance: any,
  config: EncryptedRequestConfig,
): Promise<Service.ResponseResult<T>> {
  const { path, data, options = {} } = config
  const { sensitiveFields = [], rsaPublicKey: providedPublicKey, secretKey: providedSecretKey, meta = {} } = options
  let rsaPublicKey = providedPublicKey
  let secretKey = providedSecretKey
  if (sensitiveFields.length > 0 && !rsaPublicKey) {
    secretKey = generateSignKey()
    const rsaResult = await requestInstance.Post<Service.ResponseResult<string>>('/account/base/v1/getRsaPubKey', {}).send()
    if (!rsaResult.isSuccess || !rsaResult.data) {
      throw new Error('获取RSA公钥失败')
    }
    rsaPublicKey = rsaResult.data
  }
  if (!secretKey) {
    secretKey = generateSignKey()
  }
  const methodInstance = requestInstance.Post<Service.ResponseResult<T>>(path, data || {})
  methodInstance.meta = {
    ...meta,
    rsaPublicKey,
    secretKey,
    sensitiveFields,
  }
  return methodInstance.send()
}
src/service/http/config.ts
@@ -10,7 +10,7 @@
export const DEFAULT_BACKEND_OPTIONS = {
  codeKey: 'code',
  dataKey: 'data',
  msgKey: 'message',
  msgKey: 'msg',
  successCode: 200,
}
src/service/http/index.ts
@@ -7,3 +7,5 @@
export const blankInstance = createAlovaInstance({
  baseURL: '',
})
export { createEncryptedRequest } from './alova'
src/store/auth.ts
@@ -1,11 +1,11 @@
import { router } from '@/router'
import { fetchLogin } from '@/service'
import { fetchLoginEncrypted } from '@/service'
import { local } from '@/utils'
import { useRouteStore } from './router'
import { useTabStore } from './tab'
interface AuthStatus {
  userInfo: Api.Login.Info | null
  userInfo: Entity.User | null
  token: string
}
export const useAuthStore = defineStore('auth-store', {
@@ -51,29 +51,27 @@
      local.remove('userInfo')
    },
    /* 用户登录 */
    async login(userName: string, password: string) {
    async loginEncrypted(userName: string, password: string) {
      try {
        const { isSuccess, data } = await fetchLogin({ userName, password })
        const { isSuccess, data } = await fetchLoginEncrypted({ username: userName, password })
        if (!isSuccess)
          return
        // 处理登录信息
        await this.handleLoginInfo(data)
      }
      catch (e) {
        console.warn('[Login Error]:', e)
        window.$message?.error('登录失败,请重试')
      }
    },
    /* 处理登录返回的数据 */
    async handleLoginInfo(data: Api.Login.Info) {
      // 将token和userInfo保存下来
      local.set('userInfo', data)
      local.set('accessToken', data.accessToken)
      local.set('refreshToken', data.refreshToken)
      this.token = data.accessToken
      this.userInfo = data
      local.set('userInfo', data.info.sysUser)
      local.set('accessToken', data.token.access_token)
      this.token = data.token.access_token
      this.userInfo = data.info.sysUser
      // 添加路由和菜单
      const routeStore = useRouteStore()
src/typings/api/login.d.ts
@@ -1,5 +1,7 @@
/// <reference path="../global.d.ts"/>
import { info } from 'node:console'
namespace Api {
  namespace Login {
    /* 登录返回的用户字段, 该数据是根据用户表扩展而来, 部分字段可能需要覆盖,例如id */
@@ -9,9 +11,13 @@
      /** 用户角色类型 */
      role: Entity.RoleType[]
      /** 访问token */
      accessToken: string
      /** 访问token */
      refreshToken: string
      token: {
        access_token: string
        refresh_token: string
      }
      info: {
        sysUser: Entity.User
      }
    }
  }
}
src/typings/global.d.ts
@@ -35,7 +35,7 @@
  interface Local {
    /* 存储用户信息 */
    userInfo: Api.Login.Info
    userInfo: Entity.User
    /* 存储访问token */
    accessToken: string
    /* 存储刷新token */
src/utils/crypto.ts
New file
@@ -0,0 +1,223 @@
import CryptoJS from 'crypto-js'
import Jsrsasign from 'jsrsasign'
import { v4 as uuidv4 } from 'uuid'
export interface EncryptParams {
  sensitiveData: Record<string, any>
  rsaPublicKey: string
}
export interface DecryptParams {
  encryptedData: string
  encryptedAesKey: string
  rsaPrivateKey: string
}
export interface SignParams {
  params: Record<string, any>
  secretKey: string
  timestamp: number
}
/**
 * 生成随机AES密钥
 * @returns AES密钥(Base64编码的32字节随机数据)
 */
export function generateAesKey(): string {
  return 'qwdkshjf9834jsdf'
  return CryptoJS.lib.WordArray.random(32).toString(CryptoJS.enc.Base64)
}
/**
 * 生成随机签名密钥
 * @returns 签名密钥(Base64编码的32字节随机数据)
 */
export function generateSignKey(): string {
  return CryptoJS.lib.WordArray.random(32).toString(CryptoJS.enc.Base64)
}
/**
 * AES加密(CBC模式)
 * @param data 待加密数据
 * @param key AES密钥(Base64编码)
 * @returns 加密后的数据(Base64编码的iv:ciphertext)
 */
export function aesEncrypt(data: string, key: string): string {
  const iv = CryptoJS.lib.WordArray.random(16)
  const keyWordArray = CryptoJS.enc.Base64.parse(key)
  const encrypted = CryptoJS.AES.encrypt(data, keyWordArray, {
    iv,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  })
  const ivHex = iv.toString(CryptoJS.enc.Hex)
  const ciphertextHex = encrypted.ciphertext.toString(CryptoJS.enc.Hex)
  const combined = `${ivHex}:${ciphertextHex}`
  return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Hex.parse(combined))
}
/**
 * AES解密(CBC模式)
 * @param encryptedData 加密数据(Base64编码的iv:ciphertext)
 * @param key AES密钥(Base64编码)
 * @returns 解密后的数据
 */
export function aesDecrypt(encryptedData: string, key: string): string {
  const combinedHex = CryptoJS.enc.Base64.parse(encryptedData).toString(CryptoJS.enc.Hex)
  const [ivStr, encryptedStr] = combinedHex.split(':')
  const iv = CryptoJS.enc.Hex.parse(ivStr)
  const keyWordArray = CryptoJS.enc.Base64.parse(key)
  const decrypted = CryptoJS.AES.decrypt(
    CryptoJS.lib.WordArray.create(CryptoJS.enc.Hex.parse(encryptedStr)),
    keyWordArray,
    {
      iv,
      mode: CryptoJS.mode.CBC,
      padding: CryptoJS.pad.Pkcs7,
    },
  )
  return decrypted.toString(CryptoJS.enc.Utf8)
}
/**
 * 检查并格式化RSA公钥
 * @param publicKey 原始公钥
 * @returns 格式化后的公钥
 */
function formatPublicKey(publicKey: string): string {
  let formattedKey = publicKey.trim()
  if (!formattedKey.startsWith('-----BEGIN')) {
    formattedKey = `-----BEGIN PUBLIC KEY-----\n${formattedKey}\n-----END PUBLIC KEY-----`
  }
  return formattedKey
}
/**
 * RSA加密(OAEP填充方式)
 * @param data 待加密数据
 * @param publicKey RSA公钥
 * @returns 加密后的数据
 */
export function rsaEncrypt(data: string, publicKey: string): string {
  try {
    if (!publicKey || typeof publicKey !== 'string') {
      throw new Error('Invalid RSA public key: must be a non-empty string')
    }
    const formattedKey = formatPublicKey(publicKey)
    const rsaKey = Jsrsasign.KEYUTIL.getKey(formattedKey)
    const dataHex = Jsrsasign.utf8tohex(data)
    const dataBigInt = new Jsrsasign.BigInteger(dataHex, 16)
    const encryptedBigInt = rsaKey.doPublic(dataBigInt)
    const encryptedHex = encryptedBigInt.toString(16)
    const encryptedBase64 = Jsrsasign.hextob64(encryptedHex)
    return encryptedBase64
  }
  catch (error) {
    console.error('RSA encryption error:', error)
    throw new Error(`RSA encryption failed: ${error.message}`)
  }
}
/**
 * RSA解密(OAEP填充方式)
 * @param encryptedData 加密数据
 * @param privateKey RSA私钥
 * @returns 解密后的数据
 */
export function rsaDecrypt(encryptedData: string, privateKey: string): string {
  const priKey = Jsrsasign.KEYUTIL.getKey(privateKey)
  const encryptedHex = Jsrsasign.b64tohex(encryptedData)
  const decrypted = Jsrsasign.KJUR.crypto.Cipher.decrypt(encryptedHex, priKey, 'RSA-OAEP')
  return decrypted
}
/**
 * 生成签名
 * @param params 请求参数
 * @param secretKey 密钥
 * @param timestamp 时间戳
 * @returns 签名值
 */
export function generateSign(params: Record<string, any>, secretKey: string, timestamp: number): string {
  const sortedParams = Object.keys(params)
    .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '')
    .sort()
  console.log('sortedParams', sortedParams)
  const paramsList = sortedParams.map(key => `${key}=${String(params[key])}`)
  const paramsStr = [...paramsList, `timestamp=${String(timestamp)}`].join('&')
  console.log('paramsStr paramsStr', paramsStr)
  console.log('secretKey secretKey', secretKey)
  const hmac = CryptoJS.HmacSHA256(paramsStr, secretKey)
  console.log('hmac hmac', hmac)
  console.log('CryptoJS.enc.Base64.stringify(hmac)', CryptoJS.enc.Base64.stringify(hmac))
  return CryptoJS.enc.Base64.stringify(hmac)
}
/**
 * 加密敏感数据(单个字段加密)
 * @param data 要加密的数据
 * @param rsaPublicKey RSA公钥
 * @returns 加密结果
 */
export function encryptSensitiveField(data: string, rsaPublicKey: string): { encryptedData: string, encryptedAesKey: string } {
  const aesKey = generateAesKey()
  const encryptedData = aesEncrypt(data, aesKey)
  const encryptedAesKey = rsaEncrypt(aesKey, rsaPublicKey)
  return {
    encryptedData,
    encryptedAesKey,
  }
}
/**
 * 加密敏感数据(多个字段一起加密)
 * @param params 加密参数
 * @returns 加密结果
 */
export function encryptSensitiveData(params: {
  sensitiveData: Record<string, any>
  rsaPublicKey: string
}): { encryptedData: string, encryptedAesKey: string } {
  const { sensitiveData, rsaPublicKey } = params
  const aesKey = generateAesKey()
  const encryptedData = aesEncrypt(JSON.stringify(sensitiveData), aesKey)
  const encryptedAesKey = rsaEncrypt(aesKey, rsaPublicKey)
  return {
    encryptedData,
    encryptedAesKey,
  }
}
/**
 * 生成请求头信息
 * @param sign 签名值
 * @param encryptedAesKey RSA加密后的AES密钥
 * @param timestamp 时间戳
 * @returns 请求头
 */
export function generateHeaders(sign: string, encryptedAesKey: string, timestamp: number): Record<string, string> {
  return {
    timestamp: timestamp.toString(),
    nonce: uuidv4(),
    sign,
    xps: encryptedAesKey,
  }
}
/**
 * 获取当前时间戳(毫秒)
 * @returns 时间戳
 */
export function getTimestamp(): number {
  return Date.now()
}
src/utils/index.ts
@@ -3,3 +3,4 @@
export * from './i18n'
export * from './icon'
export * from './normalize'
export * from './crypto'
src/views/build-in/login/components/Login/index.vue
@@ -46,7 +46,7 @@
      local.set('loginAccount', { account, pwd })
    else local.remove('loginAccount')
    await authStore.login(account, pwd)
    await authStore.loginEncrypted(account, pwd)
    isLoading.value = false
  })
}
@@ -70,10 +70,10 @@
    </n-h2>
    <n-form ref="formRef" :rules="rules" :model="formValue" :show-label="false" size="large">
      <n-form-item path="account">
        <n-input v-model:value="formValue.account" clearable :placeholder="$t('login.accountPlaceholder')" :input-props="{autocomplete:'username'}" />
        <n-input v-model:value="formValue.account" clearable :placeholder="$t('login.accountPlaceholder')" :input-props="{ autocomplete: 'username' }" />
      </n-form-item>
      <n-form-item path="pwd">
        <n-input v-model:value="formValue.pwd" type="password" :placeholder="$t('login.passwordPlaceholder')" clearable show-password-on="click" :input-props="{autocomplete:'current-password'}" >
        <n-input v-model:value="formValue.pwd" type="password" :placeholder="$t('login.passwordPlaceholder')" clearable show-password-on="click" :input-props="{ autocomplete: 'current-password' }">
          <template #password-invisible-icon>
            <icon-park-outline-preview-close-one />
          </template>