五.Springboot通过AOP实现API接口的签名验证
文章目录
- 前言
- 一、实现原理
- 二、签名规则
- 三、服务端实现
- 4.1、创建自定义注解
- 4.2、创建切面处理类
- 4.3、对应工具类`RequestUtil`
- 四、测试
- 4.1 签名失败测试:
- 4.2 签名成功测试:
- 四、总结
前言
对外开放的接口,需要验证请求方发送过来的数据确实是由发送方发起的,并且中途不能被篡改和伪造,所以希望对接口的访问进行签名验证,以验证请求的合法性和完整性,防止数据篡改、伪造或重放攻击,保障接口调用的安全性
一、实现原理
客户端对请求参数进行整理,并且通过MD5
的方式(必须是不可逆的签名算法)生成签名标识,后端拿到请求的信息内容,经过同样的算法规则得到签名标识,比对和接收到的签名一致,则验证通过
二、签名规则
该签名规则只是作为参考,实际项目根据需要进行变更,因为规则的复杂程度决定了签名是否可以被伪造的难易程度
参数一般分为3种,分别为:
①PathVariable
参数
②QueryParams
参数
③Body
参数
- 步骤1:参数排序:
将①和②类型的参数按参数名ASCII
码从小到大排序(字典序,从大到小也可以,根据需求约定好即可),且参数名和参数值以键值对互相连接组成一个请求参数串paramStr
,每个参数以#
结尾,例如:
PathVariable
有2个(subId,type
)QueryParams
参数有2个(status,date
)
1.先将参数名按照ASCII
码从小到大排序,排序后为date, status, subId, type
2.再将参数名和参数值以键值对互相连接,即date=2023-01-01#status=1#subId=1001#type=2#
- 步骤2:拼接参数:
将③body
参数json
字符串、当前时间戳timestamp
、随机串nonce
、直接拼接到paramStr
字符串前面,顺序为:nonce,timestamp,body;例如:
这里可以根据项目需求,可以再添加一个密钥key加入到签名中,前后端保持一致,增加安全性
body参数为:
{"name":"zhangsan","age":1}
,最后拼接的字符串为:nonce=KnjIHO9F4w#timestamp=1672554225456#{"name":"zhangsan","age":1}#date=2023-01-01#status=1#subId=1001#type=2#
注意:所有键值对都需要以#结尾
-
步骤3:计算签名:
将步骤2
最后得到的字符串进行MD5
计算,得到sign
签名如下:0e72ded211cc19f3c00aeca54378d06b
-
步骤4:携带Headers:
将步骤3
获取到的签名sign
以及进行签名的时间戳timestamp
放到Headers
一起请求接口地址
三、服务端实现
服务端依然是采用AOP
思想来实现对参数进行统一解析并签名对比的思路实现,其实现思路为:通过自定义注解,对需要进行验证签名的接口进行拦截,再按照签名规则进行签名验证,如果验证失败直接进行返回,验证成功则放行。话不多说,直接开干
4.1、创建自定义注解
在sign
包中创建自定义注解CheckSign
package com.light.common.sign;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 自定义验证签名注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME )
public @interface CheckSign {
}
4.2、创建切面处理类
package com.light.common.sign;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.light.common.exception.ServiceException;
import com.light.common.utils.RequestUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Map;
import java.util.TreeMap;
/**
* 签名验证的切面处理类
*/
@Aspect
@Component
@Order(3)
public class SignAspect {
private static final Logger log = LoggerFactory.getLogger(SignAspect.class);
//接口签名验证超时时间,默认3分钟,此处
private final int EXPIRE_TIME = 180;
/**
* 处理请求前执行
*/
@Before(value = "@annotation(checkSign)")
public void boBefore(JoinPoint joinPoint, CheckSign checkSign) throws ServiceException {
log.info("开始验证签名");
signHandle(joinPoint);
}
/**
* 切面逻辑实现
*
* @param joinPoint 连接点
*/
public void signHandle(JoinPoint joinPoint) throws ServiceException {
// 获取HttpServletRequest对象
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 从请求头中获取timestamp和sign参数
String timestamp = request.getHeader("timestamp");
String sign = request.getHeader("sign");
if (ObjectUtil.isEmpty(timestamp) || ObjectUtil.isEmpty(sign)) {
throw new ServiceException("未检测到签名参数");
}
long requestTime = Long.parseLong(timestamp) / 1000; // 请求时间(秒)
long now = System.currentTimeMillis() / 1000; // 当前时间(秒)
// 如果当前时间小于请求时间,说明请求时间无效
if (now < requestTime) {
log.warn("请求时间无效,当前时间: {}, 请求时间: {}", now, requestTime);
throw new ServiceException("请求时间无效");
}
// 判断请求时间与当前时间的差值是否超过过期时间
if ((now - requestTime) > EXPIRE_TIME) {
// 请求过期,处理过期逻辑
log.warn("请求已过期,当前时间: {}, 请求时间: {}", now, requestTime);
throw new ServiceException("请求已过期");
}
// 1. 获取并排序参数
String paramStr = RequestUtil.getSortedParamString(joinPoint);
// 2. 拼接Body参数和时间戳
String bodyParam = RequestUtil.getBodyParameter(joinPoint);
String finalStr = String.join("#", timestamp, bodyParam, paramStr);
// 3. 计算签名
String generatedSign = SecureUtil.md5(finalStr);
log.warn("参数签名: {}, 计算签名: {}", sign, generatedSign);
if (!generatedSign.equals(sign)) {
throw new ServiceException("签名错误");
}
}
}
4.3、对应工具类RequestUtil
package com.light.common.utils;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Parameter;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
public class RequestUtil {
/**
* 获取PathVariable和QueryParams参数,并按字典序排序拼接成字符串
* @param joinPoint 切点
* @return 参数拼装后的字符串
*/
public static String getSortedParamString(JoinPoint joinPoint) {
// 使用TreeMap自动排序
Map<String, String> params = new TreeMap<>();
// 获取QueryParams和PathVariable参数
JSONObject requestParamJson = RequestUtil.getRequestParamParameterJson(joinPoint);
if (requestParamJson != null) {
// 如果参数不为空,将其加入到params中
requestParamJson.forEach((key, value) -> params.put(key, value.toString()));
}
JSONObject pathVariableJson = RequestUtil.getPathVariableParameterJson(joinPoint);
if (pathVariableJson != null) {
// 如果参数不为空,将其加入到params中
pathVariableJson.forEach((key, value) -> params.put(key, value.toString()));
}
// 拼接成字符串,如:date=2023-01-01#status=1#subId=1001#type=2#
StringBuilder paramStr = new StringBuilder();
for (Map.Entry<String, String> entry : params.entrySet()) {
paramStr.append(entry.getKey()).append("=").append(entry.getValue()).append("#");
}
return paramStr.toString();
}
/**
* 获取Body参数并将其转为JSON字符串
* @param joinPoint 切点
* @return body参数json字符串
*/
public static String getBodyParameter(JoinPoint joinPoint) {
// 获取Body参数
JSONObject bodyParamJson = RequestUtil.getRequestBodyParameterJson(joinPoint);
return bodyParamJson != null ? JSONUtil.toJsonStr(bodyParamJson) : "";
}
/**
* 获取@RequestBody参数的JSON格式对象
*
* @param joinPoint 连接点,包含了当前方法执行的信息,主要用于获取方法参数
* @return 返回RequestBody参数的JSON格式对象,如果没有找到@RequestBody参数,返回null
*/
public static JSONObject getRequestBodyParameterJson(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
for (int i = 0; i < args.length; i++) {
Parameter parameter = ((MethodSignature) joinPoint.getSignature()).getMethod().getParameters()[i];
if (parameter.isAnnotationPresent(RequestBody.class)) {
// 将RequestBody参数转换为JSON对象
return JSONUtil.parseObj(args[i]);
}
}
return null;
}
/**
* 获取@RequestParam参数的JSON格式对象
*
* @param joinPoint 连接点,包含了当前方法执行的信息,主要用于获取方法参数
* @return 返回RequestParam参数的JSON格式对象,如果没有找到@RequestParam参数,返回null
*/
public static JSONObject getRequestParamParameterJson(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
Parameter[] parameters = ((MethodSignature) joinPoint.getSignature()).getMethod().getParameters();
for (int i = 0; i < parameters.length; i++) {
RequestParam requestParam = parameters[i].getAnnotation(RequestParam.class);
if (requestParam != null) {
Map<String, Object> map = new HashMap<>();
String key = parameters[i].getName();
if (!StrUtil.isEmpty(requestParam.value())) {
key = requestParam.value();
}
if (args[i] != null) {
map.put(key, args[i]);
}
// 将@RequestParam参数转换为JSON对象
return JSONUtil.parseObj(map);
}
}
return null;
}
/**
* 获取@PathVariable参数的JSON格式对象
*
* @param joinPoint 连接点,包含了当前方法执行的信息,主要用于获取方法参数
* @return 返回PathVariable参数的JSON格式对象,如果没有找到@PathVariable参数,返回null
*/
public static JSONObject getPathVariableParameterJson(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
Parameter[] parameters = ((MethodSignature) joinPoint.getSignature()).getMethod().getParameters();
for (int i = 0; i < parameters.length; i++) {
PathVariable pathVariable = parameters[i].getAnnotation(PathVariable.class);
if (pathVariable != null) {
Map<String, Object> map = new HashMap<>();
String key = parameters[i].getName();
if (!StrUtil.isEmpty(pathVariable.value())) {
key = pathVariable.value();
}
if (args[i] != null) {
map.put(key, args[i]);
}
// 将@PathVariable参数转换为JSON对象
return JSONUtil.parseObj(map);
}
}
return null;
}
/**
* 获取所有参数的JSON格式字符串,包含@RequestBody、@RequestParam和@PathVariable注解参数
*
* @param joinPoint 连接点,包含了当前方法执行的信息,主要用于获取方法参数
* @return 返回包含@RequestBody、@RequestParam和@PathVariable参数的JSON格式对象
*/
public static JSONObject getAllParameter(JoinPoint joinPoint) {
// 创建一个Map来保存所有参数的JSON对象
Map<String, Object> parametersJson = new HashMap<>();
// 获取RequestBody参数的JSON
JSONObject requestBodyJson = getRequestBodyParameterJson(joinPoint);
if (requestBodyJson != null) {
parametersJson.put("bodyParam", requestBodyJson);
} else {
parametersJson.put("bodyParam", null);
}
// 获取RequestParam参数的JSON
JSONObject requestParamJson = getRequestParamParameterJson(joinPoint);
if (requestParamJson != null) {
parametersJson.put("requestParam", requestParamJson);
} else {
parametersJson.put("requestParam", null);
}
// 获取PathVariable参数的JSON
JSONObject pathVariableJson = getPathVariableParameterJson(joinPoint);
if (pathVariableJson != null) {
parametersJson.put("pathParam", pathVariableJson);
} else {
parametersJson.put("pathParam", null);
}
// 将所有参数的JSON格式对象拼装成一个JSONObject并返回
return new JSONObject(parametersJson);
}
}
四、测试
我们只需要需要进行验证签名的控制器方法中加上@CheckSign
即可验证自动验证签名
@CheckSign
@GetMapping("/getMsg/{subId}")
public BaseResult<?> getTest(@PathVariable String subId, @RequestParam int name,@RequestBody JSONObject postData) {
JSONObject data = new JSONObject();
data.put("subId", subId);
data.put("name", name);
data.put("postData", postData);
return BaseResult.success(data);
}
4.1 签名失败测试:
- 控制台输出
4.2 签名成功测试:
四、总结
对请求参数进行签名验证的方式还有多种,比如WebFilter
,拦截器 Interceptor
,AOP
,RequestBodyAdvice
,HandlerMethodArgumentResolver
等,我们这里采用的AOP
主要是希望可以灵活控制哪些接口是否需要验签,且灵活度较高,实际项目可根据需求自行选择