前些时候写了文章,记录了如何在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)
}
},
}
}