目录
概述
介于公司最近发布了一个面向C端用户的Web系统,为防止前端调用后端的API接口时,数据传输的内容轻易的被黑客获取,而设计的一个前后端数据加密传输方案
最初的方案
在最开始,我们采用了最传统的AES对称加密的方式来加密API数据,前后端共用一个秘钥。具体操作流程为前端用秘钥将请求报文加密后发送到后端,后端在过滤器中同一个秘钥来完成解密请求报文;后端在响应阶段采用同一个秘钥对想用报文加密后发送到前端,前端收到响应报文后完成响应报文的解密。但是这样做有一个非常明显的弊端,就是前端需要将这个秘钥在前端的代码中写死,一旦黑客在浏览器中打开F12搜索关键词 AES,很容易找我们设置的秘钥,从而导致整个加解密全部暴露。
改进后的方案
为解决秘钥容易在前端暴露的问题,我们重新设计了整个加解密的流程,整个过程使用了两对RAS非对称加密的秘钥。一对给前端加密、后端解密用(后端秘钥),另一对给后端加密,前端解密用(前端秘钥)。具体为:
1. 前端在加载主页时,以伪装成加载图片内容(base64字符串)的形式先向后端发送两个请求,分别用于获取前段解密用的私钥、加密用的公钥以及秘钥对应的摘要。注意,这里分别获取的公钥、私钥并不是同一对,而是两对秘钥中分别取一个。返回的秘钥伪装成一个base64格式的图片流,并且使用了一定的扰动处理。
@Operation(summary = "获取公钥")
@GetMapping("/welcome")
public RespEntity<String> welcome() throws Exception {
String s1 = GlobalStatus.getRequest().getHeader("s1");
String publicKey = "";
if(StringUtils.isNotBlank(s1)){
publicKey = rsaService.getPublicKey(CodecProperties.SERVER_RSA_KEY_PRE,s1);
if(StringUtils.isBlank(publicKey)){
return RespEntity.ok();
}
}else{
// 生成一对公私钥
Map<String, String> keyMap = rsaService.getRsa(CodecProperties.SERVER_RSA_KEY_PRE,s1);
publicKey = keyMap.get(RsaUtils.PUBLIC_KEY);
s1 = Sha1Utils.getSHA1Digest(publicKey);
}
// 将秘钥进行混淆后拼上摘要后返回前端
String firstStr = publicKey.substring(0,3);
String secondStr = publicKey.substring(3,publicKey.length()-3);
String thirdStr = publicKey.substring(publicKey.length()-3);
String simulatePublicKey = StringUtils.getRandomLetterString(3) + thirdStr + secondStr + firstStr + "_" + s1 + StringUtils.getRandomLetterString(4);
byte [] simulatePublicKeyArray = simulatePublicKey.getBytes();
String keyBase64 = "data:image/png;base64," + Base64.getEncoder().encodeToString(simulatePublicKeyArray);
return RespEntity.ok(keyBase64);
}
@Operation(summary = "获取私钥")
@PostMapping("/getUsefulImg")
public RespEntity<String> getUsefulImg() throws Exception {
String s2 = GlobalStatus.getRequest().getHeader("s2");
String privateKey = "";
if(StringUtils.isNotBlank(s2)){
privateKey = rsaService.getPrivateKey(CodecProperties.CLIENT_RSA_KEY_PRE,s2);
if(StringUtils.isBlank(privateKey)){
return RespEntity.ok();
}
}else{
// 生成一对公私钥
Map<String, String> keyMap = rsaService.getRsa(CodecProperties.CLIENT_RSA_KEY_PRE,s2);
privateKey = keyMap.get(RsaUtils.PRIVATE_KEY);
String publicKey = keyMap.get(RsaUtils.PUBLIC_KEY);
s2 = Sha1Utils.getSHA1Digest(publicKey);
}
// 将秘钥进行混淆后拼上摘要后返回前端
String firstStr = privateKey.substring(0,privateKey.indexOf("/"));
String secondStr = privateKey.substring(privateKey.indexOf("/"),privateKey.lastIndexOf("/")+1);
String thirdStr = privateKey.substring(privateKey.lastIndexOf("/")+1);
String simulatePrivateKey = StringUtils.getRandomLetterString(3) + thirdStr + secondStr + firstStr + "_" + s2 + StringUtils.getRandomLetterString(4);
byte [] simulatePrivateKeyArray = simulatePrivateKey.getBytes();
String keyBase64 = "data:image/png;base64," + Base64.getEncoder().encodeToString(simulatePrivateKeyArray);
return RespEntity.ok(keyBase64);
}
2. 考虑到RSA非对称加密的性能较差,因此对报文内容的加密我们仍然使用AES对称加密,前端每次在加密请求报文时,会生成一对新的AES秘钥,对请求报文加密,然后使用获取到的RAS非对称加密的私钥对这个AES的key做加密;那么请求报文中,将会有加密后的data、私钥的摘要、公钥的摘要、加密的AES的key发送给后端。
3. 后端在收到请求报文后,根据私钥的摘要找到对应私钥的公钥,然后以公钥对加密的AES key进行解密,然后再以AES的key对请求报文进行解密。
4. 后端在加密响应报文时,同样的会生成一对新的AES的key,以这个key对响应报文加密,然后以后端的私钥对AES key 加密;因此,后端的响应报文也会有 加密后的data、私钥的摘要、加密的AES的key三个字段。
5. 前端在收到响应报文后,先根据摘要校验后端加密的公钥与前端解密的私钥是否是一对,如果不是,则要重新获取新的私钥。
package com.vteam.uap.core.filter;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.vteam.uap.cache2.strategy.HashMapContainer;
import com.vteam.uap.cache2.strategy.StringContainer;
import com.vteam.uap.common.util.*;
import com.vteam.uap.core.service.RsaService;
import com.vteam.uap.jwt.exception.ParamCheckException;
import com.vteam.uap.security.codec.CodecProperties;
import com.vteam.uap.security.global.SecurityGlobalStatus;
import com.vteam.uap.security.httpWrapper.RequestWrapper;
import com.vteam.uap.security.httpWrapper.ResponseWrapper;
import com.vteam.uap.util.SpringContextUtils;
import jakarta.annotation.Resource;
import jakarta.servlet.Filter;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.springframework.util.PatternMatchUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.HandlerExceptionResolver;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
/**
* @author Miller.Lai
* @description: 加解码过滤器
* @date 2023-12-15 16:46:03
*/
@Slf4j
public class CodecFilter implements Filter {
@Resource
protected CodecProperties codecProperties;
@Resource
private StringContainer stringContainer;
@Resource
private HashMapContainer hashMapContainer;
@Resource
private RsaService rsaService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 请求解密
if (codecProperties.isApiEnable() && !isIgnore() &&
(
( "POST".equalsIgnoreCase(((HttpServletRequest)request).getMethod().trim()) && "application/json".equals(request.getContentType().trim().toLowerCase()))
|| !"POST".equalsIgnoreCase(((HttpServletRequest)request).getMethod().trim())
) ){
// 包装响应对象
ResponseWrapper responseWrapper = new ResponseWrapper((HttpServletResponse) response);
String url = ((HttpServletRequest) request).getRequestURI();
String userId = SecurityGlobalStatus.getUserid();
HandlerExceptionResolver handlerExceptionResolver = (HandlerExceptionResolver) SpringContextUtils.getBean("handlerExceptionResolver");
// 如果是post请求,则请求体解密,get请求不做处理
if("POST".equals(((HttpServletRequest)request).getMethod().trim().toUpperCase())){
RequestWrapper requestWrapper = new RequestWrapper((HttpServletRequest) request);
String bodyStr = requestWrapper.getRequestBody();
if(StringUtils.isNotBlank(bodyStr)){
JSONObject bodyJson = JSONObject.parseObject(bodyStr);
Object data = bodyJson.get("data");
// s1是服务端秘钥的摘要
String s1 = ((HttpServletRequest) request).getHeader("s1");
if(StringUtils.isEmpty(s1)){
log.error("服务端秘钥摘要缺失,需要添加请求头:s1");
handlerExceptionResolver.resolveException((HttpServletRequest) request,(HttpServletResponse) response,null,new ParamCheckException("非法请求"));
return;
}
// 从请求参数 uuid 中拿到 AES解密的 key 和 iv
String cryptUuid = (String)bodyJson.get("u");
if( StringUtils.isBlank(cryptUuid) ){
log.error("加密的k、v缺失,body中必须要有u字段");
handlerExceptionResolver.resolveException((HttpServletRequest) request,(HttpServletResponse) response,null,new ParamCheckException("非法请求"));
return;
}
String privateKey = rsaService.getPrivateKey(CodecProperties.SERVER_RSA_KEY_PRE,s1);
if(StringUtils.isBlank(privateKey)){
log.error("未找到s1对应的秘钥信息");
handlerExceptionResolver.resolveException((HttpServletRequest) request,(HttpServletResponse) response,null,new ParamCheckException("非法请求"));
return;
}
String k = null;
String v = null;
try{
String uuid = RsaUtils.privateDecrypt(cryptUuid, privateKey);
String[] uuidArray = uuid.split("_");
k = uuidArray[0];
v = uuidArray[1];
}catch (Exception e){
log.error("AES的k、v解密失败");
handlerExceptionResolver.resolveException((HttpServletRequest) request,(HttpServletResponse) response,null,new ParamCheckException("非法请求"));
return;
}
if(data instanceof String && !((String) data).trim().isEmpty()){
if (log.isDebugEnabled()) {
log.debug("解密 API 请求消息:type={}, requestBody={}",requestWrapper.getMethod(), bodyJson.toJSONString());
}
String plainText = AesUtils.decrypt((String) data, AesUtils.Mode.API,k,v,codecProperties.isDbEnable());
Object dataObj = "";
try{
dataObj = JSON.parseObject(plainText);
Object timestampObj = ((JSONObject)dataObj).get("timestamp");
if(timestampObj == null ){
log.error("请求时间戳缺失");
handlerExceptionResolver.resolveException((HttpServletRequest) request,(HttpServletResponse) response,null,new ParamCheckException("非法请求"));
return;
}
long timestamp = (long)timestampObj;
long currentTimeMillis = System.currentTimeMillis();
long currentTimeMicros = currentTimeMillis * 1000;
// 请求时间戳是否过期,超过1分钟过期
if((currentTimeMicros -timestamp)/(1000 * 1000)>60){
log.error("请求时间戳过期");
handlerExceptionResolver.resolveException((HttpServletRequest) request,(HttpServletResponse) response,null,new ParamCheckException("非法请求"));
return;
}
boolean ifAbsent = stringContainer.setIfAbsent(MD5Utils.getDigest(userId + url + timestamp), "1", 61L);
if(!ifAbsent){
log.error("当前请求已被处理过,二次请求不再受理");
handlerExceptionResolver.resolveException((HttpServletRequest) request,(HttpServletResponse) response,null,new ParamCheckException("非法请求"));
return;
}
}catch (Exception e1){
try {
dataObj = JSON.parseArray(plainText);
}catch (Exception e2){
dataObj = data + "";
}
}
bodyJson.put("data",dataObj);
requestWrapper.setRequestBody(bodyJson.toJSONString());
}else if(data != null && !(data instanceof String)){
log.error("服务已开启请求参数加密处理,但当前请求参数未被加密");
handlerExceptionResolver.resolveException((HttpServletRequest) request,(HttpServletResponse) response,null,new ParamCheckException("非法请求"));
return;
}
}
chain.doFilter(requestWrapper, responseWrapper);
}else{
chain.doFilter(request, responseWrapper);
}
// 拿到响应对象
byte[] responseData = responseWrapper.getResponseData();
String contentType = responseWrapper.getContentType();
if(contentType != null && "application/json".equalsIgnoreCase(contentType.trim())){
String originalResulet = new String(responseData);
JSONObject jsonObject =JSONObject.parseObject(originalResulet);
// 拿到响应对象中的data
Object data = jsonObject.get("data");
if(data != null){
// AES 加密
String k = StringUtils.getRandomString(16);
String v = StringUtils.getRandomString(16);
String encText = AesUtils.encrypt(JSON.toJSONString(data), AesUtils.Mode.API,k,v,codecProperties.isDbEnable());
// s2 是服务端秘钥的摘要
String s2 = ((HttpServletRequest) request).getHeader("s2");
if( s2 == null){
log.error("服务端秘钥的摘要缺失,需要添加请求头:s2 ");
handlerExceptionResolver.resolveException((HttpServletRequest) request,(HttpServletResponse) response,null,new ParamCheckException("非法请求"));
return;
}
String publicKey = (String)hashMapContainer.doQueryCache(CodecProperties.CLIENT_RSA_KEY_PRE + s2,RsaUtils.PUBLIC_KEY);
if(publicKey == null){
Map<String,String> clientRsa = rsaService.getRsa(CodecProperties.CLIENT_RSA_KEY_PRE,s2);
publicKey = clientRsa.get(RsaUtils.PUBLIC_KEY);
s2 = clientRsa.get(RsaUtils.SignMode.SHA1.getMode());
}
String aeskv = RsaUtils.publicEncrypt(k + "_" + v,publicKey);
jsonObject.put("data", encText);
jsonObject.put("u",aeskv);
jsonObject.put("s2",s2);
}
responseData = jsonObject.toJSONString().getBytes("utf-8");
}
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
response.setContentLength(responseData.length);
httpServletResponse.getOutputStream().write(responseData);
httpServletResponse.getOutputStream().flush();
}else{
// 处理业务逻辑
chain.doFilter(request, response);
}
}
@Override
public void destroy() {
}
protected boolean isIgnore() {
boolean isIgnore = false;
String[] ignoreUrl = codecProperties.getIgnoreUrl();
if (ArrayUtils.isNotEmpty(ignoreUrl)) {
HttpServletRequest request =
((ServletRequestAttributes) Objects.requireNonNull(
RequestContextHolder.getRequestAttributes())).getRequest();
for (String s : ignoreUrl) {
if (PatternMatchUtils.simpleMatch(s, request.getRequestURI())) {
isIgnore = true;
break;
}
}
}
return isIgnore;
}
public static void main(String[] args) {
int timestampObj = (int)230416900;
long timestamp =timestampObj;
}
}
秘钥的过期时间
考虑到秘钥仍然有暴露的可能性,这两对秘钥会做定期的更新。前端加密所用私钥暴露的危害性很大,这对秘钥在后端生成后可定为 几分钟乃至一个小时过期时间;前端解密所用公钥暴露的危害性较小,这对秘钥在后端生成后可定为 一天的过期时间;