记录一次接口加密的实现方案
隔了三个月才写了这篇文章,实在是莫得时间去写,踩坑很多但是输出很少,后面有时间也会多记录一些自己的踩坑经历,要是能给各位同学有所帮助那是最好的了,废话不多说,进入正题了。
背景介绍
由于部门业务体量正在提升,为了防止数据盗取或者外部攻击,对接口进行加密提上了日程,部门的大佬们也讨论了各种加密方式,考虑各种情况,最终敲定了方案。说到我们常用的数据加密方法,方式是各种各样的,根据我们实际的业务需求,我们可以选择其中的一种或者几种方式方法进行数据加密处理。
- 加密方法:常用的AES,RSA,MD5,BASE64,SSL等等;
- 加密方式:单向加密,对称加密,非对称加密,加密盐,数字签名等等;
首先我们来简单分析一下上面说到的这几种加密有什么区别吧:
- AES加密:对称加密的方法,加解密使用相同的加密规则,密钥最小能够支持128,192,256位(一个字节8位,后面我使用的是16位字符);
- RSA加密:非对称加密的方法,加解密使用一对公钥私钥进行匹配,客户端使用公钥加密,服务端使用私钥进行解密;
- MD5加密:单向加密,加密后不可解密,只能通过相同的数据进行相同的加密再与库中数据进行对比;
- BASE64:一种数据编码方式,伪加密,把数据转化为BASE64的编码形式,通过A-Z,a-z,0-9,+,/ 共64个字符对明文数据进行转化;
- SSL加密:https协议使用的加密方式,使用多种加密方式进行加密(具体使用哪些,我也不了解,感兴趣的同学可以去搜一下告诉我哈);
想要详细了解各类加密方式方法的同学,可以自行百度一下哈,这里就不进行赘述了,之后就来详细讲一下本次使用的加密方式。本次为了更加全面加密,使用了AES,RSA,以及加密盐,时间戳,BASE64与BASE16转化等方式进行加密处理。
请求体AES加密
请求体使用AES的对称加密方式,每次接口请求会随机生成一个16位的秘钥,使用秘钥对数据进行加密处理,返回的数据也会使用此秘钥进行解密处理。
import CryptoJs from 'crypto-js'// AES加密库
import { weAtob } from './weapp-jwt' // atob方法
// 请求体加密方法
export const encryptBodyEvent = (data, aeskey, isEncryption) => {
// 请求体内容
const wirteData = {
data: data, // 接口数据
token: Taro.getStorageSync("authToken"), // token 校验
nonce: randomNumberEvent(32), // 32位随机数,接口唯一随机数,可查询服务日志
timestamp: new Date().getTime, // 时间戳,用于设置接口调用过期时间
}
const encryptBodyJson = CryptoJs.AES.encrypt(JSON.stringify(wirteData), CryptoJs.enc.Utf8.parse(aeskey), {
mode: CryptoJs.mode.ECB,
padding: CryptoJs.pad.Pkcs7
}).toString()
// 判断接口是否需要加密
// 服务接收BASE16数据,Base64toHex方法为BASE64转化为BASE16方法
return isEncryption ? Base64toHex(encryptBodyJson) : wirteData
}
// BASE64转化BASE16方法
function Base64toHex (base64) {
let raw = weAtob(base64)
let HEX = ""
for (let i=0; i < raw.length; i++) {
let _HEX = raw.charCodeAt(i).toString(16)
HEX = (_HEX.length == 2 ? _HEX : "0" + _HEX)
}
return HEX
}
// 生成n位随机数,默认生成16位
function randomNumberEvent (length = 16) {
let str = ""
let arr = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']
// 随机产生
for(let i=0; i < length; i++){
let pos = Math.round(Math.random() * (arr.length-1));
str += arr[pos];
}
return str
}
- mode是分组加密模式:有五种模式(ECB、CBC、CFB、OFB、CTR),这里我们使用最简单的ECB模式,明文分组加密之后的结果将直接成为密文分组,对其他几种模式感兴趣的可以去搜索一下几种模式的区别;
- padding是填充模式:正常的加密后的字节长度不可能刚刚好满足固定字节的对齐(块大小),所以需要进行一定的填充,常用的有三种模式(PKCS7、PKCS5、Zero,还有其他模式),这里我们使用的是PKCS7模式。假设数据长度需要填充n个字节才对齐,那么填充n个字节,每个字节都是n;假设数据本身就已经对齐了,则填充一个长度为块大小的数据,每个字节都是块大小;
- weAtob即小程序使用的atob方法:atob是JS的一个全局函数,用于将BASE64编码转化为原始字符串,在正常的H5项目中atob可以直接使用,但是在小程序中此方法不可用,因此使用一个手动实现的方式(文件就不上传了,电脑是加密的,上传也是乱码,网上也是能找到类似的方法);
- timestamp是用于防止过期调用:这里的时间是为了展示方便直接使用客户端时间,实际是会调用一个服务端的接口获取服务器时间进行时间校准,防止客户端手动修改时间,服务端设置过期时间,会根据传入的时间判断是否过期;
请求头RSA加密
看完上面的请求体加密,我们会想到一个问题,就是我们的aesKey是客户端随机生成的,但是服务端也需要这个aesKey进行数据的加解密,那么我们通过什么形式传给服务端呢?因此我们在请求头中设置一个secret-key字段,使用RSA中的公钥对aesKey进行加密,服务端使用对应私钥进行解密;
// import JSEncrypt from 'jsencrypt' // RSA加密库,小程序不支持
import WxmpRsa from 'wxmp-rsa' // RSA加密库,小程序支持
let public_key = 'xxxxxxxxxxxxxxxx' // 公钥
// 请求头加密方法
export const randomKeyEvent = (aesKey) => {
// JSEncrypt方法小程序不可用
// const RSAUtils = new JSEncrypt() // 新建JSEncrypt对象
// RSAUtils.setPublicKey(public_key) // 设置公钥
// return RSAUtils.encrypt(aesKey).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
const RSAUtils = new WxmpRsa() // 新建WxmpRsa对象
RSAUtils.setPublicKey(public_key) // 设置公钥
// 进行RSA加密后,生成字符串中的部分特殊字符在服务端会被自动转化为空格,导致解密失败,所以先进行转换处理
return RSAUtils.encryptLong(aesKey).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
}
- JSEncrypt在小程序不可用是由于库里面存在window对象以及navigator对象,但是小程序没有对应的方法,所以使用了一个优化后的wxmp-rsa库;
- replaceAll处理字符是因为RSA加密后,生成字符串中的部分特殊字符传给服务端会被自动转化为空格,导致解密失败,所以需要进行转换处理,为了兼容低版本replaceAll方法不支持可使用replace加正则进行替换;
返回体AES解密
服务端返回的数据内容使用了相同的AES加密方法,因此也需要使用AES进行数据解密处理,并且返回的数据是BASE16,因此还需要进行一次编码转换处理;
// 返回体解密方法
export const decryptBodyEvent = (data, aeskey) => {
// HexToBase64为BASE16转化为BASE64方法
const responseData = CryptoJs.AES.decrypt(HexToBase64(data), CryptoJs.enc.Utf8.parse(aeskey), {
mode: CryptoJs.mode.ECB,
padding: CryptoJs.pad.Pkcs7
}).toString(CryptoJs.enc.Uth8)
return JSON.parse(responseData)
}
// base16转base64 网上找个一个方法,应该有其他简单的实现方式
function HexToBase64 (sha1) {
var digits = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
var base64_rep = ""
var ascv
var bit_arr = 0
var bit_num = 0
for (var n = 0; n < sha1.length; ++n) {
if (sha1[n] >= 'A' && sha1[n] <= 'Z') {
ascv = sha1.charCodeAt(n) - 55
} else if (sha1[n] >= 'a' && sha1[n] <= 'z') {
ascv = sha1.charCodeAt(n) - 87
} else {
ascv = sha1.charCodeAt(n) - 48
}
bit_arr = (bit_arr << 4) | ascv
bit_num += 4
if (bit_num >= 6) {
bit_num -= 6
base64_rep += digits[bit_arr >>> bit_num]
bit_arr &= ~ (-1 << bit_num)
}
}
if (bit_num > 0) {
bit_arr <<= 6 - bit_num
base64_rep += digits[bit_arr]
}
var padding = base64_rep.length % 4
if (padding > 0) {
for (var n = 0; n < 4 - padding; ++n) {
base64_rep += "="
}
}
return base64_rep
}
封装接口
因为是小程序项目,使用的是Taro框架进行封装的,Vue中使用axios封装其实也是类似的,还封装了一套ajax的方法,除了接口这里封装有区别,加密都是类似的。
const baseUrl = 'https://xxx.xxx.com' // 接口请求头
// Taro封装接口方法
export const requestEncHttp ({url, data, isEncryption = true}) => {
// 每次调用都会随机生成一个动态的aesKey,防止接口被复用
const aesKey = randomNumberEvent()
return new Promise((resolve, reject) => {
Taro.request({
method: "POST",
header: {
"content-type": "application/json",
"secret-key": isEncryption ? randomKeyEvent(aesKey): ''
},
dataType: 'text',
data: encryptBodyEvent(data, aesKey, isEncryption),
url: baseUrl + url,
success: (result) => {
if(result.status === 200) {
resolve(isEncryption ? decryptBodyEvent(result.data, aesKey) : JSON.parse(result.data))
} else {
reject(result)
}
}, fail: (err) => {
reject(err)
}
})
})
}
- dataType使用text:ajax没有此问题,Taro框架会出现接口有返回数据,但是在success中接收不到数据,因为数据是BASE16形式,Taro封装的数据返回格式默认应该是JSON的,所以要单独设置一下。
总结
加密的方式有很多,这篇文章也只是浅尝即止,想更加详细了解的同学可以再搜一些大佬们的总结文章,我这里也只是结合业务做了一点总结,标注一下踩坑点;
- 刚开始本来是准备一个月最少写一篇的,但是由于八月份刚换工作,不太有时间去写,所以也是一直拖着;而且是用公司电脑写的这篇文章,所以代码都没有直接粘贴过来,可能会存在疏漏,请多多包涵哈;
来源:juejin.cn/post/7298160530291490828