AJAX请求数据加解密优化

1,467 阅读7分钟

前些时候写了文章,记录了如何在Vue项目中解决数字解析精度丢失问题(点击查看)。在此篇文章的示例代码中,曾经涉及到了数据加密、解密的问题,当时由于是讲解数据精度丢失,所以没有对加解密作介绍。今天我们就来对加解密闹闹磕。

前端加密、解密我们可以使用 crypto-js 库来实现,它是一个JavaScript的加解密的工具包。 它支持多种算法:MD5、SHA1、SHA2、SHA3、RIPEMD-160 的哈希散列,以及进行 AES、DES、Rabbit、RC4、Triple DES 加解密。具体使用哪种算法,前端可以根据项目需要,配合后端完成。

其实今天我想给大家闹磕摆的其实是这样一个需求场景,在一个项目中(特别是后台管理项目),很多页面中的都涉及到增删改查操作,而我们目前遇到的需求就是,一些页面的查询操作(分页列表、详情)需要解密 (解密后还可能引起数据精度丢失的问题),而新增、编辑请求参数需要加密。如何才能优化这些代码,将这些页面的前端开发代码更加简洁、易于开发和维护呢。

最先我是这样实现的:

前端和后端约定好加密、解密的数据对象为整个json数据,而非某一个字段,避免需要处理的数据字段过多,一个个加解密太多繁琐。然后前端将加密、解密、处理精度丢失的方法($encrypt、$decrypt、$jsonbig.parse)定义在了公共js中,最后在需要使用他们的ajax请求中,调用其公共方法。

公共js方法定义加解密方法:

import { parse, stringify } from 'lossless-json'
// 对number值进行转换,如果不用这个方法,Number类型就变成LosslessNumber
const bigDataReviver = (key, value) => {
  if (value && value.isLosslessNumber) {
    if (Number.isSafeInteger(parseInt(value.value))) {
      return parseInt(value.value)
    }
    return value.value
  } else {
    return value
  }
}
Object.defineProperty(Vue.prototype, '$jsonbig', {
  value: {
    parse : (keyword,reviver=bigDataReviver)=>{
      return parse(keyword,reviver)
    },
    stringify
  }
})

const keyString = '加密秘钥'
// AES加密
Object.defineProperty(Vue.prototype, '$encrypt', {
  value:  (word, keyStr=keyString) => {
    if(!word) {
      return ''
    }
    let key = CryptoJS.enc.Utf8.parse(keyStr)
    let srcs = CryptoJS.enc.Utf8.parse(JSON.stringify(word));
    let encrypted = CryptoJS.AES.encrypt(srcs, key, {
      mode:CryptoJS.mode.ECB,
      padding: CryptoJS.pad.Pkcs7
    });
    return encrypted.toString()
  }
})

// AES解密
Object.defineProperty(Vue.prototype, '$decrypt', {
  value:  (word, keyStr=keyString) => {
    if(!word) {
      return {}
    }
    let key = CryptoJS.enc.Utf8.parse(keyStr)
    let decrypt = CryptoJS.AES.decrypt(word, key,
      {
        mode:CryptoJS.mode.ECB,
        padding: CryptoJS.pad.Pkcs7
      },
    )
    let decString = CryptoJS.enc.Utf8.stringify(decrypt).toString();
    return decString
  }
})

酒店管理页面(加解密应用页面):

export default {
  mixins: [pageMixin],
  setup: () => useAdmateAdapter(
    {
      urlPrefix: 'sot-admin-api/hotel/self-employed',
      axiosConfig: {
        u: {
          headers : { 'Content-Type': 'application/json' }
        },
        c: {
          headers : { 'Content-Type': 'application/json' }
        },
      },
      list: {
        filter: {
          reviewStatus: ''
        },
        dataAt: 'data.record'
      },
    }, 
    {
      afterOpenForm({data}) {
        this.$set(this.form,'data',this.$jsonbig.parse(this.$decrypt(data)))
        this.retrieve()
      },
      afterGetList({ data }){
        let arr = this.$jsonbig.parse(this.$decrypt(data.record))
        this.$set(this.list,'data',arr)
      }
    }
  ),
  methods: {
    submit () {
      this.$refs.formRef.$refs.elFormRef.validate(valid => {
        if (valid) {
          this.loadingOfSubmit = true
          // 提交表单加密
          this.form.data = this.$encrypt(this.form.data)
          this.submitForm()
            .then(_ => {
              this.form.show = false
              this.queryCountByReviewStatus()
            })
            .finally(_ => {
              this.loadingOfSubmit = false
            })
        } else {
          this.$swal.error('验证失败,请检查所填写项')
        }
      })
    },
  }
 }

上面一些无关代码已省略,只保留了使用加密、解密、处理精度丢失方法的使用场景(大家不要问我为什么一个增删改查页面写起来怎么这么简洁,只在setup里面简单配置了些参数就实现了这么多功能。其实这是我们曾经的一个同事,为了方便我们后台页面开发,结合后端返回数据格式规范,开发的一套前端js库,我们只需按照其定义的格式和方法,就能很方便的实现后台页面的增删改查操作,而不必每个页面都写一大堆重复的代码。这个管理后台助手js库,名为admate【查看】,还有一个配套的js库,名为kikimore【查看】,两者结合,事半功倍,二者都npm下载。感兴趣的可以自己去研究)。

