今天这篇文章聊一聊接口安全问题,涉及到接口的加密、解密。
和产品、前端同学对外需求后,梳理了相关技术方案, 主要的需求点如下:
尽量少改动,不影响之前的业务逻辑; 考虑到时间紧迫性,可采用对称性加密方式,服务需要对接安卓、IOS、H5三端,另外考虑到H5端存储密钥安全性相对来说会低一些,故分针对H5和安卓、IOS分配两套密钥; 要兼容低版本的接口,后面新开发的接口可不用兼容; 接口有GET和POST两种接口,需要都要进行加解密;需求解析:
服务端、客户端和H5统一拦截加解密,网上有成熟方案,也可以按其他服务中实现的加解密流程来搞; 使用AES放松加密,考虑到H5端存储密钥安全性相对来说会低一些,故分针对H5和安卓、IOS分配两套密钥; 本次涉及客户端和服务端的整体改造,经讨论,新接口统一加 /secret/ 前缀来区分按本次需求来简单还原问题,定义两个对象,后面用得着,
用户类:
@Data public class User { private Integer id ; private String name ; private UserType userType = UserType .COMMON ; @JsonFormat ( pattern = "yyyy-MM-dd HH:mm:ss" ) private LocalDateTime registerTime ; }
用户类型枚举类:
@Getter @JsonFormat ( shape = JsonFormat .Shape .OBJECT ) public enum UserType { VIP ( "VIP用户" ) , COMMON ( "普通用户" ) ; private String code ; private String type ; UserType ( String type ) { this .code = name ( ) ; this .type = type ; } }
构造一个简单的用户列表查询示例:
@RestController @RequestMapping ( value = { "/user" , "/secret/user" } ) public class UserController { @RequestMapping ( "/list" ) ResponseEntity < List < User >> listUser ( ) { List < User > users = new ArrayList <> ( ) ; User u = new User ( ) ; u .setId ( 1 ) ; u .setName ( "boyka" ) ; u .setRegisterTime ( LocalDateTime .now ( ) ) ; u .setUserType ( UserType .COMMON ) ; users .add ( u ) ; ResponseEntity < List < User >> response = new ResponseEntity <> ( ) ; response .setCode ( 200 ) ; response .setData ( users ) ; response .setMsg ( "用户列表查询成功" ) ; return response ; } }
调用:localhost:8080/user/list
查询结果如下,没毛病:
{ "code" : 200 , "data" : [ { "id" : 1 , "name" : "boyka" , "userType" : { "code" : "COMMON" , "type" : "普通用户" } , "registerTime" : "2022-03-24 23:58:39" } ] , "msg" : "用户列表查询成功" }
目前主要是利用ControllerAdvice来对请求和响应体进行拦截,主要定义SecretRequestAdvice对请求进行加密和SecretResponseAdvice对响应进行加密(实际情况会稍微复杂一点,项目中又GET类型请求,自定义了一个Filter进行不同的请求解密处理)。
好了,网上的ControllerAdvice使用示例非常多,我这把两个核心方法给大家展示看看,相信大佬们一看就晓得了,不需多言。上代码:
SecretRequestAdvice请求解密:
@ControllerAdvice @Order ( Ordered .HIGHEST_PRECEDENCE ) @Slf4j public class SecretRequestAdvice extends RequestBodyAdviceAdapter { @Override public boolean supports ( MethodParameter methodParameter , Type type , Class < ? extends HttpMessageConverter < ? >> aClass ) { return true ; } @Override public HttpInputMessage beforeBodyRead ( HttpInputMessage inputMessage , MethodParameter parameter , Type targetType , Class < ? extends HttpMessageConverter < ? >> converterType ) throws IOException { // 如果支持加密消息,进行消息解密。 String httpBody ; if ( Boolean .TRUE .equals ( SecretFilter .secretThreadLocal .get ( ) ) ) { httpBody = decryptBody ( inputMessage ) ; } else { httpBody = StreamUtils .copyToString ( inputMessage .getBody ( ) , Charset .defaultCharset ( ) ) ; } // 返回处理后的消息体给messageConvert return new SecretHttpMessage ( new ByteArrayInputStream ( httpBody .getBytes ( ) ) , inputMessage .getHeaders ( ) ) ; } /** * 解密消息体 * * @param inputMessage 消息体 * @return 明文 */ private String decryptBody ( HttpInputMessage inputMessage ) throws IOException { InputStream encryptStream = inputMessage .getBody ( ) ; String requestBody = StreamUtils .copyToString ( encryptStream , Charset .defaultCharset ( ) ) ; // 验签过程 HttpHeaders headers = inputMessage .getHeaders ( ) ; if ( CollectionUtils .isEmpty ( headers .get ( "clientType" ) ) || CollectionUtils .isEmpty ( headers .get ( "timestamp" ) ) || CollectionUtils .isEmpty ( headers .get ( "salt" ) ) || CollectionUtils .isEmpty ( headers .get ( "signature" ) ) ) { throw new ResultException ( SECRET_API_ERROR , "请求解密参数错误,clientType、timestamp、salt、signature等参数传递是否正确传递" ) ; } String timestamp = String .valueOf ( Objects .requireNonNull ( headers .get ( "timestamp" ) ) .get ( 0 ) ) ; String salt = String .valueOf ( Objects .requireNonNull ( headers .get ( "salt" ) ) .get ( 0 ) ) ; String signature = String .valueOf ( Objects .requireNonNull ( headers .get ( "signature" ) ) .get ( 0 ) ) ; String privateKey = SecretFilter .clientPrivateKeyThreadLocal .get ( ) ; ReqSecret reqSecret = JSON .parseObject ( requestBody , ReqSecret .class ) ; String data = reqSecret .getData ( ) ; String newSignature = "" ; if ( ! StringUtils .isEmpty ( privateKey ) ) { newSignature = Md5Utils .genSignature ( timestamp + salt + data + privateKey ) ; } if ( ! newSignature .equals ( signature ) ) { // 验签失败 throw new ResultException ( SECRET_API_ERROR , "验签失败,请确认加密方式是否正确" ) ; } try { String decrypt = EncryptUtils .aesDecrypt ( data , privateKey ) ; if ( StringUtils .isEmpty ( decrypt ) ) { decrypt = "{}" ; } return decrypt ; } catch ( Exception e ) { log .error ( "error: " , e ) ; } throw new ResultException ( SECRET_API_ERROR , "解密失败" ) ; } }
SecretResponseAdvice响应加密:
@ControllerAdvice public class SecretResponseAdvice implements ResponseBodyAdvice { private Logger logger = LoggerFactory .getLogger ( SecretResponseAdvice .class ) ; @Override public boolean supports ( MethodParameter methodParameter , Class aClass ) { return true ; } @Override public Object beforeBodyWrite ( Object o , MethodParameter methodParameter , MediaType mediaType , Class aClass , ServerHttpRequest serverHttpRequest , ServerHttpResponse serverHttpResponse ) { // 判断是否需要加密 Boolean respSecret = SecretFilter .secretThreadLocal .get ( ) ; String secretKey = SecretFilter .clientPrivateKeyThreadLocal .get ( ) ; // 清理本地缓存 SecretFilter .secretThreadLocal .remove ( ) ; SecretFilter .clientPrivateKeyThreadLocal .remove ( ) ; if ( null != respSecret && respSecret ) { if ( o instanceof ResponseBasic ) { // 外层加密级异常 if ( SECRET_API_ERROR == ( ( ResponseBasic ) o ) .getCode ( ) ) { return SecretResponseBasic .fail ( ( ( ResponseBasic ) o ) .getCode ( ) , ( ( ResponseBasic ) o ) .getData ( ) , ( ( ResponseBasic ) o ) .getMsg ( ) ) ; } // 业务逻辑 try { String data = EncryptUtils .aesEncrypt ( JSON .toJSONString ( o ) , secretKey ) ; // 增加签名 long timestamp = System .currentTimeMillis ( ) / 1000 ; int salt = EncryptUtils .genSalt ( ) ; String dataNew = timestamp + "" + salt + "" + data + secretKey ; String newSignature = Md5Utils .genSignature ( dataNew ) ; return SecretResponseBasic .success ( data , timestamp , salt , newSignature ) ; } catch ( Exception e ) { logger .error ( "beforeBodyWrite error:" , e ) ; return SecretResponseBasic .fail ( SECRET_API_ERROR , "" , "服务端处理结果数据异常" ) ; } } } return o ; } }
OK, 代码Demo撸好了,试运行一波:
请求方法: localhost : 8080 / secret / user / list header : Content - Type : application / json signature : 55 efb04a83ca083dd1e6003cde127c45 timestamp : 1648308048 salt : 123456 clientType : ANDORID body体 : // 原始请求体 { "page" : 1 , "size" : 10 } // 加密后的请求体 { "data" : "1ZBecdnDuMocxAiW9UtBrJzlvVbueP9K0MsIxQccmU3OPG92oRinVm0GxBwdlXXJ" } // 加密响应体: { "data" : "fxHYvnIE54eAXDbErdrDryEsIYNvsOOkyEKYB1iBcre/QU1wMowHE2BNX/je6OP3NlsCtAeDqcp7J1N332el8q2FokixLvdxAPyW5Un9JiT0LQ3MB8p+nN23pTSIvh9VS92lCA8KULWg2nViSFL5X1VwKrF0K/dcVVZnpw5h227UywP6ezSHjHdA+Q0eKZFGTEv3IzNXWqq/otx5fl1gKQ==" , "code" : 200 , "signature" : "aa61f19da0eb5d99f13c145a40a7746b" , "msg" : "" , "timestamp" : 1648480034 , "salt" : 632648 } // 解密后的响应体: { "code" : 200 , "data" : [ { "id" : 1 , "name" : "boyka" , "registerTime" : "2022-03-27T00:19:43.699" , "userType" : "COMMON" } ] , "msg" : "用户列表查询成功" , "salt" : 0 }
OK,客户端请求加密-》发起请求-》服务端解密-》业务处理-》服务端响应加密-》客户端解密展示,看起来没啥问题,实际是头天下午花了2小时碰需求,差不多花1小时写好demo测试,然后对所有接口统一进行了处理,整体一下午赶脚应该行了吧,告诉H5和安卓端同学明儿上午联调(不小的大家到这个时候发现猫腻没有,当时确实疏忽了,翻了大车......)
次日,安卓端反馈,你这个加解密有问题,解密后的数据格式和之前不一样,仔细一看,擦,这个userType和registerTime是不对劲,开始思考:这个能是哪儿的问题呢?1s之后,初步定位,应该是响应体的JSON.toJSONString的问题:
String data = EncryptUtils .aesEncrypt ( JSON .toJSONString ( o ) ) ,
Debug断点调试,果然,是JSON.toJSONString(o)这一步骤转换出了问题,那JSON转换时是不是有高级属性可以配置生成想要的序列化格式呢?FastJson在序列化时提供重载方法,找到其中一个"SerializerFeature"参数可以琢磨一下,这个参数是可以对序列化进行配置的,它提供了很多配置类型,其中感觉这几个比较沾边:
WriteEnumUsingToString , WriteEnumUsingName , UseISO8601DateFormat
对枚举类型来说,默认是使用的WriteEnumUsingName(枚举的Name), 另一种WriteEnumUsingToString是重新toString方法,理论上可以转换成想要的样子,即这个样子:
@Getter @JsonFormat ( shape = JsonFormat .Shape .OBJECT ) public enum UserType { VIP ( "VIP用户" ) , COMMON ( "普通用户" ) ; private String code ; private String type ; UserType ( String type ) { this .code = name ( ) ; this .type = type ; } @Override public String toString ( ) { return "{" + "\"code\":\"" + name ( ) + '\"' + ", \"type\":\"" + type + '\"' + '}' ; } }
结果转换出来的数据是字符串类型"{"code":"COMMON", "type":"普通用户"}",这个方法好像行不通,还有什么好办法呢?思前想后,看文章开始定义的User和UserType类,标记数据序列化格式@JsonFormat,再突然想起之前看到过的一些文章,SpringMVC底层默认是使用Jackson进行序列化的,那好了,就用Jacksong实施呗,将SecretResponseAdvice中的序列化方法替换一下:
String data = EncryptUtils .aesEncrypt ( JSON .toJSONString ( o ) , secretKey ) ; 换为: String data = EncryptUtils .aesEncrypt ( new ObjectMapper ( ) .writeValueAsString ( o ) , secretKey ) ;
重新运行一波,走起:
{ "code" : 200 , "data" : [ { "id" : 1 , "name" : "boyka" , "userType" : { "code" : "COMMON" , "type" : "普通用户" } , "registerTime" : { "month" : "MARCH" , "year" : 2022 , "dayOfMonth" : 29 , "dayOfWeek" : "TUESDAY" , "dayOfYear" : 88 , "monthValue" : 3 , "hour" : 22 , "minute" : 30 , "nano" : 453000000 , "second" : 36 , "chronology" : { "id" : "ISO" , "calendarType" : "iso8601" } } } ] , "msg" : "用户列表查询成功" }
解密后的userType枚举类型和非加密版本一样了,舒服了,== 好像还不对,registerTime怎么变成这个样子了?原本是"2022-03-24 23:58:39"这种格式的,网上有很多解决方案,不过用在我们目前这个需求里面,就是有损改装了啊,不太可取,遂去Jackson官网上查找一下相关文档,当然Jackson也提供了ObjectMapper的序列化配置,重新再初始化配置ObjectMpper对象:
String DATE_TIME_FORMATTER = "yyyy-MM-dd HH:mm:ss" ; ObjectMapper objectMapper = new Jackson2ObjectMapperBuilder ( ) .findModulesViaServiceLoader ( true ) .serializerByType ( LocalDateTime .class , new LocalDateTimeSerializer ( DateTimeFormatter .ofPattern ( DATE_TIME_FORMATTER ) ) ) .deserializerByType ( LocalDateTime .class , new LocalDateTimeDeserializer ( DateTimeFormatter .ofPattern ( DATE_TIME_FORMATTER ) ) ) .build ( ) ;
转换结果:
{ "code" : 200 , "data" : [ { "id" : 1 , "name" : "boyka" , "userType" : { "code" : "COMMON" , "type" : "普通用户" } , "registerTime" : "2022-03-29 22:57:33" } ] , "msg" : "用户列表查询成功" }
OK,和非加密版的终于一致了,完了吗?感觉还是可能存在些什么问题,首先业务代码的时间序列化需求不一样,有"yyyy-MM-dd hh:mm:ss"的,也有"yyyy-MM-dd"的,还可能其他配置思考不到位的,导致和之前非加密版返回数据不一致的问题,到时候联调测出来了也麻烦,有没有一劳永逸的办法呢?哎,这个时候如果你看过 Spring 源码的话,就应该知道spring框架自身是怎么序列化的,照着配置应该就行嘛,好像有点道理,我这里不从0开始分析源码了。
跟着执行链路,找到具体的响应序列化,重点就是RequestResponseBodyMethodProcessor,
protected < T > void writeWithMessageConverters ( @Nullable T value , MethodParameter returnType , ServletServerHttpRequest inputMessage , ServletServerHttpResponse outputMessage ) throws IOException , HttpMediaTypeNotAcceptableException , HttpMessageNotWritableException { // 获取响应的拦截器链并执行beforeBodyWrite方法,也就是执行了我们自定义的SecretResponseAdvice中的beforeBodyWrite啦 body = this .getAdvice ( ) .beforeBodyWrite ( body , returnType , selectedMediaType , converter .getClass ( ) , inputMessage , outputMessage ) ; if ( body != null ) { // 执行响应体序列化工作 if ( genericConverter != null ) { genericConverter .write ( body , ( Type ) targetType , selectedMediaType , outputMessage ) ; } else { converter .write ( body , selectedMediaType , outputMessage ) ; } }
进而通过实例化的AbstractJackson2HttpMessageConverter对象找到执行序列化的核心方法
-> AbstractGenericHttpMessageConverter : public final void write ( T t , @Nullable Type type , @Nullable MediaType contentType , HttpOutputMessage outputMessage ) throws IOException , HttpMessageNotWritableException { ... this .writeInternal ( t , type , outputMessage ) ; outputMessage .getBody ( ) .flush ( ) ; } -> 找到Jackson序列化 AbstractJackson2HttpMessageConverter : // 从spring容器中获取并设置的ObjectMapper实例 protected ObjectMapper objectMapper ; protected void writeInternal ( Object object , @Nullable Type type , HttpOutputMessage outputMessage ) throws IOException , HttpMessageNotWritableException { MediaType contentType = outputMessage .getHeaders ( ) .getContentType ( ) ; JsonEncoding encoding = this .getJsonEncoding ( contentType ) ; JsonGenerator generator = this .objectMapper .getFactory ( ) .createGenerator ( outputMessage .getBody ( ) , encoding ) ; this .writePrefix ( generator , object ) ; Object value = object ; Class < ? > serializationView = null ; FilterProvider filters = null ; JavaType javaType = null ; if ( object instanceof MappingJacksonValue ) { MappingJacksonValue container = ( MappingJacksonValue ) object ; value = container .getValue ( ) ; serializationView = container .getSerializationView ( ) ; filters = container .getFilters ( ) ; } if ( type != null && TypeUtils .isAssignable ( type , value .getClass ( ) ) ) { javaType = this .getJavaType ( type , ( Class ) null ) ; } ObjectWriter objectWriter = serializationView != null ? this .objectMapper .writerWithView ( serializationView ) : this .objectMapper .writer ( ) ; if ( filters != null ) { objectWriter = objectWriter .with ( filters ) ; } if ( javaType != null && javaType .isContainerType ( ) ) { objectWriter = objectWriter .forType ( javaType ) ; } SerializationConfig config = objectWriter .getConfig ( ) ; if ( contentType != null && contentType .isCompatibleWith ( MediaType .TEXT_EVENT_STREAM ) && config .isEnabled ( SerializationFeature .INDENT_OUTPUT ) ) { objectWriter = objectWriter .with ( this .ssePrettyPrinter ) ; } // 重点进行序列化 objectWriter .writeValue ( generator , value ) ; this .writeSuffix ( generator , object ) ; generator .flush ( ) ; }
那么,可以看出SpringMVC在进行响应序列化的时候是从容器中获取的ObjectMapper实例对象,并会根据不同的默认配置条件进行序列化,那处理方法就简单了,我也可以从Spring容器拿数据进行序列化啊。SecretResponseAdvice进行如下进一步改造:
@ControllerAdvice public class SecretResponseAdvice implements ResponseBodyAdvice { @Autowired private ObjectMapper objectMapper ; @Override public Object beforeBodyWrite ( .... ) { ..... String dataStr = objectMapper .writeValueAsString ( o ) ; String data = EncryptUtils .aesEncrypt ( dataStr , secretKey ) ; ..... } }
经测试,响应数据和非加密版万全一致啦,还有GET部分的请求加密,以及后面加解密惨遭跨域问题,后面有空再和大家聊聊。
原文地址:https://mp.weixin.qq.com/s/mUUWhIxfixM-VoAwlu0fig
查看更多关于Spring Boot 接口数据加解密就该这样设计的详细内容...