前情
为什么要搞,要这么做? 难道只用HTTPS不够吗?
如果应用只使用HTTPS,那还真不够用!
原因:攻击者可以模拟客户端操作,枚举敏感用户信息、攻击应用。譬如,管理界面只要是放在互联网中,那么攻击者
就能够通过网络直接访问。只要是能访问,那么客户端与服务端的链接通道就找到了,并打开了。在数据还没有进入到互联网
环境前,攻击者可利用三方工具对模拟真实的请求,并对其拦截、抓包、修改,如此变绕开了前端的基础校验。
对于一些特殊敏感数据,例:用户表,主键id(userId)。这些数据如果通过HTTP、互联网环境传输到服务器端,而恰巧主键生成策略是有规律可循(bigint自增、某种规律性的公式)的,那么攻击者可以通过枚举的方式,高频繁修改请求包信息,请求服务端。以此来获取一些敏感的数据信息。
思路
攻击者能够肆无忌惮的攻击服务器,归根结底是因为两点:
- 请求被抓包,对包信息
修改
; - 修改后的包信息可以直接发送给服务端;
对于抓包我们无法处理,但是我们可以对修改的信息做些手脚!这就用到了数字签名,加签验签!
数字签名的注意事项:
- 因为是全局性处理,所以必须要考虑性能损耗!
- 数字签名不能被攻击者复制。否则数字签名就无效了!
- 对传输的请求不要任何脏影响,也就是说请求体数据必须保证
完整性
!
针对以上思考,采用的方案:
- 签名算法使用MD5(AES、国密、甚至RSA都可以);
- 考虑到MD5的易破解性,所以我们加slat (必须包含特殊字符,确保安全);
- 服务端使用Filter对请求做合法判断处理;
- 因为HTTP方法有多种,Content-Type存在多种。所以我们采用
String
格式做签名**(保证数据顺序的一致性)**;
开始
环境介绍:
- 前端框架:Vue 4.5.10,使用Axios作为网络请求库;
- 包管理工具:npm 6.9.0
- 后端框架:SpringBoot 2.4.1
前端开发
安装加密组件
# npm 安装加密组件
cnpm install crypto-js
说明:用其他组件也可以,或者自己手写都行。关键是能保证前后端的验签算法保持一致即可。
crypto.js (封装 util)
import CryptoJS from 'crypto-js'
/**
* 加盐MD5加密,可以作为加签算法
* @param {加密对象} obj
* @param {*} slat
*/
export function MD5(obj, slat) {
if (!obj) {
return obj;
}
// 转换成字符串
let str = JSON.stringify(obj);
if (!!slat) {
// 拼接slat
str = str.concat(slat)
}
// 关键点:将所有上引号替换成空,理由:后台Filter获取的参数全部为String,所以为了保证格式一致,取消掉上引号
str = str.replaceAll(/"/g, "");
// JSON数据存在特殊符号[和]
str = str.replaceAll("\[", "");
str = str.replaceAll("]", "");
// MD5加密后,转成字符串
return CryptoJS.MD5(str).toString();
}
关键点:
- 为了保证后端在验签时,对数据的还原保持一致性,所以需要对特殊字符做处理(删除)
- 加盐的公式,我们可以任意自定义,不变的是,保证slat具有一定的复杂性
Axios全局request拦截
import axios from 'axios'
/**
* 需要加签、验签的路径集合
* 例:"/user",将匹配以"/user"开头的所有API
*/
const blackBeginUrl = ["/user", "/role"]
// HTTP request拦截
axios.interceptors.request.use(
(config) => {
const meta = config.meta || {}
const isToken = meta.isToken === false
if (getToken() && !isToken) {
config.headers['Authorization'] = getToken()
}
// 判断是否需要对路径做加签操作
let needSign = false;
for (let blackUrl of blackBeginUrl) {
if (config.url.indexOf(blackUrl) != -1) {
needSign = true;
break;
}
}
if (needSign) {
// 这里默认post请求的Content-Type:application/json (可以和开发者做好约定)
let requestData = "";
if (config.method === "get" && !!config.params) {
requestData = config.params
} else if (config.method == "post" && !!config.data) {
requestData = config.data
}
// 时间戳,作为slat的必备组成之一
const timestamp = Date.parse(new Date())
config.headers['Timestamp'] = timestamp
// 随机字符串,作为slat的必备组成之一
const randomStr = "K:*C8bw6zJ"
// slat = 时间戳 + 随机字符串 (自定义slat公式)
const slat = timestamp.concat(randomStr);
const signature = MD5(requestData, slat);
config.headers['Signature'] = signature;
}
return config;
},
(error) => {
tryHideFullScreenLoading()
return Promise.reject(error)
},
)
随机生成网站:在线生成随机字符串
说明:如果业务上对个别API加签,可以仿照上述代码的方式,定义需要验签的API黑名单。
- 效果
通过上图可以看到,我们对本次请求成功生成了数字签名。Headers key为 Signature。
至此,前端的工作就完成了!
后端
YAML配置
demo:
signature:
stub:
header-signature: "Signature"
header-timestamp: "Timestamp"
# 保证与前端一致。这里可以做加密处理,防止所有开发人员都知道
random-str: "K:*C8bw6zJ"
# 需要校验的路径集合
path:
include-url:
- /user/*
- /role/*
SignatureCheckFilter.java (核心:Filter Logic)
package com.demo.extra.filter;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.demo.exception.impl.SignatureCheckException;
import com.demo.exception.impl.SignatureHeaderMissingException;
import com.demo.extra.filter.property.SignatureProperty;
import com.demo.extra.wrapper.CustomHttpServletRequestWrapper;
import org.springframework.util.AntPathMatcher;
import org.apache.http.entity.ContentType;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 签名校验过滤器
*
* @author utrix
* @date 2021/11/18
*/
@Setter
@Component
@ConfigurationProperties(prefix = "demo.signature.stub")
public class SignatureCheckFilter implements Filter {
/**
* 签名Header的key
*/
private String headerSignature;
/**
* 签名校验之时间戳 Header key
*/
private String headerTimestamp;
/**
* 与前端约定的 加签随机值
*/
private String randomStr;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest servletRequest = (HttpServletRequest) request;
// 文件上传类的直接放行
if (contentType == null || contentType.startsWith(ContentType.MULTIPART_FORM_DATA.getMimeType())) {
chain.doFilter(request, response);
return;
}
HttpServletRequest requestWrapper = servletRequest;
// 获取请求体的数据
String requestData;
String contentType = servletRequest.getContentType();
// 获取Content-Type为 JSON格式的请求体数据
if (StrUtil.isNotEmpty(contentType) && contentType.contains("application/json")) {
// 因为request.getReader() 或者request.getInputStream()只能读取一次,所以我们需要自定义包装类
requestWrapper = new CustomHttpServletRequestWrapper(servletRequest);
requestData = ((CustomHttpServletRequestWrapper) requestWrapper).getRequestData();
} else {
// GET类请求数据
Map<String, String[]> parameterMap = servletRequest.getParameterMap();
if (MapUtil.isEmpty(parameterMap)) {
chain.doFilter(requestWrapper, response);
return;
}
// 谷歌的Gson工具可以保证Map转成String时顺序的一致性(真香)
GsonBuilder gsonBuilder = new GsonBuilder();
// 对特殊字符不做转义处理
gsonBuilder.disableHtmlEscaping();
Gson gson = gsonBuilder.create();
requestData = gson.toJson(new LinkedHashMap<>(parameterMap));
}
String signature = servletRequest.getHeader(headerSignature);
String timestamp = servletRequest.getHeader(headerTimestamp);
if (StrUtil.isEmpty(signature) || StrUtil.isEmpty(timestamp)) {
// 因为Filter层抛出的异常,不能被全局异常处理器捕获到,所以采用”曲线救国“的策略处理
request.setAttribute("signatureException", new SignatureHeaderMissingException("请求不合法"));
//将异常分发到/expiredJwtException控制器
request.getRequestDispatcher("/signatureException").forward(requestWrapper, response);
return;
}
// 清理请求体内容。保证无特殊字符干扰验签
requestData = requestData.replaceAll("\\[", StrUtil.EMPTY);
requestData = requestData.replaceAll("]", StrUtil.EMPTY);
requestData = requestData.replaceAll("\"", StrUtil.EMPTY);
// 验签,通过静态方法获取YAML中配置的随机字符串(与前端约定好)
String slat = randomStr.concat(timestamp);
String md5Params = requestData.concat(slat);
String verifySignature = SecureUtil.md5(md5Params);
if (StrUtil.equals(signature, verifySignature)) {
chain.doFilter(requestWrapper, response);
return;
}
request.setAttribute("signatureException", new SignatureCheckException("请求不合法"));
request.getRequestDispatcher("/signatureException").forward(requestWrapper, response);
}
}
因为涉及到多个辅助类,现在依次补充上
FilterOfPCConfig.java (核心:Filter Config)
package com.demo.config;
import com.demo.extra.filter.SignatureCheckFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* PC端的过滤器配置
*
* @author utrix
* @date 2021/11/18
*/
@Configuration
@RequiredArgsConstructor
@ConfigurationProperties(prefix = "demo.signature.path")
public class FilterOfPCConfig {
/**
* 需要校验的前端路径。譬如<img src="xx">的路径,前端Axios Request拦截器无法拦截的路径,或者
* <p>对系统数据不产生影响的路径集合
*/
private String[] includeUrl;
private final SignatureCheckFilter signatureCheckFilter;
@Bean("buildSignatureCheckFilter")
public FilterRegistrationBean<SignatureCheckFilter> buildSignatureCheckFilter() {
FilterRegistrationBean<SignatureCheckFilter> registrationBean = new FilterRegistrationBean<>();
// 优先级最高,这样就可以保证前端请求首先过滤了
registrationBean.setOrder(1);
registrationBean.setFilter(signatureCheckFilter);
registrationBean.addUrlPatterns(includeUrl);
registrationBean.setName("signatureCheckFilter");
return registrationBean;
}
}
CustomHttpServletRequestWrapper.java (辅助类:ServletRequestWrapper)
package com.demo.extra.wrapper;
import lombok.Getter;
import lombok.SneakyThrows;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 自定义HttpServletRequest包装类
* <pre>
* 原因:
* request.getReader() 和 request.getInputStream() 都是只能调用一次
* 并且 getReader() 方法底层也是调用 getInputStream() 来实现的.
* 所以我们要使用 HttpServletRequestWrapper 来实现自定义的 CustomHttpServletRequestWrapper, 把 body 保存在 CustomHttpServletRequestWrapper 中
* </pre>
*
* @author utrix
* @date 2021/11/18
*/
public class CustomHttpServletRequestWrapper extends HttpServletRequestWrapper {
@Getter
private final String requestData;
@SneakyThrows
public CustomHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
BufferedReader br = request.getReader();
String str;
StringBuilder content = new StringBuilder();
while ((str = br.readLine()) != null) {
content.append(str);
}
requestData = content.toString();
}
@Override
public ServletInputStream getInputStream() throws IOException {
// 必须重写
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(requestData.getBytes(StandardCharsets.UTF_8));
return new ServletInputStream() {
@Override
public int read() throws IOException {
return byteArrayInputStream.read();
}
@Override
public void setReadListener(ReadListener listener) {
}
@Override
public boolean isReady() {
return false;
}
@Override
public boolean isFinished() {
return false;
}
};
}
}
SignatureExceptionController.java (辅助类:Exception Handle)
package com.demo.extra.filter.controller;
import com.demo.exception.impl.SignatureCheckException;
import com.demo.exception.impl.SignatureHeaderMissingException;
import com.demo.utils.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/**
* 兜底处理签名校验异常
*
* @author utrix
* @date 2021/11/18
*/
@Slf4j
@RestController
public class SignatureExceptionController {
/**
* 对抛出的签名异常做统一处理
*
* @param request {@link HttpServletRequest}
* @return {@link R<String>}
*/
@RequestMapping("/signatureException")
public R<String> handleSignatureException(HttpServletRequest request) {
Object exception = request.getAttribute("signatureException");
// 这里也可以直接抛出,交由全局异常处理层处理
RuntimeException signatureException;
if (exception instanceof SignatureCheckException) {
log.error("签名校验异常,说明请求被抓包,请求体被修改过了。修改之后的请求体内容:{},异常:{}", request, exception);
signatureException = (SignatureCheckException) exception;
} else if (exception instanceof SignatureHeaderMissingException) {
log.error("签名校验异常,说明请求被抓包,验签的Headers被删除了、缺失。请求体内容:{},异常:{}", request, exception);
signatureException = (SignatureHeaderMissingException) exception;
} else {
// 自定义的响应实体类,代码中应该都定义了。名字不一样罢了
return R.failed("验签异常");
}
return R.failed(signatureException.getMessage());
}
}
Exception (辅助类:自定义异常)
- AbstractSignatureException.java (签名异常夫类)
package com.demo.exception;
/**
* 签名异常类
*
* @author utrix
* @date 2021/11/18
*/
public abstract class AbstractSignatureException extends RuntimeException{
private static final long serialVersionUID = -83904050176855L;
public AbstractSignatureException() {
super();
}
public AbstractSignatureException(String message) {
super(message);
}
public AbstractSignatureException(String message, Throwable cause) {
super(message, cause);
}
public AbstractSignatureException(Throwable cause) {
super(cause);
}
protected AbstractSignatureException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
- SignatureCheckException.java (子类)
package com.demo.exception.impl;
import com.demo.exception.AbstractSignatureException;
/**
* 签名校验错误bug
*
* @author utrix
* @date 2021/11/18
*/
public class SignatureCheckException extends AbstractSignatureException {
private static final long serialVersionUID = -44670696324195L;
public SignatureCheckException(String message) {
super(message);
}
}
- SignatureHeaderMissingException.java (子类)
package com.demo.exception.impl;
import com.demo.exception.AbstractSignatureException;
/**
* 缺失签名Header异常
*
* @author utrix
* @date 2021/11/18
*/
public class SignatureHeaderMissingException extends AbstractSignatureException {
private static final long serialVersionUID = 26732288906799170L;
public SignatureHeaderMissingException(String message) {
super(message);
}
}
测试
对所有验签的API做测试,发现都通过(*^_^*)
总结
难点:
-
GET、DELETE请求中,URL请求参数转换成String时Params顺序一致性的问题。
一开始不断尝试使用Hutool的JsonUtil、JsonObject等尝试,都不行。
最后突然想到了Guava,是否可尝试尝试。没成想”一炮成功“!内心感叹,还是Guava做的细腻呀,膜拜! -
特殊字符捣乱
- JavaScript使用
JSON.stringify(obj)
转换成String时,如果数据格式是JSON,那么String会包含特殊字符[
和]
。 - 如果
JSON对象中,属性值属于Number
,那么String中不会对此属性值加双引号处理,但后端的Map<String, String[]> parameterMap = servletRequest.getParameterMap();
却无差别的把所有属性值定义为了String,故使用Gson.toJson()
时,验签字符串带有双引号!
- JavaScript使用
这个问题看似简单,但愚笨的我想了半天才发现简单的处理方法 —— 删掉它不就完事了!
哎,还是太菜呀,这么点东西耗费了半天时间才搞定。
(本文完,如有引用必须付本文链接!)