Spring MVC接口数据加密传输

前言

假设现在有个需求,要实现接口请求体参数和响应数据的加密传输,换作是你会如何实现呢?

最容易想到的方案就是,在接口方法里直接接收加密后的密文字符串,手动解密成明文,再转换成对应的参数类型,伪代码如下所示:

java 复制代码
@PostMapping("api")
public Object api(@RequestBody String encryptedData) {
    String decryptedData = decrypt(encryptedData);
    Params params = JSON.parseObject(decryptedData);
    .....
}

这个方案的缺点是代码侵入性太强,接口方法更应该专注于业务。另外就是处理起来太麻烦,会产生很多冗余代码。

有没有更优雅的处理方式呢?

RequestResponseBodyAdviceChain

通过阅读 Spring MVC 的源码,我们发现它提供了两个很有用的接口:RequestBodyAdvice、ResponseBodyAdvice。从名字就可以看出来,它们分别是对请求体和响应体的增强接口。

实现 RequestBodyAdvice 接口,允许开发者在 Spring MVC 把请求体转换为方法参数前后做一些拦截处理。

java 复制代码
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);
	
	// 请求体为空时的处理
	Object handleEmptyBody(@Nullable Object body, HttpInputMessage inputMessage, MethodParameter parameter,
						   Type targetType, Class<? extends HttpMessageConverter<?>> converterType);
}

实现 ResponseBodyAdvice 接口,允许开发者在响应数据前修改响应体内容。

java 复制代码
public interface ResponseBodyAdvice<T> {
  
  // 是否支持返回值类型
  boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
  
  // 响应数据前置拦截
  T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
					  Class<? extends HttpMessageConverter<?>> selectedConverterType,
					  ServerHttpRequest request, ServerHttpResponse response);
}

还有一个类 RequestResponseBodyAdviceChain,同时实现上述两个接口。内部分别聚合了一组 RequestBodyAdvice 和 ResponseBodyAdvice,方便对多个增强器做链式调用。

java 复制代码
class RequestResponseBodyAdviceChain implements RequestBodyAdvice, ResponseBodyAdvice<Object> {
  
  private final List<Object> requestBodyAdvice = new ArrayList<>(4);
  
  private final List<Object> responseBodyAdvice = new ArrayList<>(4);
}

这些增强器会在什么时候触发呢?

RequestResponseBodyMethodProcessor

RequestResponseBodyMethodProcessor 类既是方法参数解析器,又是返回值处理器。也就是说,它既负责解析@RequestBody 参数,也负责响应@ResponseBody 返回值。

它在解析参数时,会调用AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters 方法,通过 HttpMessageConverter 转换器把请求体转换成目标参数类型。在转换前触发 RequestBodyAdvice 的增强方法,允许你自定义请求体。

