前后端分离模式的API混合加密
前后端分离模式的API混合加密
为什么要进行数据加密
当我们制作一些信息浏览网站,如课程表,信息网站,博客论坛时,通常逻辑为前端(客户端)向后端发送请求,后端(服务端)将处理后的数据返回给前端,在前后端分离的开发模式下,我们常常在前端只要调用后端提供的接口即可完成业务交互。然而,在享受前后端分离的模块化,规格化,便捷化的同时,只需要简单的用爬虫模拟发送浏览器请求,就可以获取接口的对应数据。后端辛辛苦苦写的逻辑处理的数据被他人直接盗用不说,用户相关信息的泄露更是对信息安全的极大威胁。所以对数据的加密极为重要。
前端加密的困难与意义
相对于后端直接在服务端运行,被爬取的成本与难度都比较大;反观前端,如一句话所说:“前端没有秘密可言”。在浏览器发展越来越快,与开发者模式越来越强的今天,即使对js进行混淆,浏览器也会自动将其格式化,若有人有心想破解,付出一定程度的时间成本,也可读懂代码逻辑。
总结:前端没有绝对的加密,但对前端的加密并非没有意义,增大破解难度与成本让人放弃对网站的监听与爬取数据是前端加密与混淆的意义所在。
数据加密
AES加密
回到之前的问题,我们如何保护自己的信息安全?我们可以想到一个很直观的解决逻辑:爬虫通过模拟用户发送前端(客户端)请求获得后端(服务端)响应,既然如此,我们可以将接口返回给前端的明文信息加密,由明文传输转为密文传输。在信息返回给前端前,将信息加密。这样,即使被人抓包调用接口,获取到的数据也是加密后的。所以我们仅需要一个可靠的加密方案,这里我们选择AES加密算法。
简单介绍AES加密
AES加密为对称加密的一种,具有安全性高,加解密速度快的优势,靠人为设定加密模式和对应的一定长度的字符串为密钥来进行加解密操作。理论上,在不知道密钥key的情况下,极难破解加密后的数据。(对称加密,即数据的加密与解密都使用同一个密钥。)
AES加密的后端实现
在这里,我们使用java来实现一个AES加解密的工具类,具体实现代码如下:
package cn.edu.cust.util;
import com.alibaba.druid.util.StringUtils;
import org.apache.commons.codec.binary.Base64;
import sun.misc.BASE64Decoder;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigInteger;
import java.util.Random;
/**
* @author 来自网络 and updated by 易奔二
*/
public class AesUtil {
//加密算法
private static final String ALGORITHMSTR = "AES/ECB/PKCS5Padding";
//AES密钥,自定义,按当前加密算法需指定16长度的字符串
private static final String AES_KEY = "ytdxcmqwbQS=@phr";
/**
* 随机生成Aes密钥
* @author 易奔二
* @return aes密钥
*/
public static String genAesKey(){
String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
Random random = new Random();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 16; i++) {
int number = random.nextInt(str.length());
sb.append(str.charAt(number));
}
return sb.toString();
}
/**
* aes解密
*
* @param encrypt 内容
* @return
* @throws Exception
*/
public static String aesDecrypt(String encrypt) {
try {
return aesDecrypt(encrypt, AES_KEY);
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
/**
* aes加密
*
* @param content
* @return
* @throws Exception
*/
public static String aesEncrypt(String content) {
try {
return aesEncrypt(content, AES_KEY);
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
/**
* 将byte[]转为各种进制的字符串
*
* @param bytes byte[]
* @param radix 可以转换进制的范围,从Character.MIN_RADIX到Character.MAX_RADIX,超出范围后变为10进制
* @return 转换后的字符串
*/
public static String binary(byte[] bytes, int radix) {
return new BigInteger(1, bytes).toString(radix);// 这里的1代表正数
}
/**
* base 64 encode
*
* @param bytes 待编码的byte[]
* @return 编码后的base 64 code
*/
public static String base64Encode(byte[] bytes) {
return Base64.encodeBase64String(bytes);
}
/**
* base 64 decode
*
* @param base64Code 待解码的base 64 code
* @return 解码后的byte[]
* @throws Exception
*/
public static byte[] base64Decode(String base64Code) throws Exception {
return StringUtils.isEmpty(base64Code) ? null : new BASE64Decoder().decodeBuffer(base64Code);
}
/**
* AES加密
*
* @param content 待加密的内容
* @param encryptKey 加密密钥
* @return 加密后的byte[]
* @throws Exception
*/
public static byte[] aesEncryptToBytes(String content, String encryptKey) throws Exception {
KeyGenerator kgen = KeyGenerator.getInstance("AES");
kgen.init(128);
Cipher cipher = Cipher.getInstance(ALGORITHMSTR);
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(encryptKey.getBytes(), "AES"));
return cipher.doFinal(content.getBytes("utf-8"));
}
/**
* AES加密为base 64 code
*
* @param content 待加密的内容
* @param encryptKey 加密密钥
* @return 加密后的base 64 code
* @throws Exception
*/
public static String aesEncrypt(String content, String encryptKey) throws Exception {
return base64Encode(aesEncryptToBytes(content, encryptKey));
}
/**
* AES解密
*
* @param encryptBytes 待解密的byte[]
* @param decryptKey 解密密钥
* @return 解密后的String
* @throws Exception
*/
public static String aesDecryptByBytes(byte[] encryptBytes, String decryptKey) throws Exception {
KeyGenerator kgen = KeyGenerator.getInstance("AES");
kgen.init(128);
Cipher cipher = Cipher.getInstance(ALGORITHMSTR);
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(decryptKey.getBytes(), "AES"));
byte[] decryptBytes = cipher.doFinal(encryptBytes);
return new String(decryptBytes);
}
/**
* 将base 64 code AES解密
*
* @param encryptStr 待解密的base 64 code
* @param decryptKey 解密密钥
* @return 解密后的string
* @throws Exception
*/
public static String aesDecrypt(String encryptStr, String decryptKey) throws Exception {
return StringUtils.isEmpty(encryptStr) ? null : aesDecryptByBytes(base64Decode(encryptStr), decryptKey);
}
}
将代码放入工具类后,我们便可以直接调用:
@ResponseBody
@RequestMapping("/exam")
private Object exam(HttpServletRequest request){
String card = request.getRemoteUser();
//获取处理后要返回的数据
Object value = examCache(card, false);
//将要响应给前端的数据加密后返回给后端
String aesJson = AesUtil.aesEncrypt(JSON.toJSONString(value));
return aesJson;
}
此时,我们传达给前端的响应不再是明文,而是加密后的数据。
AES解密的前端实现
数据加密后,我们再通过前端将其解密,导入aes.js后即可自己编写相应的解密方法。aes.js文件请自在网络自行搜索下载:
//定义一个全局变量
var key = "ytdxcmqwbQS=@phr";
//根据下载的aes.js写自己的解密js(因为每人下载的aes.js可能封装不同,所以代码仅供参考)
function aesDecrypt(encryptedStr) {
var encryptedHexStr = CryptoJS.enc.Base64.parse(encryptedStr);
var encryptedBase64Str = CryptoJS.enc.Base64.stringify(encryptedHexStr);
//key与aes加密算法需与后端一致
var decryptedData = CryptoJS.AES.decrypt(encryptedBase64Str, key, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
return decryptedData.toString(CryptoJS.enc.Utf8);
}
此时我们处理调用后端接口时,便可得到解密后的数据:
//获取考试信息
$.ajax({
url: 'exam',
data: {},
//解密后已经是json了,不需要重复转换
// dataType: 'json',
async: false,
success: function (data5) {
//解密并处理对应json
data5 = eval(aesDecrypt(data5));
}
})
如此,我们便在前端获得了正确的明文数据。
修正与改进
通过后端加密,前端解密的逻辑固然能让爬虫无法监听到正确的明文数据,但如之前所说:前端没有秘密可言。因密钥直接写在js文件中,想爬取数据的人只需进入浏览器开发者模式,便可获得你的aes密钥,只要用该密钥自动解密获取的接口数据,我们的接口数据仍没有安全可言,该方法所谓防君子不防小人。因此,我们需要想办法隐藏前端aes密钥,为此我们可以使用JS混淆来加密我们的信息。下面给大家推荐我常用的js混淆网站,点击进入
总结
至此,我们对前后端已经有了一个基础的加密,但在浏览器功能日益强大的今天,混淆代码也并不能保证我们的密钥一定被安全的隐藏。在对方知道我们的加密方式后,付出一定的时间成本,想在混淆代码中找到关于密钥key的字符串并不是很困难的事,那我们之前做的都没有意义了吗?也并不是,当对方要获取你数据的成本越高,你的网站便越安全,既然对面解密成本并不高,那我们便提高他们的解密成本,当然,你加密的同时也会增加你自己的成本。如果你的数据安全重要性并不高,也可以选择不继续下去。如果你重视数据的安全与保护,那么可按照如下逻辑方法对之前逻辑进行改进:
- 运用dh密钥协商算法,实现前后端在不进行密钥信息传递的情况下,每次运行生成相同的随机aes密钥。
- 运用rsa非对称加密,将后端随机生成的aes密钥加密发送给前端。
两种方法大概思路都是在密钥不暴露的情况下,生成动态密钥,这样,即便对方用浏览器调试获取了你的AES密钥,网站刷新后,密钥便发生改变,原来的密钥也失去了作用,爬取数据的成本将大大提高。在这里,我们主要讲解第二种方法。
RSA加密
简单介绍RSA加密
RSA加密为非对称加密的一种,安全性高但加解密速度较慢,尤其是对长文本,不能加解密比密钥大小长的字符串,因此,我们使用RSA加解密长文本时,通常采用分段加/解密再拼接的方式,靠算法或软件生成一对密钥对来进行加解密操作,其中,公钥,即publicKey负责数据加密,即使密钥泄露也没有关系;私钥,即privateKey负责解密对应公钥加密的数据,是不能泄密的。理论上,在不知道公钥对应的私钥privateKey的情况下,极难破解加密后的数据。(非对称加密,即数据的加密与解密不由同一密钥来完成。)
RSA加密的后端实现
这里参考csdn某博主所写的工具类点击进入,并对其做了适宜的改动,将生成随机密钥对的方法返回值由void修改为Map<Integer, String>。在此不做赘述。
RSA加密的前端实现
这里采用的js为github的jsencrrpt.js点击进入,下载过来导入我们的前端代码之后即可自己编写向应的解密方法,以下代码仅供参考:
/*rsa加密*/
function rsaEncrypt(encryptedStr, key) {
/*实例化加密对象*/
let encrypt = new JSEncrypt;
/*设置公钥密钥 注:密钥为openssl密钥生成软件生成,而非自定义*/
encrypt.setPublicKey(key);
return encrypt.encrypt(encryptedStr);
}
/*rsa解密*/
function rsaDecrypt(decryptedStr, key) {
/*实例化加密对象*/
let decrypt = new JSEncrypt;
/*设置私钥密钥 注:密钥为openssl密钥生成软件生成,而非自定义*/
decrypt.setPrivateKey(key);
return decrypt.decrypt(decryptedStr,key);
}
总结
RSA算法的非对称方式的性质决定了他的用途所在:
- 主要注意点在于藏好固定的私钥,对公钥的安全保护并不重要。
- 密钥对由算法生成不能自定义,实现了密钥的随机性不能暴力破解。
- 加/解密过长的文本只能分段加/解密。
- 密钥长度很长,且密钥的格式接近混淆代码的乱码形式,较aes字符串更容易在混淆的js代码中进行隐藏,也不容易看出加/解密方式,具有一定的隐蔽性。
实现API混合加密
算法逻辑
由RSA的特性我们可知,利用RSA实现对AES密钥加/解密是最好的办法(直接用来加/解密数据耗时过多),故按以下逻辑,既能实现前后端数据传递密钥而不被监听获取,也能动态生成aes密钥提升破解成本,具体逻辑如下:
1. 网页运行js初始化时,发送请求到后端(服务端),后端(服务端)用RSA算法生成一对公钥和私钥,我们简称为pubkey1,prikey1,将公钥pubkey1返回给前端。
-
前端拿到后端返回的公钥pubkey1后,用自己的公钥和私钥,我们简称为pubkey2,prikey2,将公钥pubkey2通过公钥pubkey1加密,加密之后传输给后端。
-
此时后端收到前端传输的密文,用私钥prikey1进行解密,因为数据是用公钥pubkey1加密的,通过解密就可以得到前端生成的公钥pubkey2
-
然后自己再随机生成AES密钥,生成了这个key之后我们就用公钥pubkey2进行加密,返回给前端,因为只有前端有pubkey2对应的私钥prikey2,所以只有前端才能解密,前端得到数据之后,用prikey2进行解密操作,得到AES的加密key,最后就用加密key进行数据传输的加密,至此整个流程结束。
混合加密在后端的实现
要满足逻辑的代码能完美实现,首先我们需要一个实体类用来存放数据,因为存放的AES的KEY要全局使用,故我们使用单例模式来实现这个实体类,实现代码如下:
package cn.edu.cust.entity;
/**
* 密钥实体类(单例模式)
* @author 易奔二
* AesKey Aes密钥
* RsaPubKey Rsa公钥
* RsaPriKey Rsa私钥
* WebRsaPubKey web端公钥
*
*/
public class Key {
//单例模式
private Key(){
super();
//防止密钥赋值失败而对其初始化
aesKey = "ytdxcmqwbQS=@phr";
RsaPriKey = "请自己使用工具或网站生成";
RsaPubKey = "请自己使用工具或网站生成";
}
private static Key instance = new Key();
public static Key getInstance(){
return instance;
}
private String aesKey;
private String RsaPubKey;
private String RsaPriKey;
private String WebRsaPubKey;
public String getWebRsaPubKey() {
return WebRsaPubKey;
}
public void setWebRsaPubKey(String webRsaPubKey) {
WebRsaPubKey = webRsaPubKey;
}
public String getAesKey() {
return aesKey;
}
public void setAesKey(String aesKey) {
this.aesKey = aesKey;
}
public String getRsaPubKey() {
return RsaPubKey;
}
public void setRsaPubKey(String rsaPubKey) {
RsaPubKey = rsaPubKey;
}
public String getRsaPriKey() {
return RsaPriKey;
}
public void setRsaPriKey(String rsaPriKey) {
RsaPriKey = rsaPriKey;
}
}
拥有了实体类后,我们再controller结合之前的工具类新建一个类,实现代码如下:
package cn.edu.cust.controller;
import cn.edu.cust.entity.Key;
import cn.edu.cust.util.AesUtil;
import cn.edu.cust.util.RSAUtil;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 密钥传输加密信息通道算法
*
* @author 易奔二
*/
@Controller
public class SecretController {
Key key = Key.getInstance();
/**
* 随机生成RSA密钥并将公钥发送给前端
*
* @return keyList 公钥(分成一定等分)
*/
@ResponseBody
@RequestMapping("/secretRequest1")
private ArrayList<String> sendPubKey() {
ArrayList<String> keyList = new ArrayList<>();
try {
//生成一对公钥与私钥
Map<Integer, String> keyMap = RSAUtil.genKeyPair();
//给实体类赋值
key.setRsaPubKey(keyMap.get(0));
key.setRsaPriKey(keyMap.get(1));
keyList.add(keyMap.get(0));
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
keyList.add(key.getRsaPubKey());
}
return keyList;
}
/**
* 接收前端生成的Rsa公钥并解密
* @return 一个字符串,不返回任何值
*/
@RequestMapping(value = "/secretResponse")
@ResponseBody
private String receivePubKey(@RequestParam("tempPubKey") String tempPubKey) {
int parting = 3;
StringBuilder webPubKey = new StringBuilder();
try {
//分段解密
for (int i = 1; i <= parting; i++) {
webPubKey.append(RSAUtil.decrypt(tempPubKey.substring(tempPubKey.length() / 3 * (i - 1),
tempPubKey.length() / 3 * i),key.getRsaPriKey()));
}
} catch (Exception e) {
e.printStackTrace();
}
key.setWebRsaPubKey(webPubKey.toString());
return "json";
}
/**
* 随机生成一个AES密钥,并由前端的RSA公钥加密再返回给后端
* @return 加密后的AES字符串
*/
@ResponseBody
@RequestMapping("/secretRequest2")
private ArrayList<String> sendAesKey(){
ArrayList<String> keyList = new ArrayList<String>();
//初始化一个aes密钥
key.setAesKey(AesUtil.genAesKey());
try {
//使用前端公钥加密aes密钥
keyList.add(RSAUtil.encrypt(key.getAesKey(),key.getWebRsaPubKey()));
} catch (Exception e) {
e.printStackTrace();
}
return keyList;
}
}
此时,我们再调用之前的AES加密,也需要做一定的更改:
//声明实体类对象全局变量
Key key = Key.getInstance();
@ResponseBody
@RequestMapping("/exam")
private Object exam(HttpServletRequest request) {
//获取处理后要返回的数据
Object value = examCache(card, false);
String aesJson = null;
try {
//将返回数据按照新随机生成第AES密钥进行加密
aesJson = AesUtil.aesEncrypt(JSON.toJSONString(value),key.getAesKey());
} catch (Exception e) {
e.printStackTrace();
}
return aesJson;
}
混合代码在前端的实现
与之相对应的,我们前端也需要接收后端的响应和请求,实现代码如下(仅供参考):
/*web端rsa的公,私钥配置*/
function getRsaKey(a) {
let keyValue;
switch (a) {
/*设置公钥密钥 注:密钥为openssl密钥生成软件生成,而非自定义*/
case "public":
keyValue = "";
break;
/*设置私钥密钥 注:密钥为openssl密钥生成软件生成,而非自定义*/
case "private":
keyValue = "";
break;
default:
console.error("参数异常。");
break;
}
return keyValue;
}
/*获取aes的key*/
function getAesKey() {
let tempPubKey = "";
//向后端发送请求,将后端的公钥返回给前端
$.ajax({
// 编写json格式,设置属性和值
url: 'secretRequest1',
data: {},
dataType: 'json',
async: false,
success: function (pubKey) {
let parting = 3;
//通过后端的公钥加密前端的公钥(分段加密再拼接)
for (let i = 1; i <= parting; i++) {
let start = getRsaKey("public").toString().length / 3 * (i - 1);
let end = getRsaKey("public").toString().length / 3 * i;
//分段加密再拼接
tempPubKey = tempPubKey.concat(rsaEncrypt(getRsaKey("public").substring(start,end),pubKey[0]));
}
//将加密后的前端公钥返回给后端
$.ajax({
// 编写json格式,设置属性和值
async: false,
type: 'post',
url: 'secretResponse',
data:{
"tempPubKey":tempPubKey
},
success: function () {
//获得最终加密后的aes密钥并解密
$.ajax({
// 编写json格式,设置属性和值
url: 'secretRequest2',
data: {},
dataType: 'json',
async: false,
success: function (data) {
//通过前端的私钥解密aes密钥
tempPubKey = rsaDecrypt(data[0],getRsaKey("private"));
}
})
}
});
}
});
//隐式声明全局变量key
key = CryptoJS.enc.Utf8.parse(tempPubKey);//aes秘钥
}
如此,我们便完成了整个前后端传递AES密钥的加密传输与动态生成。
总结与改进(未实现)
通过双混合加密保护动态AES安全到达前端的逻辑固然提升了破解者解密的成本,但这逻辑也有一个明显的漏洞相信各位也看出来了,那便是:尽管后端的RSA密钥对与AES密钥一直为动态,但前端的RSA密钥对却是静态的。对方解析代码获得了我们的私钥后,依然可以解密我们的AES以此解密数据。对此我们能想到以下改进方法:
- 针对性提升js混淆程度,重点将字符串分割与混淆变量名,死代码注入以此来隐藏密钥
- 将配置js一并混淆并更改文件名,使爬虫工程师判断我们加密逻辑方式难度增大
- js实现每次初始化随机生成RSA密钥对
前两点改进方法很容易实现,并也可有效提升破解网站所需成本,但第三点的实现却有一定难度,须知RSA密钥对的生成需要强算力随机数,以浏览器js的性能并不能快速完成,若应用可能造成网页加载过慢,在性能上颇得不偿失。前端加密的意义在于付出成本与破解成本的权衡,增大对方的破解成本并以此保护我们自己的数据安全,没有绝对的前端加密,未知的加密逻辑是前端实现加密最好的利器。
2021/9/19更新
前端js混淆方向指南
对前后端数据进行加密后,如果没有混淆,那加密的逻辑与解密密钥将直接暴露在浏览器中,所以对混淆js十分重要,不然之前所有努力都将白费,这里提供我对API混淆加密这个方法的混淆思路
修改关键词信息
在混淆代码时,如何提升爬虫者解析我们代码逻辑的难度,最简单的方法是将我们的变量名进行混淆修改,这里我推荐的在线混淆网站已经默认拥有该功能,但混淆的仅是全局变量和局部变量,对引用其他js文件的方法名并不能混淆(当然他要是这也混淆了代码也不能正常运行了)。所以我们需要利用IDE工具,进行手动重构,如图:
这样代码混淆后,这个关键词不会提供给爬虫者任何信息,这里我们将setPublicKey和setPrivateKey重构即可不让人轻易分析出我们用的为rsa加密。同时,我们发现,ajax的url接口名称能让爬虫者轻易分析出接口所包含的信息类别,如exam ——考试信息,secretResquesion ——加密信息,所以我们对整个url接口也进行手动更改名称,修改后效果如下:
注:请做好更改前接口名称的备份,别把自己也绕进去了。
同时,我们将加密引入的js静态文件名也更改了,如图:
这样我们手动的混淆就基本完成啦。
使用在线混淆网站进行一键混淆
接下来进入网站,在性能和混淆复杂度的权衡下,有选择的进行混淆选项,这里推荐我的配置如图:
这样,我们的js就完成了可读性极低下的混淆,大大增加了爬虫者的破解成本,同时也增加了自己的调试成本。
注意事项
- 做好混淆前和修改关键词之前的js与url接口名称等备份,防止自己以后调试也看不懂代码。
- 请在前端基本成型,不需要再进行大规模,高频次改动时再进行严格的加密与混淆,不然增大了自己的开发调试难度。
- 一定要备份混淆前源代码,混淆是不可逆的,请勿丢失。
本文由易奔二(点击进入博客主页)于2021/9/9原创上传至博客文章,未经允许不得擅自转载或商用。