到这里,其实我们已经实现了,按需调用公共方法加解密页面数据的需求。这个需求当时刚好就是我做的,由于当时时间比较仓促,忙着赶紧做完,然后开发其他的任务,加之当时需求涉及到的页面也才几个,开发完毕后,也没觉得有什么不妥,或者需要优化的。直到后来一个同事在开发新需求,同样需要加解密等,在使用的过程中,发现此功能还可继续优化,使其使用起来更加方便简洁。想知道他是怎么实现的么?我们接下来先讲一下为什么他觉得还需要继续优化,然后再接着讲他实现的原理和过程。

我们假设现在后台有好多个页面都有刚才我们提到的加解密场景,我们都按照上面定义的方法完成开发,我们会发现,基本上每个页面都会出现一些上面示例中afterOpenForm、afterGetList、submit等函数里面书写的逻辑,并且还是相同的,那能不能将他们封装起来呢,自动执行呢?

答案肯定是可以了!而且只要想到了在哪里去实现,其实并不复查。

我们只需在页面需要调用加解密的地方,ajax请求头设置加解密参数(比如我们项目,加密'is-encrypt'、解密'is-decrypt'),提醒ajax我要加密、解密了,然后再在公共ajax的配置文件(比如我们项目中axios的公共配置文件request.js)中,拦截axios的请求头,对包含加密'is-encrypt'标志的,进行加密逻辑处理;对返回数据,根据请求头是否包含解密'is-decrypt'标志,进行解密逻辑处理。

这样一通逻辑下来,确实要简洁不少。我们下面具体看看其实现逻辑吧。

ajax的配置文件request.js:

import axios from 'axios'
import SwalPreset from 'sweetalert2-preset'
import { pickDeepBy, encrypt, decrypt } from '@/utils'
import store from '@/store'
import { getToken, setToken } from '@/utils/auth'
import loginSilently from '@/views/login/components/loginSilently'
import relativeBaseURLHandler from './relativeBaseURLHandler'
import { at } from 'lodash-es'

const request = axios.create({
  baseURL: import.meta.env.VITE_APP_BASE_API,
  //withCredentials: true,
  timeout: 10000
})

request.interceptors.request.use(
  (config) => {
    relativeBaseURLHandler(config)

    if (config.data === undefined) {
      // 后端遗留问题: 不支持传空
      config.data = {}
    } else {
      // 过滤 data
      config.data = pickDeepBy(
        config.data,
        (v, k) => ![NaN, null, undefined].includes(v) && !k.startsWith('__')
      )
    }

    // 过滤 params
    if (config.params) {
      config.params = pickDeepBy(
        config.params,
        (v, k) => ![NaN, null, undefined].includes(v) && !k.startsWith('__')
      )
    }

    const token = getToken()
    if (token) {
      config.headers['Authorization'] = token
    }

    // 公共加密方法 is-encrypt 是否加密,传入isEncrypt就会加密参数
    if(config.headers['is-encrypt']) {
      delete config.data.isEncrypt
      config.data = encrypt(config.data)
    }

    return config
  },
  (e) => {
    // do something with request error
    console.log(e) // for debug
    return Promise.reject(e)
  }
)

// response interceptor
request.interceptors.response.use(
  /**
   * If you want to get http information such as headers or status
   * Please return  response => response
   */

  /**
   * Determine the request status by custom code
   * Here is just an example
   * You can also judge the status by HTTP Status Code
   */
  async (response) => {
    if (response.config.method === 'head') {
      return response
    }

    const res = response.data

    if (res.type) {
      if (res.type === 'application/vnd.ms-excel' || res.type === 'application/x-msdownload') {
        const url = window.URL.createObjectURL(new Blob([res]))
        const link = document.createElement('a')
        link.href = url
        let fileName = response.headers['content-disposition']?.replace('attachment;filename=', '')
        if (fileName) {
          fileName = decodeURIComponent(fileName)
        } else {
          fileName = new Date() + '.xlsx'
        }
        link.setAttribute('download', fileName)
        document.body.appendChild(link)
        link.click()
        return
      }
      return res
    }

    const token = response.headers['right-token']
    token && setToken(token)

    if (res.errorCode === '00000') {
      // 判断该接口数据是否需要解密
      const isDecrypt = response.config.headers['is-decrypt']
      if(isDecrypt) {
        // 判断需要解密的字段
        let pix = 'data'
        if(isDecrypt != 'true') {
          pix = isDecrypt
        }
        let val = at(res, pix)[0] // 后端返回的数据,可能会没有加密的字段,这个时候前端先返回null
        res[pix] = val ? decrypt(val) : null
      }
      return res
    } else {
      if (res.errorCode === '00002') {
        store.dispatch('user/logout')
      } else if (res.errorCode === '00003') {
        let isLogin = store.getters.isLogin // 是否是在已登录状态
        if (isLogin) {
          store.dispatch('user/logout', true) // 如果已登录的情况下登录失效,不必跳到登录页

          await SwalPreset.confirm({
            title: '由于您长时间未操作,\n为了您的账号安全,请重新登录。',
            icon: 'info',
            showCancelButton: false,
            allowOutsideClick: false,
            allowEscapeKey: false
          })

          await loginSilently().then((res) => {
            location.reload()
          }) // 当前页面调起登录,重新登录
        } else {
          store.dispatch('user/logout') // 未登录的情况下登录失效,直接跳转到登录界面
        }
      } else {
        res.message && SwalPreset.error(res.message)
      }

      return Promise.reject(res)
    }
  },
  (e) => {
    if (e.message?.includes('timeout')) {
      e.message = '网络超时'
    } else if (e.message === 'Network Error') {
      e.message = '网络错误'
    }
    e.message && SwalPreset.error(e.message)
    return Promise.reject(e)
  }
)

