前段時間在作一個對外的網關項目,涉及到加密和解密模塊,這裏詳細分析解決方案和適用的場景。爲了模擬真實的交互場景,先定製一下整個交互流程。第三方傳輸(包括請求和響應)數據報文包括三個部分:java
爲了簡單起見,加密和解密採用AES,對稱祕鑰爲"throwable"。上面的場景和加解密例子僅僅是爲了模擬真實場景,安全係數低,切勿直接用於生產環境。web
如今還有一個地方要考慮,就是沒法得知第三方如何提交請求數據,假定都是採用POST的Http請求方法,提交報文的時候指定ContentType爲application/json或者application/x-www-form-urlencoded,兩種ContentType提交方式的請求體是不相同的:算法
//application/x-www-form-urlencoded timestamp=xxxx&data=yyyyyy&sign=zzzzzzz //application/json {"timestamp":xxxxxx,"data":"yyyyyyyy","sign":"zzzzzzz"}
最後一個要考慮的地方是,第三方強制要求部分接口須要用明文進行請求,在提供一些接口方法的時候,容許使用明文交互。總結一下就是要作到如下三點:spring
上面三種狀況要同時兼容算是十分嚴苛的場景,在生產環境中可能也是極少狀況下才遇到,不過仍是能找到相對優雅的解決方案。先定義兩個特定場景的接口:apache
一、下單接口(加密)json
二、訂單查詢接口(明文)數組
兩個接口的ContentType不相同是爲了故意複雜化場景,在下面的可取方案中,作法是把application/x-www-form-urlencoded中的形式如xxx=yyy&aaa=bbb的表單參數和application/json中形式如{"key":"value"}的請求參數統一當作application/json形式的參數處理,這樣的話,咱們就能夠直接在控制器方法中使用@RequestBody。安全
咱們首先基於上面說到的加解密方案,提供一個加解密工具類:mvc
public enum EncryptUtils { /** * SINGLETON */ SINGLETON; private static final String SECRET = "throwable"; private static final String CHARSET = "UTF-8"; public String sha(String raw) throws Exception { MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); messageDigest.update(raw.getBytes(CHARSET)); return Hex.encodeHexString(messageDigest.digest()); } private Cipher createAesCipher() throws Exception { return Cipher.getInstance("AES"); } public String encryptByAes(String raw) throws Exception { Cipher aesCipher = createAesCipher(); KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); keyGenerator.init(128, new SecureRandom(SECRET.getBytes(CHARSET))); SecretKey secretKey = keyGenerator.generateKey(); SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getEncoded(), "AES"); aesCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec); byte[] bytes = aesCipher.doFinal(raw.getBytes(CHARSET)); return Hex.encodeHexString(bytes); } public String decryptByAes(String raw) throws Exception { byte[] bytes = Hex.decodeHex(raw); Cipher aesCipher = createAesCipher(); KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); keyGenerator.init(128, new SecureRandom(SECRET.getBytes(CHARSET))); SecretKey secretKey = keyGenerator.generateKey(); SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getEncoded(), "AES"); aesCipher.init(Cipher.DECRYPT_MODE, secretKeySpec); return new String(aesCipher.doFinal(bytes), CHARSET); } }
注意爲了簡化加解密操做引入了apache的codec依賴:app
<dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>1.11</version> </dependency>
上面的加解密過程當中要注意兩點:
上面兩點必須注意,不然會產生亂碼,這個和編碼相關,具體能夠看以前寫的一篇博客。
其實最暴力的方案是直接定製每一個控制器的方法參數類型,由於咱們能夠和第三方磋商哪些請求路徑須要加密,哪些是不須要加密,甚至哪些是application/x-www-form-urlencoded,哪些是application/json的請求,這樣咱們能夠經過大量的硬編碼達到最終的目標。舉個例子:
@RestController public class Controller1 { @Autowired private ObjectMapper objectMapper; @PostMapping(value = "/order/save", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) public ResponseEntity<EncryptModel> saveOrder(@RequestParam(name = "sign") String sign, @RequestParam(name = "timestamp") Long timestamp, @RequestParam(name = "data") String data) throws Exception { EncryptModel model = new EncryptModel(); model.setData(data); model.setTimestamp(timestamp); model.setSign(sign); String inRawSign = String.format("data=%s×tamp=%d", model.getData(), model.getTimestamp()); String inSign = EncryptUtils.SINGLETON.sha(inRawSign); if (!inSign.equals(model.getSign())){ throw new IllegalArgumentException("驗證參數簽名失敗!"); } //這裏忽略實際的業務邏輯,簡單設置返回的data爲一個map Map<String, Object> result = new HashMap<>(8); result.put("code", "200"); result.put("message", "success"); EncryptModel out = new EncryptModel(); out.setTimestamp(System.currentTimeMillis()); out.setData(EncryptUtils.SINGLETON.encryptByAes(objectMapper.writeValueAsString(result))); String rawSign = String.format("data=%s×tamp=%d", out.getData(), out.getTimestamp()); out.setSign(EncryptUtils.SINGLETON.sha(rawSign)); return ResponseEntity.ok(out); } @PostMapping(value = "/order/query", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) public ResponseEntity<Order> queryOrder(@RequestBody User user){ Order order = new Order(); //這裏忽略實際的業務邏輯 return ResponseEntity.ok(order); } }
這種作法能在短期完成對應的加解密功能,不須要加解密的接口不用引入相關的代碼便可。缺陷十分明顯,存在硬編碼、代碼冗餘等問題,一旦接口增多,項目的維護難度大大提升。所以,這種作法是不可取的。
這裏先說一點,這裏是在SpringMVC中使用Filter。由於要兼容兩種contentType,咱們須要作到幾點:
使用Filter有一個優勢:不須要理解SpringMVC的流程,也不須要擴展SpringMVC的相關組件。缺點也比較明顯:
PS:上面提到的幾個須要修改請求參數、請求頭等是由於特殊場景的定製,因此若是無此場景能夠直接看下面的"單純的Json請求參數和Json響應結果"小節。流程大體以下:
編寫Filter的實現和HttpServletRequestWrapper的實現:
//CustomEncryptFilter @RequiredArgsConstructor public class CustomEncryptFilter extends OncePerRequestFilter { private final ObjectMapper objectMapper; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //Content-Type String contentType = request.getContentType(); String requestBody = null; boolean shouldEncrypt = false; if (StringUtils.substringMatch(contentType, 0, MediaType.APPLICATION_FORM_URLENCODED_VALUE)) { shouldEncrypt = true; requestBody = convertFormToString(request); } else if (StringUtils.substringMatch(contentType, 0, MediaType.APPLICATION_JSON_VALUE)) { shouldEncrypt = true; requestBody = convertInputStreamToString(request.getInputStream()); } if (!shouldEncrypt) { filterChain.doFilter(request, response); } else { CustomEncryptHttpWrapper wrapper = new CustomEncryptHttpWrapper(request, requestBody); wrapper.putHeader("Content-Type", MediaType.APPLICATION_PROBLEM_JSON_UTF8_VALUE); filterChain.doFilter(wrapper, response); } } private String convertFormToString(HttpServletRequest request) { Map<String, String> result = new HashMap<>(8); Enumeration<String> parameterNames = request.getParameterNames(); while (parameterNames.hasMoreElements()) { String name = parameterNames.nextElement(); result.put(name, request.getParameter(name)); } try { return objectMapper.writeValueAsString(result); } catch (JsonProcessingException e) { throw new IllegalArgumentException(e); } } private String convertInputStreamToString(InputStream inputStream) throws IOException { return StreamUtils.copyToString(inputStream, Charset.forName("UTF-8")); } } //CustomEncryptHttpWrapper public class CustomEncryptHttpWrapper extends HttpServletRequestWrapper { private final Map<String, String> headers = new HashMap<>(8); private final byte[] data; public CustomEncryptHttpWrapper(HttpServletRequest request, String content) { super(request); data = content.getBytes(Charset.forName("UTF-8")); Enumeration<String> headerNames = request.getHeaderNames(); while (headerNames.hasMoreElements()) { String key = headerNames.nextElement(); headers.put(key, request.getHeader(key)); } } public void putHeader(String key, String value) { headers.put(key, value); } @Override public String getHeader(String name) { return headers.get(name); } @Override public Enumeration<String> getHeaders(String name) { return Collections.enumeration(Collections.singletonList(headers.get(name))); } @Override public Enumeration<String> getHeaderNames() { return Collections.enumeration(headers.keySet()); } @Override public ServletInputStream getInputStream() throws IOException { ByteArrayInputStream inputStream = new ByteArrayInputStream(data); return new ServletInputStream() { @Override public boolean isFinished() { return !isReady(); } @Override public boolean isReady() { return inputStream.available() > 0; } @Override public void setReadListener(ReadListener listener) { } @Override public int read() throws IOException { return inputStream.read(); } }; } @Override public BufferedReader getReader() throws IOException { return super.getReader(); } } //CustomEncryptConfiguration @Configuration public class CustomEncryptConfiguration { @Bean public FilterRegistrationBean<CustomEncryptFilter> customEncryptFilter(ObjectMapper objectMapper){ FilterRegistrationBean<CustomEncryptFilter> bean = new FilterRegistrationBean<>(new CustomEncryptFilter(objectMapper)); bean.addUrlPatterns("/e/*"); return bean; } }
控制器代碼:
//可加密的,空接口 public interface Encryptable { } @Data public class Order implements Encryptable{ private Long userId; } @Data public class EncryptResponse<T> implements Encryptable { private Integer code; private T data; } @RequiredArgsConstructor @RestController public class Controller { private final ObjectMapper objectMapper; @PostMapping(value = "/e/order/save", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) public EncryptResponse<Order> saveOrder(@RequestBody Order order) throws Exception { //這裏忽略實際的業務邏輯,簡單設置返回的data爲一個map EncryptResponse<Order> response = new EncryptResponse<>(); response.setCode(200); response.setData(order); return response; } @PostMapping(value = "/c/order/query", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) public ResponseEntity<Order> queryOrder(@RequestBody User user) { Order order = new Order(); //這裏忽略實際的業務邏輯 return ResponseEntity.ok(order); } }
這裏可能有人有疑問,爲何不在Filter作加解密的操做?由於考慮到場景太特殊,要兼容兩種形式的表單提交參數,若是在Filter作加解密操做,會影響到Controller的編碼,這就違反了全局加解密不影響到裏層業務代碼的目標。上面的Filter只會攔截URL知足/e/*的請求,所以查詢接口/c/order/query不會受到影響。這裏使用了標識接口用於決定請求參數或者響應結果是否須要加解密,也就是隻須要在HttpMessageConverter中判斷請求參數的類型或者響應結果的類型是否加解密標識接口的子類:
@RequiredArgsConstructor public class CustomEncryptHttpMessageConverter extends MappingJackson2HttpMessageConverter { private final ObjectMapper objectMapper; @Override protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { if (Encryptable.class.isAssignableFrom(clazz)) { EncryptModel in = objectMapper.readValue(StreamUtils.copyToByteArray(inputMessage.getBody()), EncryptModel.class); String inRawSign = String.format("data=%s×tamp=%d", in.getData(), in.getTimestamp()); String inSign; try { inSign = EncryptUtils.SINGLETON.sha(inRawSign); } catch (Exception e) { throw new IllegalArgumentException("驗證參數簽名失敗!"); } if (!inSign.equals(in.getSign())) { throw new IllegalArgumentException("驗證參數簽名失敗!"); } try { return objectMapper.readValue(EncryptUtils.SINGLETON.decryptByAes(in.getData()), clazz); } catch (Exception e) { throw new IllegalArgumentException("解密失敗!"); } } else { return super.readInternal(clazz, inputMessage); } } @Override protected void writeInternal(Object object, Type type, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { Class<?> clazz = (Class) type; if (Encryptable.class.isAssignableFrom(clazz)) { EncryptModel out = new EncryptModel(); out.setTimestamp(System.currentTimeMillis()); try { out.setData(EncryptUtils.SINGLETON.encryptByAes(objectMapper.writeValueAsString(object))); String rawSign = String.format("data=%s×tamp=%d", out.getData(), out.getTimestamp()); out.setSign(EncryptUtils.SINGLETON.sha(rawSign)); } catch (Exception e) { throw new IllegalArgumentException("參數簽名失敗!"); } super.writeInternal(out, type, outputMessage); } else { super.writeInternal(object, type, outputMessage); } } }
自實現的HttpMessageConverter主要須要判斷請求參數的類型和返回值的類型,從而判斷是否須要進行加解密。
通常狀況下,對接方的請求參數和響應結果是徹底規範統一使用Json(contentType指定爲application/json,使用@RequestBody接收參數),那麼全部的事情就會變得簡單,由於不須要考慮請求參數由xxx=yyy&aaa=bbb轉換爲InputStream再交給SpringMVC處理,所以咱們只須要提供一個MappingJackson2HttpMessageConverter子類實現(繼承它而且覆蓋對應方法,添加加解密特性)。咱們仍是使用標識接口用於決定請求參數或者響應結果是否須要加解密:
@RequiredArgsConstructor public class CustomEncryptHttpMessageConverter extends MappingJackson2HttpMessageConverter { private final ObjectMapper objectMapper; @Override protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { if (Encryptable.class.isAssignableFrom(clazz)) { EncryptModel in = objectMapper.readValue(StreamUtils.copyToByteArray(inputMessage.getBody()), EncryptModel.class); String inRawSign = String.format("data=%s×tamp=%d", in.getData(), in.getTimestamp()); String inSign; try { inSign = EncryptUtils.SINGLETON.sha(inRawSign); } catch (Exception e) { throw new IllegalArgumentException("驗證參數簽名失敗!"); } if (!inSign.equals(in.getSign())) { throw new IllegalArgumentException("驗證參數簽名失敗!"); } try { return objectMapper.readValue(EncryptUtils.SINGLETON.decryptByAes(in.getData()), clazz); } catch (Exception e) { throw new IllegalArgumentException("解密失敗!"); } } else { return super.readInternal(clazz, inputMessage); } } @Override protected void writeInternal(Object object, Type type, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { Class<?> clazz = (Class) type; if (Encryptable.class.isAssignableFrom(clazz)) { EncryptModel out = new EncryptModel(); out.setTimestamp(System.currentTimeMillis()); try { out.setData(EncryptUtils.SINGLETON.encryptByAes(objectMapper.writeValueAsString(object))); String rawSign = String.format("data=%s×tamp=%d", out.getData(), out.getTimestamp()); out.setSign(EncryptUtils.SINGLETON.sha(rawSign)); } catch (Exception e) { throw new IllegalArgumentException("參數簽名失敗!"); } super.writeInternal(out, type, outputMessage); } else { super.writeInternal(object, type, outputMessage); } } }
沒錯,代碼是拷貝上一節提供的HttpMessageConverter實現,而後控制器方法的參數使用@RequestBody註解而且類型實現加解密標識接口Encryptable便可,返回值的類型也須要實現加解密標識接口Encryptable。這種作法可讓控制器的代碼對加解密徹底無感知。固然,也能夠不改變原來的MappingJackson2HttpMessageConverter實現,使用RequestBodyAdvice和ResponseBodyAdvice完成相同的功能:
@RequiredArgsConstructor public class CustomRequestBodyAdvice extends RequestBodyAdviceAdapter { private final ObjectMapper objectMapper; @Override public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { Class<?> clazz = (Class) targetType; return Encryptable.class.isAssignableFrom(clazz); } @Override public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException { Class<?> clazz = (Class) targetType; if (Encryptable.class.isAssignableFrom(clazz)) { String content = StreamUtils.copyToString(inputMessage.getBody(), Charset.forName("UTF-8")); EncryptModel in = objectMapper.readValue(content, EncryptModel.class); String inRawSign = String.format("data=%s×tamp=%d", in.getData(), in.getTimestamp()); String inSign; try { inSign = EncryptUtils.SINGLETON.sha(inRawSign); } catch (Exception e) { throw new IllegalArgumentException("驗證參數簽名失敗!"); } if (!inSign.equals(in.getSign())) { throw new IllegalArgumentException("驗證參數簽名失敗!"); } ByteArrayInputStream inputStream = new ByteArrayInputStream(in.getData().getBytes(Charset.forName("UTF-8"))); return new MappingJacksonInputMessage(inputStream, inputMessage.getHeaders()); } else { return super.beforeBodyRead(inputMessage, parameter, targetType, converterType); } } } @RequiredArgsConstructor public class CustomResponseBodyAdvice extends JsonViewResponseBodyAdvice { private final ObjectMapper objectMapper; @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { Class<?> parameterType = returnType.getParameterType(); return Encryptable.class.isAssignableFrom(parameterType); } @Override protected void beforeBodyWriteInternal(MappingJacksonValue bodyContainer, MediaType contentType, MethodParameter returnType, ServerHttpRequest request, ServerHttpResponse response) { Class<?> parameterType = returnType.getParameterType(); if (Encryptable.class.isAssignableFrom(parameterType)) { EncryptModel out = new EncryptModel(); out.setTimestamp(System.currentTimeMillis()); try { out.setData(EncryptUtils.SINGLETON.encryptByAes(objectMapper.writeValueAsString(bodyContainer.getValue()))); String rawSign = String.format("data=%s×tamp=%d", out.getData(), out.getTimestamp()); out.setSign(EncryptUtils.SINGLETON.sha(rawSign)); out.setSign(EncryptUtils.SINGLETON.sha(rawSign)); } catch (Exception e) { throw new IllegalArgumentException("參數簽名失敗!"); } } else { super.beforeBodyWriteInternal(bodyContainer, contentType, returnType, request, response); } } }
通常狀況下,對接方的請求參數徹底採用application/x-www-form-urlencoded表單請求參數返回結果所有按照Json接收,咱們也能夠經過一個HttpMessageConverter實現就完成加解密模塊。
public class FormHttpMessageConverter implements HttpMessageConverter<Object> { private final List<MediaType> mediaTypes; private final ObjectMapper objectMapper; public FormHttpMessageConverter(ObjectMapper objectMapper) { this.objectMapper = objectMapper; this.mediaTypes = new ArrayList<>(1); this.mediaTypes.add(MediaType.APPLICATION_FORM_URLENCODED); } @Override public boolean canRead(Class<?> clazz, MediaType mediaType) { return Encryptable.class.isAssignableFrom(clazz) && mediaTypes.contains(mediaType); } @Override public boolean canWrite(Class<?> clazz, MediaType mediaType) { return Encryptable.class.isAssignableFrom(clazz) && mediaTypes.contains(mediaType); } @Override public List<MediaType> getSupportedMediaTypes() { return mediaTypes; } @Override public Object read(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { if (Encryptable.class.isAssignableFrom(clazz)) { String content = StreamUtils.copyToString(inputMessage.getBody(), Charset.forName("UTF-8")); EncryptModel in = objectMapper.readValue(content, EncryptModel.class); String inRawSign = String.format("data=%s×tamp=%d", in.getData(), in.getTimestamp()); String inSign; try { inSign = EncryptUtils.SINGLETON.sha(inRawSign); } catch (Exception e) { throw new IllegalArgumentException("驗證參數簽名失敗!"); } if (!inSign.equals(in.getSign())) { throw new IllegalArgumentException("驗證參數簽名失敗!"); } try { return objectMapper.readValue(EncryptUtils.SINGLETON.decryptByAes(in.getData()), clazz); } catch (Exception e) { throw new IllegalArgumentException("解密失敗!"); } } else { MediaType contentType = inputMessage.getHeaders().getContentType(); Charset charset = (contentType != null && contentType.getCharset() != null ? contentType.getCharset() : Charset.forName("UTF-8")); String body = StreamUtils.copyToString(inputMessage.getBody(), charset); String[] pairs = StringUtils.tokenizeToStringArray(body, "&"); MultiValueMap<String, String> result = new LinkedMultiValueMap<>(pairs.length); for (String pair : pairs) { int idx = pair.indexOf('='); if (idx == -1) { result.add(URLDecoder.decode(pair, charset.name()), null); } else { String name = URLDecoder.decode(pair.substring(0, idx), charset.name()); String value = URLDecoder.decode(pair.substring(idx + 1), charset.name()); result.add(name, value); } } return result; } } @Override public void write(Object o, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { Class<?> clazz = o.getClass(); if (Encryptable.class.isAssignableFrom(clazz)) { EncryptModel out = new EncryptModel(); out.setTimestamp(System.currentTimeMillis()); try { out.setData(EncryptUtils.SINGLETON.encryptByAes(objectMapper.writeValueAsString(o))); String rawSign = String.format("data=%s×tamp=%d", out.getData(), out.getTimestamp()); out.setSign(EncryptUtils.SINGLETON.sha(rawSign)); StreamUtils.copy(objectMapper.writeValueAsString(out) .getBytes(Charset.forName("UTF-8")), outputMessage.getBody()); } catch (Exception e) { throw new IllegalArgumentException("參數簽名失敗!"); } } else { String out = objectMapper.writeValueAsString(o); StreamUtils.copy(out.getBytes(Charset.forName("UTF-8")), outputMessage.getBody()); } } }
上面的HttpMessageConverter的實現能夠參考org.springframework.http.converter.FormHttpMessageConverter。
這篇文章強行復雜化了實際的狀況(可是在實際中真的碰到過),通常狀況下,如今流行使用Json進行數據傳輸,在SpringMVC項目中,咱們只須要針對性地改造MappingJackson2HttpMessageConverter便可(繼承而且添加特性),若是對SpringMVC的源碼相對熟悉的話,直接添加自定義的RequestBodyAdvice(RequestBodyAdviceAdapter)和ResponseBodyAdvice(JsonViewResponseBodyAdvice)實現也能夠達到目的。至於爲何使用HttpMessageConverter作加解密功能,這裏基於SpringMVC源碼的對請求參數處理的過程整理了一張處理流程圖:
上面流程最核心的代碼能夠看AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters
和HandlerMethodArgumentResolverComposite#resolveArgument
,畢竟源碼不會騙人。控制器方法返回值的處理是基本對稱的,閱讀起來也比較輕鬆。
參考資料:
(本文完 c-d-4)