java 复制代码
for (HttpMessageConverter<?> converter : this.messageConverters) {
	Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
	GenericHttpMessageConverter<?> genericConverter =
			(converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null);
	if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
			(targetClass != null && converter.canRead(targetClass, contentType))) {
		if (message.hasBody()) {
		  // 前置拦截
			HttpInputMessage msgToUse =
					getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
			// 读取
			body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
					((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
			// 后置拦截
			body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
		}
		else {
			// 处理空请求体
			body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
		}
		break;
	}
}

处理返回值也是一个道理,在响应数据前会触发 ResponseBodyAdvice,允许你自定义响应内容。

java 复制代码
for (HttpMessageConverter<?> converter : this.messageConverters) {
	GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ?
			(GenericHttpMessageConverter<?>) converter : null);
	if (genericConverter != null ?
			((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) :
			converter.canWrite(valueType, selectedMediaType)) {
		// MappingJackson2HttpMessageConverter
		body = getAdvice().beforeBodyWrite(body, returnType, selectedMediaType,
				(Class<? extends HttpMessageConverter<?>>) converter.getClass(),
				inputMessage, outputMessage);
		if (body != null) {
			Object theBody = body;
			LogFormatUtils.traceDebug(logger, traceOn ->
					"Writing [" + LogFormatUtils.formatValue(theBody, !traceOn) + "]");
			addContentDispositionHeader(inputMessage, outputMessage);
			if (genericConverter != null) {
				genericConverter.write(body, targetType, selectedMediaType, outputMessage);
			}
			else {
				((HttpMessageConverter) converter).write(body, selectedMediaType, outputMessage);
			}
		}
		else {
			if (logger.isDebugEnabled()) {
				logger.debug("Nothing to write: null body");
			}
		}
		return;
	}
}

综上所述,这俩扩展点刚好可以满足接口数据加密传输的需求。

实战

如下所示,我们有一个示例接口,用于获取用户信息:

java 复制代码
@RestController
public class UserController {

    @PostMapping("user/get")
    public R<UserResult> get(@RequestBody GetUserParam param) {
        param.checkParams();
        UserResult result = new UserResult();
        result.setUserId(param.userId);
        result.setName("Lisa");
        result.setAge(18);
        return R.ok(result);
    }

    @Data
    public static class GetUserParam implements Params {

        private String userId;

        @Override
        public void checkParams() {
            Assert.hasText(userId, "无效用户ID");
        }
    }

    @Data
    public static class UserResult {
        private String userId;
        private String name;
        private Integer age;
    }
}

明文传输时,请求体和响应体是这样的:

json 复制代码
{
    "userId":"1001"
}

{
    "data": {
        "userId": "1001",
        "name": "Lisa",
        "age": 18
    },
    "code": 200,
    "message": "success"
}

现在,我们在不修改接口的情况下,利用上述两个扩展点来实现数据加密传输。

数据加密方式选择对称加密 AES,新建 EncryptRequestResponseBodyAdvice 类,同时实现 RequestBodyAdvice、ResponseBodyAdvice 接口,完成数据加解密逻辑。

java 复制代码
@ControllerAdvice
public class EncryptRequestResponseBodyAdvice extends RequestBodyAdviceAdapter implements ResponseBodyAdvice<R> {

    private final Log log = LogFactory.getLog(EncryptRequestResponseBodyAdvice.class);
    private final byte[] key = "B6217B035CD94F78".getBytes();

    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return Params.class.isAssignableFrom(methodParameter.getParameterType());
    }

    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        return new HttpInputMessage() {
            @Override
            public InputStream getBody() throws IOException {
                InputStream inputStream = inputMessage.getBody();
                byte[] bytes = new byte[inputStream.available()];
                inputStream.read(bytes);
                String data = JSON.parseObject(bytes).getString("data");
                log.info("RequestBody 密文:" + data);
                data = SecureUtil.aes(key).decryptStr(data);
                log.info("RequestBody 明文:" + data);
                return new ByteArrayInputStream(data.getBytes());
            }

            @Override
            public HttpHeaders getHeaders() {
                return inputMessage.getHeaders();
            }
        };
    }

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return R.class.equals(returnType.getParameterType());
    }

    @Override
    public R beforeBodyWrite(R body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        if (body.getData() != null) {
            String data = JSON.toJSONString(body.getData());
            log.info("ResponseBody 明文:" + data);
            data = SecureUtil.aes(key).encryptBase64(data);
            log.info("ResponseBody 密文:" + data);
            body.setData(data);
        }
        return body;
    }
}

现在请求体必须传输密文了,否则解密会失败。请求体和响应体是这样的:

json 复制代码
{
    "data": "ZejOfDFiQtpMR0jHD14GPVCbr8pRI15j6tmyUjmrcFg="
}

{
    "data": "1DF3cx13KWUOt4bDTvRMDjV4SKCg1P6D8VuZ9yoJIazPxKk66wnk+U1VN9Fqbb2OYyCv4b1s4P5PB4KVDC8IVA==",
    "code": 200,
    "message": "success"
}

响应数据的 data 解密后就是正常的 UserResult 数据。

尾巴

Spring MVC 提供了两个扩展点:RequestBodyAdvice、ResponseBodyAdvice。前者可以在请求体转换为接口方法参数时自定义请求体数据,后者可以在响应@ResponseBody 数据前自定义响应体数据。RequestResponseBodyAdviceChain 同时维护了这两组扩展点实现,以实现统一的链式调用。RequestResponseBodyMethodProcessor 既负责 @RequestBody 参数的解析,又负责 @ResponseBody 数据的响应,解析参数时会自动触发 RequestBodyAdvice 扩展点,响应数据前会触发 ResponseBodyAdvice 扩展点。通过这俩扩展点,我们可以在不修改 Controller 的前提下轻松实现数据的加密传输等需求。

相关推荐
小灰灰__20 分钟前
IDEA加载通义灵码插件及使用指南
java·ide·intellij-idea
夜雨翦春韭23 分钟前
Java中的动态代理
java·开发语言·aop·动态代理
程序媛小果44 分钟前
基于java+SpringBoot+Vue的宠物咖啡馆平台设计与实现
java·vue.js·spring boot
追风林1 小时前
mac m1 docker本地部署canal 监听mysql的binglog日志
java·docker·mac
芒果披萨1 小时前
El表达式和JSTL
java·el
duration~2 小时前
Maven随笔
java·maven
zmgst2 小时前
canal1.1.7使用canal-adapter进行mysql同步数据
java·数据库·mysql
跃ZHD2 小时前
前后端分离,Jackson,Long精度丢失
java
blammmp2 小时前
Java:数据结构-枚举
java·开发语言·数据结构
暗黑起源喵3 小时前
设计模式-工厂设计模式
java·开发语言·设计模式