export default request

公共js方法定义加解密方法(utils/index.js):

import settings from '@/settings.js'

// CryptoJS加密
export const encrypt = (word) => {
  if(!word) {
    return ''
  }
  let key = CryptoJS.enc.Utf8.parse(settings.cryptoJSKeyString)
  let srcs = CryptoJS.enc.Utf8.parse(JSON.stringify(word));
  let encrypted = CryptoJS.AES.encrypt(srcs, key, {
    mode:CryptoJS.mode.ECB,
    padding: CryptoJS.pad.Pkcs7
  });
  return encrypted.toString()
}


// CryptoJS解密
export const decrypt = (word)=>{
  if(!word) {
    return {}
  }
  let key = CryptoJS.enc.Utf8.parse(settings.cryptoJSKeyString)
  let decrypt = CryptoJS.AES.decrypt(word, key,
    {
      mode:CryptoJS.mode.ECB,
      padding: CryptoJS.pad.Pkcs7
    },
  )
  let decString = CryptoJS.enc.Utf8.stringify(decrypt).toString();

  return parse(decString , (key, value) => {
    if (value && value.isLosslessNumber) {
      // if (Number.isSafeInteger(parseInt(value.value))) {
      //   return parseInt(value.value)
      // }
      // return value.value
      
      // 原来的数字处理方式似乎有问题,改为下面这种
      return value.valueOf()
    } else {
      return value
    }
  })
}
// 我同事在修改解密方法的过程中,直接在解密数据的同时,将处理数据溢出的方法直接作用到解密的方法中了。

实际应用:

还是上面提到酒店管理页面:

export default {
  mixins: [pageMixin],
  setup: () => useAdmateAdapter(
    {
      urlPrefix: 'sot-admin-api/hotel/self-employed',
      axiosConfig: {
        getList: {
          headers : { 
            'is-decrypt': 'data.record'
          }
        },
        r: {
          headers : { 
            'is-decrypt': 'data'
          }
        },
        u: {
          headers : { 'Content-Type': 'application/json', 'is-encrypt': true }
        },
        c: {
          headers : { 'Content-Type': 'application/json', 'is-encrypt': true }
        },
      },
      list: {
        filter: {
          reviewStatus: ''
        },
        dataAt: 'data.record'
      },
        show: false,
        status: 'c'
      },
    }
  ),
  methods: {
    submit () {
      this.$refs.formRef.$refs.elFormRef.validate(valid => {
        if (valid) {
          this.loadingOfSubmit = true
          // 这里调用加密的代码没有了
          // this.form.data = this.$encrypt(this.form.data)
          this.submitForm()
            .then(_ => {
              this.form.show = false
              this.queryCountByReviewStatus()
            })
            .finally(_ => {
              this.loadingOfSubmit = false
            })
        } else {
          this.$swal.error('验证失败,请检查所填写项')
        }
      })
    },
  }
 }

看到这里,打开详情弹框后afterOpenForm、获取到分页列表数据后afterGetList、提交表单submit等函数里面书写的逻辑,通通自动在request中处理了,我们只在需要加解密的地方,在headers头部中配置了加解密标志,就轻松实现了。

不用admate库的使用示例:

export default {
  watch: {
    '$attrs.id': {
      handler: function(newID) {
        if (newID) {
          this.hotelId = newID
          this.row.show = true
          this.queryForDetail()
        }
      },
      immediate: true
    },
  },
  methods: {
    queryForDetail() {
      this.$POST('sot-admin-api/hotel/self-employed/queryForDetail', { 'id': this.hotelId }, { headers : { 'is-decrypt': 'data'} }).then(_ => {
        this.hotelName = _.data?.hotelName
      })
    }
  }
}

另一个

export default {
  methods: {
    submit() {
      if (this.params.type == 'add') {
        this.integration()
        return this.$POST('/sot-admin-api/merchant/merchant/add', this.form, {
          headers: { 'Content-Type': 'application/json', 'is-encrypt': true }
        })
          .then((data) => {
            this.$swal.success('保存成功')
            this.dialogVisible = false
            this.$emit('save')
          })
      } else if (this.params.type === 'edit') {
        this.integration()
        return this.saveData(this.form)
      }
    },
  }
}