阅读文本大概需要25分钟。
前提
前段时间在做一个对外的网关项目,涉及到加密和解密模块,这里详细分析解决方案和适用的场景。为了模拟真实的交互场景,先定制一下整个交互流程。第三方传输(包括请求和响应)数据报文包括三个部分:
1、timestamp,long类型,时间戳。
2、data,String类型,实际的业务请求数据转化成的Json字符串再进行加密得到的密文。
3、sign,签名,生成规则算法伪代码是SHA-256(data=xxx×tamp=11111),防篡改。
为了简单起见,加密和解密采用AES,对称秘钥为"throwable"。上面的场景和加解密例子仅仅是为了模拟真实场景,安全系数低,切勿直接用于生产环境。
现在还有一个地方要考虑,就是无法得知第三方如何提交请求数据,假定都是采用POST的Http请求方法,提交报文的时候指定ContentType为application/json或者application/x-www-form-urlencoded,两种ContentType提交方式的请求体是不相同的:
timestamp=xxxx&data=yyyyyy&sign=zzzzzzz //application/json {"timestamp":xxxxxx,"data":"yyyyyyyy","sign":"zzzzzzz"}//application/x-www-form-urlencoded
最后一个要考虑的地方是,第三方强制要求部分接口需要用明文进行请求,在提供一些接口方法的时候,允许使用明文交互。总结一下就是要做到以下三点:
1、需要加解密的接口请求参数要进行解密,响应结果要进行加密。
2、不需要加解密的接口可以用明文请求。
3、兼容ContentType为application/json或者application/x-www-form-urlencoded两种方式。
上面三种情况要同时兼容算是十分严苛的场景,在生产环境中可能也是极少情况下才遇到,不过还是能找到相对优雅的解决方案。先定义两个特定场景的接口:
1、下单接口(加密)
URL:/order/save
HTTP METHOD:POST
ContentType:application/x-www-form-urlencoded
原始参数:orderId=yyyyyyyyy&userId=xxxxxxxxx&amount=zzzzzzzzz
加密参数:timestamp=xxxx&data=yyyyyy&sign=zzzzzzz
2、订单查询接口(明文)
URL:/order/query
ContentType:application/json
HTTP METHOD:POST
原始参数:{"userId":"xxxxxxxx"}
两个接口的ContentType不相同是为了故意复杂化场景,在下面的可取方案中,做法是把application/x-www-form-urlencoded中的形式如xxx=yyy&aaa=bbb的表单参数和application/json中形式如{"key":"value"}的请求参数统一当做application/json形式的参数处理,这样的话,我们就可以直接在控制器方法中使用@RequestBody。
方案
我们首先基于上面说到的加解密方案,提供一个加解密工具类:
/** *SINGLETON */ SINGLETON; privatestaticfinalStringSECRET="throwable"; privatestaticfinalStringCHARSET="UTF-8"; publicStringsha(Stringraw)throwsException{ MessageDigestmessageDigest=MessageDigest.getInstance("SHA-256"); messageDigest.update(raw.getBytes(CHARSET)); returnHex.encodeHexString(messageDigest.digest()); } privateCiphercreateAesCipher()throwsException{ returnCipher.getInstance("AES"); } publicStringencryptByAes(Stringraw)throwsException{ CipheraesCipher=createAesCipher(); KeyGeneratorkeyGenerator=KeyGenerator.getInstance("AES"); keyGenerator.init(128,newSecureRandom(SECRET.getBytes(CHARSET))); SecretKeysecretKey=keyGenerator.generateKey(); SecretKeySpecsecretKeySpec=newSecretKeySpec(secretKey.getEncoded(),"AES"); aesCipher.init(Cipher.ENCRYPT_MODE,secretKeySpec); byte[]bytes=aesCipher.doFinal(raw.getBytes(CHARSET)); returnHex.encodeHexString(bytes); } publicStringdecryptByAes(Stringraw)throwsException{ byte[]bytes=Hex.decodeHex(raw); CipheraesCipher=createAesCipher(); KeyGeneratorkeyGenerator=KeyGenerator.getInstance("AES"); keyGenerator.init(128,newSecureRandom(SECRET.getBytes(CHARSET))); SecretKeysecretKey=keyGenerator.generateKey(); SecretKeySpecsecretKeySpec=newSecretKeySpec(secretKey.getEncoded(),"AES"); aesCipher.init(Cipher.DECRYPT_MODE,secretKeySpec); returnnewString(aesCipher.doFinal(bytes),CHARSET); } }publicenumEncryptUtils{
注意为了简化加解密操作引入了apache的codec依赖:
<groupId>commons-codecgroupId> <artifactId>commons-codecartifactId> <version>1.11version> dependency><dependency>
上面的加解密过程中要注意两点:
1、加密后的结果是byte数组,要把二进制转化为十六进制字符串。
2、解密的时候要把原始密文由十六进制转化为二进制的byte数组。
上面两点必须注意,否则会产生乱码,这个和编码相关,具体可以看之前写的一篇博客。
不推荐的方案
其实最暴力的方案是直接定制每个控制器的方法参数类型,因为我们可以和第三方磋商哪些请求路径需要加密,哪些是不需要加密,甚至哪些是application/x-www-form-urlencoded,哪些是application/json的请求,这样我们可以通过大量的硬编码达到最终的目标。举个例子:
publicclassController1{ @Autowired privateObjectMapperobjectMapper; @PostMapping(value="/order/save", consumes=MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces=MediaType.APPLICATION_JSON_UTF8_VALUE) publicResponseEntitysaveOrder(@RequestParam(name="sign")Stringsign, @RequestParam(name="timestamp")Longtimestamp, @RequestParam(name="data")Stringdata)throwsException{ EncryptModelmodel=newEncryptModel(); model.setData(data); model.setTimestamp(timestamp); model.setSign(sign); StringinRawSign=String.format("data=%s×tamp=%d",model.getData(),model.getTimestamp()); StringinSign=EncryptUtils.SINGLETON.sha(inRawSign); if(!inSign.equals(model.getSign())){ thrownewIllegalArgumentException("验证参数签名失败!"); } //这里忽略实际的业务逻辑,简单设置返回的data为一个map Mapresult=newHashMap<>(8); result.put("code","200"); result.put("message","success"); EncryptModelout=newEncryptModel(); out.setTimestamp(System.currentTimeMillis()); out.setData(EncryptUtils.SINGLETON.encryptByAes(objectMapper.writeValueAsString(result))); StringrawSign=String.format("data=%s×tamp=%d",out.getData(),out.getTimestamp()); out.setSign(EncryptUtils.SINGLETON.sha(rawSign)); returnResponseEntity.ok(out); } @PostMapping(value="/order/query", consumes=MediaType.APPLICATION_JSON_VALUE, produces=MediaType.APPLICATION_JSON_UTF8_VALUE) publicResponseEntityqueryOrder(@RequestBodyUseruser){ Orderorder=newOrder(); //这里忽略实际的业务逻辑 returnResponseEntity.ok(order); } }@RestController
这种做法能在短时间完成对应的加解密功能,不需要加解密的接口不用引入相关的代码即可。缺陷十分明显,存在硬编码、代码冗余等问题,一旦接口增多,项目的维护难度大大提高。因此,这种做法是不可取的。
混合方案之Filter和SpringMVC的Http消息转换器
这里先说一点,这里是在SpringMVC中使用Filter。因为要兼容两种contentType,我们需要做到几点:
1、修改请求头的contentType为application/json。
2、修改请求体中的参数,统一转化为InputStream。
3、定制URL规则,区别需要加解密和不需要加解密的URL。
使用Filter有一个优点:不需要理解SpringMVC的流程,也不需要扩展SpringMVC的相关组件。缺点也比较明显:
1、如果需要区分加解密,只能通过URL规则进行过滤。
2、需要加密的接口的SpringMVC控制器的返回参数必须是加密后的实体类,无法做到加密逻辑和业务逻辑完全拆分,也就是解密逻辑对接收的参数是无感知,但是加密逻辑对返回结果是有感知的。
PS:上面提到的几个需要修改请求参数、请求头等是因为特殊场景的定制,所以如果无此场景可以直接看下面的"单纯的Json请求参数和Json响应结果"小节。流程大致如下:
编写Filter的实现和HttpServletRequestWrapper的实现:
@RequiredArgsConstructor publicclassCustomEncryptFilterextendsOncePerRequestFilter{ privatefinalObjectMapperobjectMapper; @Override protectedvoiddoFilterInternal(HttpServletRequestrequest, HttpServletResponseresponse, FilterChainfilterChain)throwsServletException,IOException{ //Content-Type StringcontentType=request.getContentType(); StringrequestBody=null; booleanshouldEncrypt=false; if(StringUtils.substringMatch(contentType,0,MediaType.APPLICATION_FORM_URLENCODED_VALUE)){ shouldEncrypt=true; requestBody=convertFormToString(request); }elseif(StringUtils.substringMatch(contentType,0,MediaType.APPLICATION_JSON_VALUE)){ shouldEncrypt=true; requestBody=convertInputStreamToString(request.getInputStream()); } if(!shouldEncrypt){ filterChain.doFilter(request,response); }else{ CustomEncryptHttpWrapperwrapper=newCustomEncryptHttpWrapper(request,requestBody); wrapper.putHeader("Content-Type",MediaType.APPLICATION_PROBLEM_JSON_UTF8_VALUE); filterChain.doFilter(wrapper,response); } } privateStringconvertFormToString(HttpServletRequestrequest){ Mapresult=newHashMap<>(8); EnumerationparameterNames=request.getParameterNames(); while(parameterNames.hasMoreElements()){ Stringname=parameterNames.nextElement(); result.put(name,request.getParameter(name)); } try{ returnobjectMapper.writeValueAsString(result); }catch(JsonProcessingExceptione){ thrownewIllegalArgumentException(e); } } privateStringconvertInputStreamToString(InputStreaminputStream)throwsIOException{ returnStreamUtils.copyToString(inputStream,Charset.forName("UTF-8")); } } //CustomEncryptHttpWrapper publicclassCustomEncryptHttpWrapperextendsHttpServletRequestWrapper{ privatefinalMapheaders=newHashMap<>(8); privatefinalbyte[]data; publicCustomEncryptHttpWrapper(HttpServletRequestrequest,Stringcontent){ super(request); data=content.getBytes(Charset.forName("UTF-8")); EnumerationheaderNames=request.getHeaderNames(); while(headerNames.hasMoreElements()){ Stringkey=headerNames.nextElement(); headers.put(key,request.getHeader(key)); } } publicvoidputHeader(Stringkey,Stringvalue){ headers.put(key,value); } @Override publicStringgetHeader(Stringname){ returnheaders.get(name); } @Override publicEnumerationgetHeaders(Stringname){ returnCollections.enumeration(Collections.singletonList(headers.get(name))); } @Override publicEnumerationgetHeaderNames(){ returnCollections.enumeration(headers.keySet()); } @Override publicServletInputStreamgetInputStream()throwsIOException{ ByteArrayInputStreaminputStream=newByteArrayInputStream(data); returnnewServletInputStream(){ @Override publicbooleanisFinished(){ return!isReady(); } @Override publicbooleanisReady(){ returninputStream.available()>0; } @Override publicvoidsetReadListener(ReadListenerlistener){ } @Override publicintread()throwsIOException{ returninputStream.read(); } }; } @Override publicBufferedReadergetReader()throwsIOException{ returnsuper.getReader(); } } //CustomEncryptConfiguration @Configuration publicclassCustomEncryptConfiguration{ @Bean publicFilterRegistrationBeancustomEncryptFilter(ObjectMapperobjectMapper){ FilterRegistrationBeanbean=newFilterRegistrationBean<>(newCustomEncryptFilter(objectMapper)); bean.addUrlPatterns("/e/*"); returnbean; } }//CustomEncryptFilter
控制器代码:
publicinterfaceEncryptable{ } @Data publicclassOrderimplementsEncryptable{ privateLonguserId; } @Data publicclassEncryptResponse<T>implementsEncryptable{ privateIntegercode; privateTdata; } @RequiredArgsConstructor @RestController publicclassController{ privatefinalObjectMapperobjectMapper; @PostMapping(value="/e/order/save", consumes=MediaType.APPLICATION_JSON_VALUE, produces=MediaType.APPLICATION_JSON_UTF8_VALUE) publicEncryptResponsesaveOrder(@RequestBodyOrderorder)throwsException{ //这里忽略实际的业务逻辑,简单设置返回的data为一个map EncryptResponseresponse=newEncryptResponse<>(); response.setCode(200); response.setData(order); returnresponse; } @PostMapping(value="/c/order/query", consumes=MediaType.APPLICATION_JSON_VALUE, produces=MediaType.APPLICATION_JSON_UTF8_VALUE) publicResponseEntityqueryOrder(@RequestBodyUseruser){ Orderorder=newOrder(); //这里忽略实际的业务逻辑 returnResponseEntity.ok(order); } }//可加密的,空接口
这里可能有人有疑问,为什么不在Filter做加解密的操作?因为考虑到场景太特殊,要兼容两种形式的表单提交参数,如果在Filter做加解密操作,会影响到Controller的编码,这就违反了全局加解密不影响到里层业务代码的目标。上面的Filter只会拦截URL满足/e/*的请求,因此查询接口/c/order/query不会受到影响。这里使用了标识接口用于决定请求参数或者响应结果是否需要加解密,也就是只需要在HttpMessageConverter中判断请求参数的类型或者响应结果的类型是否加解密标识接口的子类:
publicclassCustomEncryptHttpMessageConverterextendsMappingJackson2HttpMessageConverter{ privatefinalObjectMapperobjectMapper; @Override protectedObjectreadInternal(Class>clazz,HttpInputMessageinputMessage) throwsIOException,HttpMessageNotReadableException{ if(Encryptable.class.isAssignableFrom(clazz)){ EncryptModelin=objectMapper.readValue(StreamUtils.copyToByteArray(inputMessage.getBody()),EncryptModel.class); StringinRawSign=String.format("data=%s×tamp=%d",in.getData(),in.getTimestamp()); StringinSign; try{ inSign=EncryptUtils.SINGLETON.sha(inRawSign); }catch(Exceptione){ thrownewIllegalArgumentException("验证参数签名失败!"); } if(!inSign.equals(in.getSign())){ thrownewIllegalArgumentException("验证参数签名失败!"); } try{ returnobjectMapper.readValue(EncryptUtils.SINGLETON.decryptByAes(in.getData()),clazz); }catch(Exceptione){ thrownewIllegalArgumentException("解密失败!"); } }else{ returnsuper.readInternal(clazz,inputMessage); } } @Override protectedvoidwriteInternal(Objectobject,Typetype,HttpOutputMessageoutputMessage) throwsIOException,HttpMessageNotWritableException{ Class>clazz=(Class)type; if(Encryptable.class.isAssignableFrom(clazz)){ EncryptModelout=newEncryptModel(); out.setTimestamp(System.currentTimeMillis()); try{ out.setData(EncryptUtils.SINGLETON.encryptByAes(objectMapper.writeValueAsString(object))); StringrawSign=String.format("data=%s×tamp=%d",out.getData(),out.getTimestamp()); out.setSign(EncryptUtils.SINGLETON.sha(rawSign)); }catch(Exceptione){ thrownewIllegalArgumentException("参数签名失败!"); } super.writeInternal(out,type,outputMessage); }else{ super.writeInternal(object,type,outputMessage); } } }@RequiredArgsConstructor
自实现的HttpMessageConverter主要需要判断请求参数的类型和返回值的类型,从而判断是否需要进行加解密。
☆
往期精彩
☆
01漫谈发版哪些事,好课程推荐
02Linux的常用最危险的命令
03精讲Spring Boot—入门+进阶+实例
04优秀的Java程序员必须了解的GC哪些
05互联网支付系统整体架构详解
关注我
每天进步一点点
很干!在看吗?☟