1.概述
Spring MVC是当今web项目系统开发后端技术框架的不二之选,也是Spring全家桶中非常重要的一个成员,它的设计思想和实现套路都是很值得我们学习的,所以今天我们就再来看看Spring MVC框架中预留的两个钩子也就是扩展点:RequestBodyAdvice和ResponseBodyAdvice。之前在总结详解@ControllerAdvice的使用及其实现原理一文中就有提到这两个扩展类,它们需要配合@ControllerAdvice一起使用
1.1 RequestBodyAdvice
RequestBodyAdvice 是 Spring MVC 框架中的一个接口,允许在 HTTP 请求的请求体(request body)被反序列化为 Java 对象之前进行拦截和修改。它为开发者提供了一个钩子,可以在请求处理过程中插入自定义的逻辑,例如对请求体进行预处理、验证或日志记录。
1.1.1 源码定义如下:
public interface RequestBodyAdvice {
boolean supports(MethodParameter methodParameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType);
HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException;
Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType);
@Nullable
Object handleEmptyBody(@Nullable Object body, HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType);
}
- Supports():该方法用于确定是否应该应用这个 Requestbodyadvice 实例。返回 True 表示支持当前的转换。
- Beforebodyread():在读取请求体之前调用,可以用于包装或修改传入的 Httpinputmessage。
- Afterbodyread():在请求体被读取并转换为 Java 对象之后调用,可以修改或替换读取到的对象。
- Handleemptybody():在请求体为空时调用,可以提供一个默认对象或处理空请求体的情况。
1.1.2 实际应用
在实际应用中,RequestBodyAdvice 可用于以下场景:
- 日志记录:记录请求体的内容,便于调试和审计。
- 预处理:在反序列化之前对请求体进行预处理,如解密或解码。
- 验证:在反序列化之前对请求体进行验证,如检查 JSON 结构是否正确。
- 默认值:处理空请求体并提供默认值。
1.2 ResponseBodyAdvice
ResponseBodyAdvice 是 Spring MVC 框架中的一个接口,允许在响应体写入之前进行处理。它提供了一种方式,可以在 Spring MVC 将响应对象转换为 HTTP 响应体之前,对响应数据进行修改或处理。
1.2.1 源码定义如下:
public interface ResponseBodyAdvice<T> {
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
@Nullable
T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response);
}
-
supports():这个方法用于判断是否要对给定的返回类型和转换器类型应用当前的 ResponseBodyAdvice 实现。返回 true 表示要应用,返回 false 表示不应用。
-
beforeBodyWrite():这个方法在响应体写入之前调用,允许对响应数据进行修改或处理。它返回处理后的响应数据。
1.2.2 实际应用
ResponseBodyAdvice 可以用于多种应用场景,例如:
- 数据加密:在将响应数据返回给客户端之前对其进行加密。
- 数据格式转换:根据客户端的请求参数动态调整响应数据的格式,例如 XML 转 JSON。
- 统一响应格式:对所有 API 接口的响应数据进行统一包装,确保一致的响应格式。
- 响应头设置:在返回响应体之前设置或修改响应头,例如添加自定义头部信息。
2.项目实战案例
2.1 使用ResquestBodyAdvice对接口入参进行加解密和验签
之前我们在总结Spring Boot如何优雅提高接口数据安全性一文中强调了接口数据安全的重要性,特别是对外提供的open api接口,肯定是不能让接口数据裸奔的。强烈建议点击链接跳转之前总结的文章仔细看看,可以了解需求功能背景和之前的AOP切面实现,再来和今天我们使用RequestBodyAdvice的实现对比一下两者的优劣势,这样就掌握了两个知识点。
在Spring Boot项目中提高接口安全的核心所在:加密和加签,加固接口参数、验证复杂度。
加密:对参数进行加密传输,拒绝接口参数直接暴露,这样就可以有效做到防止别人轻易准确地获取到接口参数定义和传参格式要求了。
加签:对接口参数进行加签,可以有效防止接口参数被篡改和接口参数被重放恶刷。
闲话少续,这里假如你没有点击链接查看之前的需求功能背景,所以我简要叙述下之前需求功能实现:就是使用非对称加密算法RSA和对称加密算法AES对接口出入参数进行加解密。为啥使用这两种加密算法呢?
AES 是对称加密算法,优点:加密速度快;缺点:如果秘钥丢失,就容易解密密文,安全性相对比较差
RSA 是非对称加密算法 , 优点:安全 ;缺点:加密速度慢
加解密
具体步骤如下:
- 客户端(调用接口方)随机生成AES加解密的密钥aes key,这里的AES密钥每次调接口都需要随机生成,可以有效提高安全性。
- 使用aes key对接口参数requestBody进行加密,data=base64(AES(json参数))
- 通过RSA加密算法加密aes key,有效保证aes算法的密钥的可靠安全性 key=base64(RSA(aes key))
- 经过上面的步骤,得到了加密后的业务参数及密钥,这时候就可以发送请求调用接口了
- 服务端接收到请求之后,先通过RSA算法对key进行解密获取到ase key, 再通过aes key解密data得到真正json参数,最后映射到接口方法的参数对象上,供controller的业务方法逻辑使用。
- 业务方法执行完成后,对响应参数进行加密,加密流程和上面的1、2、3一样
- 客户端收到响应参数之后,和步骤5一样解密响应参数,就拿到了真正的数据结果了。
加签
具体流程如下
- 对请求参数对象param转sortMap保证参数拼接的有序性,如果接口没有参数也没有关系,这里转成一个空的sortMap
- 按照约定拼接生成字符串content = sortMap + nonce + timestamp
- 使⽤SHA1WithRSA算法及私钥对concent进⾏签名sign
- 服务端判断timestamp是否超过签名有效期和nonce是否重复使用
- 服务端和步骤2一样规则生成字符串content
- 使⽤SHA1WithRSA算法及公钥对concent和sign进行验签
- 强烈建议跳转Spring Boot如何优雅提高接口数据安全性去看看可以了解的更清楚些,有流程图那些,更详细。
代码实现
注解ApiSecurity
/**
* @author fjzheng
* @version 1.0
* @date 2023/5/2 11:50
*
* 该注解用于标识 需要经过加密或者加签来加固接口安全性的接口
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Documented
public @interface ApiSecurity {
@Alias("isSign")
boolean value() default true;
/**
* 是否加签验证,默认开启
* @return
*/
@Alias("value")
boolean isSign() default true;
/**
* 接口请求参数是否需要解密
* @return
*/
boolean decryptRequest() default false;
/**
* 接口响应参数是否需要加密
* @return
*/
boolean encryptResponse() default false;
}
RequestBodyAdvice实现
@RestControllerAdvice
public class RequestBodyHandlerAdvice implements RequestBodyAdvice {
@Resource
private ApiSecurityProperties apiSecurityProperties;
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String SIGN_KEY = "X-Sign";
private static final String NONCE_KEY = "X-Nonce";
private static final String TIMESTAMP_KEY = "X-Timestamp";
/**
*
* @param methodParameter 包含控制器方法的参数信息
* @param targetType 目标类型,即请求体将要转换成的 Java 类型
* @param converterType 将要使用的消息转换器的类型
* @return
*/
@Override
public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return methodParameter.hasMethodAnnotation(ApiSecurity.class)
|| AnnotatedElementUtils.hasAnnotation(methodParameter.getDeclaringClass(), ApiSecurity.class);
}
/**
* 接口入参解密
* @param inputMessage 包含 HTTP 请求的头和体
* @param parameter 包含控制器方法的参数信息
* @param targetType 目标类型,即请求体将要转换成的 Java 类型
* @param converterType 将要使用的消息转换器的类型
* @return 返回新的流
* @throws IOException
*/
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
ApiSecurity apiSecurity = getApiSecurity(parameter);
// 判断接口参数是否需要解密
boolean decryptRequest = apiSecurity.decryptRequest();
if (!decryptRequest) {
return inputMessage;
}
InputStream inputStream = inputMessage.getBody();
String body = IoUtil.read(inputStream, StandardCharsets.UTF_8);
if (StringUtils.isBlank(body)) {
throw new BizException("请求参数body不能为空");
}
// 加密传参格式固定为ApiSecurityParam
ApiSecurityParam apiSecurityParam = JSON.parseObject(body, ApiSecurityParam.class);
// 通过RSA私钥解密获取到aes秘钥
String aesKey = RSAUtil.decryptByPrivateKey(apiSecurityParam.getKey(), apiSecurityProperties.getRsaPrivateKey());
// 通过aes秘钥解密data参数数据,即真正实际的接口参数
String data = AESUtil.decrypt(apiSecurityParam.getData(), aesKey);
// 加密传参ApiSecurityParam可以接收签名参数,这里把签名参数放到header里面,方便在后面afterBodyRead中验签
HttpHeaders headers = inputMessage.getHeaders();
String timestamp = apiSecurityParam.getTimestamp();
if (StringUtils.isNotBlank(timestamp)) {
headers.set(TIMESTAMP_KEY, timestamp);
}
String nonce = apiSecurityParam.getNonce();
if (StringUtils.isNotBlank(nonce)) {
headers.set(NONCE_KEY, nonce);
}
String sign = apiSecurityParam.getSign();
if (StringUtils.isNotBlank(sign)) {
headers.set(SIGN_KEY, sign);
}
// 使用解密后的数据构造新的读取流, Spring MVC后续读取解析转换为接口
return new PtcHttpInputMessage(headers, data);
}
/**
* 验签
* @param body 已转换的 Java 对象,表示请求体的数据
* 其余参数和上面的{@link #beforeBodyRead(HttpInputMessage, MethodParameter, Type, Class)} 一样
*/
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
ApiSecurity apiSecurity = getApiSecurity(parameter);
boolean isSign = apiSecurity.isSign();
if (!isSign) {
return body;
}
// 验证签名sign
verifySign(inputMessage.getHeaders(), body);
return body;
}
/**
* 和 {@link #afterBodyRead(Object, HttpInputMessage, MethodParameter, Type, Class)}一样
* 只是这里处理body为空的这种情况,比如当body位空时,返回一个默认对象啥的
*/
@Override
public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return null;
}
ApiSecurity getApiSecurity(MethodParameter methodParameter) {
ApiSecurity apiSecurity = methodParameter.getMethodAnnotation(ApiSecurity.class);
return apiSecurity;
}
void verifySign(HttpHeaders headers, Object body) {
// 如果请求参数是加密传输的,先从ApiSecurityParam获取签名和时间戳放到headers里面这里读取。
// 如果请求参数是非加密即明文传输的,那签名参数只能放到header中
String sign = headers.getFirst(SIGN_KEY);
if (StringUtils.isBlank(sign)) {
throw new BizException("签名不能为空");
}
String nonce = headers.getFirst(NONCE_KEY);
if (StringUtils.isBlank(nonce)) {
throw new BizException("唯一标识不能为空");
}
String timestamp = headers.getFirst(TIMESTAMP_KEY);
if (StringUtils.isBlank(timestamp)) {
throw new BizException("时间戳不能为空");
}
try {
long time = Long.valueOf(timestamp);
// 判断timestamp时间戳与当前时间是否超过签名有效时长(过期时间根据业务情况进行配置),如果超过了就提示签名过期
long now = System.currentTimeMillis() / 1000;
if (now - time > apiSecurityProperties.getValidTime()) {
throw new BizException("签名已过期");
}
} catch (Exception e) {
throw new BizException("非法的时间戳");
}
// 判断nonce
boolean nonceExists = stringRedisTemplate.hasKey(NONCE_KEY + nonce);
if (nonceExists) {
//请求重复
throw new BizException("唯一标识nonce已存在");
}
// 验签
SortedMap sortedMap = SignUtil.beanToMap(body);
String content = SignUtil.getContent(sortedMap, nonce, timestamp);
boolean flag = RSAUtil.verifySignByPublicKey(content, sign, apiSecurityProperties.getRsaPublicKey());
if (!flag) {
throw new BizException("签名验证不通过");
}
stringRedisTemplate.opsForValue().set(NONCE_KEY+ nonce, "1", apiSecurityProperties.getValidTime(),
TimeUnit.SECONDS);
}
}
这里就是使用RequestBodyAdvice实现接口入参加解密和验签的,你可以和之前文章中使用aspectJ切面的实现,两者谁更优雅。
加密入参的数据格式:
@Data
public class ApiSecurityParam {
/**
* 应用id
*/
private String appId;
/**
* RSA加密后的aes秘钥,需解密
*/
private String key;
/**
* AES加密的json参数
*/
private String data;
/**
* 签名
*/
private String sign;
/**
* 时间戳
*/
private String timestamp;
/**
* 请求唯一标识
*/
private String nonce;
}
来看看调接口示例:
@PostMapping("/security")
@ApiSecurity(encryptResponse = true, decryptRequest = true)
public User testApiSecurity(@RequestBody User user) {
System.out.println(user);
return user;
}
执行输出如下:
可以看到对接口入参正常解密然后进行逻辑处理,最后对接口返回结果做了加密处理,这是在ResponseBodyAdvice中实现的,稍后我们会来分析的。
调整下请求测试接口,然后返回结果不加密直接返回明文,让我们来看看效果:
@PostMapping("/security")
@ApiSecurity(decryptRequest = true)
public User testApiSecurity(@RequestBody User user) {
System.out.println(user);
return user;
}
执行如下所示:
可以看出正常返回了明文结果,我们从接口方法定义知道就是简单地入参加密的user解密之后返回,验证无误。这里还展示返回结果的统一封装格式code, msg, data,这也是通过ResponseBodyAdvice实现的。有没有一种呼之欲出、按耐不住的感觉呀,有的话那么我们接下来就来看看它吧。
2.2 使用ResponseBodyAdvice对返回结果统一封装格式和加密
关于这个知识点之前就有总结过了:Spring Boot如何优雅实现结果统一封装和异常统一处理。可跳转查看,这里结合结果加密返回要求再来看看吧:
@RestControllerAdvice
@Slf4j
public class ResponseResultBodyAdvice implements ResponseBodyAdvice<Object> {
@Resource
private ObjectMapper objectMapper;
@Resource
private ApiSecurityProperties apiSecurityProperties;
/**
* 判断类或者方法是否使用了 @ResponseResultBody
*/
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseResultBody.class)
|| returnType.hasMethodAnnotation(ResponseResultBody.class)
|| AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ApiSecurity.class)
|| returnType.hasMethodAnnotation(ApiSecurity.class);
}
/**
* 当类或者方法使用了 @ResponseResultBody 就会调用这个方法
* 如果返回类型是string,那么springmvc是直接返回的,此时需要手动转化为json
* 因为当body都为null时,下面的非加密下的if判断参数类型的条件都不满足,如果接口返回类似为String,
* 会报错com.shepherd.fast.global.ResponseVO cannot be cast to java.lang.String
*/
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
Method method = returnType.getMethod();
Class<?> returnClass = method.getReturnType();
Boolean enable = apiSecurityProperties.getEnable();
ApiSecurity apiSecurity = method.getAnnotation(ApiSecurity.class);
if (Objects.isNull(apiSecurity)) {
apiSecurity = method.getDeclaringClass().getAnnotation(ApiSecurity.class);
}
if (enable && Objects.nonNull(apiSecurity) && apiSecurity.encryptResponse() && Objects.nonNull(body)) {
// 只需要加密返回data数据内容
if (body instanceof ResponseVO) {
body = ((ResponseVO) body).getData();
}
JSONObject jsonObject = encryptResponse(body);
body = jsonObject;
} else {
if (body instanceof String || Objects.equals(returnClass, String.class)) {
String value = objectMapper.writeValueAsString(ResponseVO.success(body));
return value;
}
// 防止重复包裹的问题出现
if (body instanceof ResponseVO) {
return body;
}
}
return ResponseVO.success(body);
}
JSONObject encryptResponse(Object result) {
String aseKey = AESUtil.generateAESKey();
String content = JSONObject.toJSONString(result);
String data = AESUtil.encrypt(content, aseKey);
String key = RSAUtil.encryptByPublicKey(aseKey, apiSecurityProperties.getRsaPublicKey());
JSONObject jsonObject = new JSONObject();
jsonObject.put("key", key);
jsonObject.put("data", data);
return jsonObject;
}
}
该实现类实现逻辑大概是这样的:判断controller的接口方法或者类上有没有标注了注解@ResponseResultBody(控制返回结果统一格式)或者@ApiSecurity(控制接口参数加解密)。有的话进入方法#beforeBodyWrite()执行封装格式或者加密返回处理逻辑
碍于篇幅问题,涉及到一些非核心逻辑类,比如说工具类啥的这里就不一一展示,完整代码请移步仓库:https://github.com/plasticene/plasticene-boot-starter-parent
项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用
Github地址:https://github.com/plasticene/plasticene-boot-starter-parent
Gitee地址:https://gitee.com/plasticene3/plasticene-boot-starter-parent
微信公众号:Shepherd进阶笔记
交流探讨qun:Shepherd_126
3.实现原理
当 Spring MVC 接收到一个 HTTP 请求时,流程大致如下:
DispatchServlet 接收到请求。
HandlerMapping 找到对应的处理器(Controller)。
HandlerAdapter 调用对应的处理器方法。
HttpMessageConverter 将请求体转换为处理器方法的参数对象。
处理器方法 执行并返回结果。
HttpMessageConverter 将处理器方法的返回结果转换为响应体。
返回响应
大致执行流程图如下所示:
接下来我们通过上面场景示例调试代码来看看RequestBodyAdvice和ResponseBodyAdvice底层是怎么实现的
执行代码你会发现是AbstractMessageConverterMethodArgumentResolver的#readWithMessageConverters()方法在调用RequestBodyAdvice定义的相关方法
主要是在HttpMessageConverter对requestBody的前后做一些处理和body为空的时候做处理。
getAdvice()返回是RequestResponseBodyAdviceChain:
getMatchingAdvice(parameter, RequestBodyAdvice.class)方法获取所有的RequestBodyAdvice进行遍历执行对应的方法
private <A> List<A> getMatchingAdvice(MethodParameter parameter, Class<? extends A> adviceType) {
// 获取RequestBodyAdvice类型的advice(此advice是我们定义实现RequestBodyAdvice接口的类)
List<Object> availableAdvice = getAdvice(adviceType);
if (CollectionUtils.isEmpty(availableAdvice)) {
return Collections.emptyList();
}
List<A> result = new ArrayList<>(availableAdvice.size());
for (Object advice : availableAdvice) {
if (advice instanceof ControllerAdviceBean) {
ControllerAdviceBean adviceBean = (ControllerAdviceBean) advice;
if (!adviceBean.isApplicableToBeanType(parameter.getContainingClass())) {
continue;
}
// 返回的是我们定义的Advice,即根据Bean的名称从BeanFactory中获取Bean对象
advice = adviceBean.resolveBean();
}
// 判断这个类是否是RequestBodyAdvice类型,如果不是就不会加到结果集,所以这就是我们实现RequestBodyAdvice的原因
if (adviceType.isAssignableFrom(advice.getClass())) {
result.add((A) advice);
}
}
return result;
}
这里的#getAdvice():
private List<Object> getAdvice(Class<?> adviceType) {
if (RequestBodyAdvice.class == adviceType) {
return this.requestBodyAdvice;
}
else if (ResponseBodyAdvice.class == adviceType) {
return this.responseBodyAdvice;
}
else {
throw new IllegalArgumentException("Unexpected adviceType: " + adviceType);
}
}
看看this.requestBodyAdvice:
这里的RequestResponseBodyAdviceChain构造方法参数List
标签:body,return,String,RequestBodyAdvice,SpringMVC,接口,ResponseBodyAdvice,参数,加密 From: https://www.cnblogs.com/dinopell/p/18386715