SpringMvc/SpringBoot HTTP通信加解密的实现
前言
从去年10月份到现在忙的没时间写博客了,今天就甩给大家一个干货吧!!!
近来很多人问到下面的问题
- 我们不想在每个Controller方法收到字符串报文后再调用一次解密,虽然可以完成,但是很low,且如果想不再使用加解密,修改起来很是麻烦。
- 我们想在使用Rest工具或swagger请求的时候不进行加解密,而在app调用的时候处理加解密,这可如何操作。
针对以上的问题,下面直接给出解决方案:
实现思路
- APP调用API的时候,如果需要加解密的接口,需要在httpHeader中给出加密方式,如header[encodeMethod]。
- Rest工具或swagger请求的时候无需指定此header。
- 后端API收到request后,判断header中的encodeMethod字段,如果有值,则认为是需要解密,否则就认为是明文。
约定
为了精简分享技术,先约定只处理POST上传JSON(application/json)数据的加解密处理。
请求解密实现方式
1. 先定义controller
@Controller @RequestMapping("/api/demo") public class MyDemoController { @RequestDecode @ResponseBody @RequestMapping(value = "user", method = RequestMethod.POST) public ResponseDto addUser( @RequestBody User user ) throws Exception { //TODO ... } }
/** * 解密请求数据 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RequestDecode { SecurityMethod method() default SecurityMethod.NULL; }
可以看到这里的Controller定义的很普通,只有一个额外的自定义注解RequestDecode,这个注解是为了下面的RequestBodyAdvice的使用。
2. 建设自己的RequestBodyAdvice
有了上面的入口定义,接下来处理解密这件事,目的很明确:
1. 是否需要解密判断httpHeader中的encodeMethod字段。
2. 在进入controller之前就解密完成,是controller处理逻辑无感知。
DecodeRequestBodyAdvice.java
@Slf4j @Component @ControllerAdvice(basePackages = "com.xxx.hr.api.controller") public class DecodeRequestBodyAdvice implements RequestBodyAdvice { @Value("${hrapi.aesKey}") String aesKey; @Value("${hrapi.googleKey}") String googleKey; @Override public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { return methodParameter.getMethodAnnotation(RequestDecode.class) != null && methodParameter.getParameterAnnotation(RequestBody.class) != null; } @Override public Object handleEmptyBody(Object body, HttpInputMessage request, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { return body; } @Override public HttpInputMessage beforeBodyRead(HttpInputMessage request, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException { RequestDecode requestDecode = parameter.getMethodAnnotation(RequestDecode.class); if (requestDecode == null) { return request;//controller方法不要求加解密 } String appId = request.getHeaders().getFirst(com.xxx.hr.bean.constant.HttpHeaders.APP_ID);//这里是扩展,可以知道来源方(如开放平台使用) String encodeMethod = request.getHeaders().getFirst(com.xxx.hr.bean.constant.HttpHeaders.ENCODE_METHOD); if (StringUtils.isEmpty(encodeMethod)) { return request; } SecurityMethod encodeMethodEnum = SecurityMethod.getByCode(encodeMethod); //这里灵活的可以支持到多种加解密方式 switch (encodeMethodEnum) { case NULL: break; case AES: { InputStream is = request.getBody(); ByteBuf buf = PooledByteBufAllocator.DEFAULT.heapBuffer(); int ret = -1; int len = 0; while((ret = is.read()) > 0) { buf.writeByte(ret); len ++; } String body = buf.toString(0, len, xxxSecurity.DEFAULT_CHARSET); buf.release(); String temp = null; try { temp = XxxSecurity.aesDecodeData(body, aesKey, googleKey, new CheckCallBack() { @Override public boolean isRight(String data) { return data != null && (data.startsWith("{") || data.startsWith("[")); } }); log.info("解密完成: {}", temp); return new DecodedHttpInputMessage(request.getHeaders(), new ByteArrayInputStream(temp.getBytes("UTF-8"))); } catch (DecodeException e) { log.warn("解密失败 appId: {}, Name:{} 待解密密文: {}", appId, partnerName, body, e); throw e; } } } return request; } @Override public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { return body; } static class DecodedHttpInputMessage implements HttpInputMessage { HttpHeaders headers; InputStream body; public DecodedHttpInputMessage(HttpHeaders headers, InputStream body) { this.headers = headers; this.body = body; } @Override public InputStream getBody() throws IOException { return body; } @Override public HttpHeaders getHeaders() { return headers; } } }
至此加解密完成了。
————————-华丽分割线 —————————–
响应加密
下面附件一下响应加密过程,目的
1. Controller逻辑代码无感知
2. 可以一键开关响应加密
定义Controller
@ResponseEncode @ResponseBody @RequestMapping(value = "employee", method = RequestMethod.GET) public ResponseDto<UserEEInfo> userEEInfo( @ApiParam("用户编号") @RequestParam(HttpHeaders.APPID) Long userId ) { //TODO ... }
/** * 加密响应数据 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ResponseEncode { SecurityMethod method() default SecurityMethod.NULL; }
这里的Controller定义的也很普通,只有一个额外的自定义注解ResponseEncode,这个注解是为了下面的ResponseBodyAdvice的使用。
建设自己的ResponseBodyAdvice
这里约定将响应的DTO序列化为JSON格式数据,然后再加密,最后在响应给请求方。
@Slf4j @Component @ControllerAdvice(basePackages = "com.xxx.hr.api.controller") public class EncodeResponseBodyAdvice implements ResponseBodyAdvice { @Autowired PartnerService partnerService; @Override public boolean supports(MethodParameter returnType, Class converterType) { return returnType.getMethodAnnotation(ResponseEncode.class) != null; } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { ResponseEncode responseEncode = returnType.getMethodAnnotation(ResponseEncode.class); String uid = request.getHeaders().getFirst(HttpHeaders.PARTNER_UID); if (uid == null) { uid = request.getHeaders().getFirst(HttpHeaders.APP_ID); } PartnerConfig config = partnerService.getConfigByAppId(uid); if (responseEncode.method() == SecurityMethod.NULL || responseEncode.method() == SecurityMethod.AES) { if (config == null) { return ResponseDto.rsFail(ResponseCode.E_403, "商户不存在"); } String temp = JSON.toJSONString(body); log.debug("待加密数据: {}", temp); String encodedBody = XxxSecurity.aesEncodeData(temp, config.getEncryptionKey(), config.getGoogleKey()); log.debug("加密完成: {}", encodedBody); response.getHeaders().set(HttpHeaders.ENCODE_METHOD, HttpHeaders.VALUE.AES); response.getHeaders().set(HttpHeaders.HEADER_CONTENT_TYPE, HttpHeaders.VALUE.APPLICATION_BASE64_JSON_UTF8); response.getHeaders().remove(HttpHeaders.SIGN_METHOD); return encodedBody; } return body; } }
拓展
由上面的实现,如何实现RSA验证签名呢?这个就简单了,请看分解。
目的还是很简单,进来减少对业务逻辑的入侵。
首先设定一下那些请求需要验证签名
@RequestSign @ResponseEncode @ResponseBody @RequestMapping(value = "employee", method = RequestMethod.GET) public ResponseDto<UserEEInfo> userEEInfo( @RequestParam(HttpHeaders.UID) String uid ) { //TODO ... }
这里还是使用一个注解RequestSign,然后再实现一个SignInterceptor即可完成:
@Slf4j @Component public class SignInterceptor implements HandlerInterceptor { @Autowired PartnerService partnerService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HandlerMethod method = (HandlerMethod) handler; RequestSign requestSign = method.getMethodAnnotation(RequestSign.class); if (requestSign == null) { return true; } String appId = request.getHeader(HttpHeaders.APP_ID); ValidateUtils.notTrimEmptyParam(appId, "Header[appId]"); PartnerConfig config = partnerService.getConfigByAppId(appId); ValidateUtils.notNull(config, Code.E_400, "商戶不存在"); String partnerName = partnerService.getPartnerName(appId); String sign = request.getParameter(HttpHeaders.SIGN); String signMethod = request.getParameter(HttpHeaders.SIGN_METHOD); signMethod = (signMethod == null) ? "RSA" : signMethod; Map<String, String[]> parameters = request.getParameterMap(); ValidateUtils.notTrimEmptyParam(sign, "sign"); if ("RSA".equals(signMethod)) { sign = sign.replaceAll(" ", "+"); boolean isOK = xxxxSecurity.signVerifyRequest(parameters, config.getRsaPublicKey(), sign, config.getSecurity()); if (isOK) { log.info("验证商户签名通过 {}[{}] ", appId, partnerName); return true; } else { log.warn("验证商户签名失败 {}[{}] ", appId, partnerName); } } else { throw new SignVerifyException("暂不支持该签名"); } throw new SignVerifyException("签名校验失败"); } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } }
各个枚举定义:
//加解密、签名算法枚举 public enum SecurityMethod { NULL, AES, RSA, DES, DES3, SHA1, MD5 ; }
注解定义:
/** * 请求数据数据需要解密 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RequestDecode { SecurityMethod method() default SecurityMethod.NULL; } /** * 请求数据需要验签 */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited public @interface RequestSign { SecurityMethod method() default SecurityMethod.RSA; } /** * 数据响应需要加密 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ResponseEncode { SecurityMethod method() default SecurityMethod.NULL; } /** * 响应数据需要生成签名 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited public @interface ResponseSign { SecurityMethod method() default SecurityMethod.NULL; }
aesDecodeData
/** * AES 解密数据 * * @param data 待解密数据 * @param aesKey AES 密钥(BASE64) * @param googleAuthKey GoogleAuthKey(BASE64) * @param originDataSign 原始数据md5签名 * @return */ public static String aesDecodeDataEx(String data, String aesKey, String googleAuthKey, String originDataSign) { return aesDecodeData(data, aesKey, googleAuthKey, System.currentTimeMillis(), null, originDataSign); } public static String aesDecodeData(String data, String aesKey, String googleAuthKey, long tm, CheckCallBack checkCallBack, String originDataSign) { DecodeException lastError = null; long timeWindow = googleAuth.getTimeWindowFromTime(tm); int window = googleAuth.getConfig().getWindowSize(); for (int i = -((window - 1) / 2); i <= window / 2; ++i) { String googleCode = googleAuth.calculateCode16(Base64.decodeBase64(googleAuthKey), timeWindow + i); log.debug((timeWindow + i) + " googleCode: " + googleCode); byte[] code = googleCode.getBytes(DEFAULT_CHARSET); byte[] iv = new byte[16]; System.arraycopy(code, 0, iv, 0, code.length); try { String newKey = convertKey(aesKey, iv); String decodedData = AES.decode(data, newKey, Base64.encodeBase64String(iv)); if (checkCallBack != null && !checkCallBack.isRight(decodedData)) { continue; } if (originDataSign != null) { String sign = DigestUtils.md5Hex(decodedData); if (!sign.equalsIgnoreCase(originDataSign)) { continue; } } return decodedData; } catch (DecodeException e) { lastError = e; } } if (lastError == null) { lastError = new DecodeException("Decode Failed, Error Password!"); } throw lastError; }
signVerifyRequest
static boolean signVerifyRequest(Map<String, String[]> parameters, String rsaPublicKey, String sign, String security) throws SignVerifyException { String preSignData = getHttpPreSignData(parameters, security); log.debug("待验签字符串:" + preSignData); return RSA.verify(preSignData.getBytes(DEFAULT_CHARSET), rsaPublicKey, sign); }
GoogleAuth
public class GoogleAuth { private GoogleAuthenticatorConfig config; private GoogleAuthenticator googleAuthenticator; public GoogleAuth() { GoogleAuthenticatorConfig.GoogleAuthenticatorConfigBuilder gacb = new GoogleAuthenticatorConfig.GoogleAuthenticatorConfigBuilder() .setTimeStepSizeInMillis(TimeUnit.MINUTES.toMillis(2)) .setWindowSize(3) .setCodeDigits(8) .setKeyRepresentation(KeyRepresentation.BASE64); config = gacb.build(); googleAuthenticator = new GoogleAuthenticator(config); } public GoogleAuthenticatorConfig getConfig(){ return config; } public void setConfig(GoogleAuthenticatorConfig c) { config = c; googleAuthenticator = new GoogleAuthenticator(config); } /** * 认证 * @param encodedKey(Base 32/64) * @param code * @return 是否通过 */ public boolean authorize(String encodedKey, int code) { return googleAuthenticator.authorize(encodedKey, code); } /** * 生成 GoogleAuth Code * @param keyBase64 * @return */ public int getCodeValidCode(String keyBase64) { int code = googleAuthenticator.getTotpPassword(keyBase64); return code; } public long getTimeWindowFromTime(long time) { return time / this.config.getTimeStepSizeInMillis(); } private static String formatLabel(String issuer, String accountName) { if (accountName == null || accountName.trim().length() == 0) { throw new IllegalArgumentException("Account name must not be empty."); } StringBuilder sb = new StringBuilder(); if (issuer != null) { if (issuer.contains(":")) { throw new IllegalArgumentException("Issuer cannot contain the \':\' character."); } sb.append(issuer); sb.append(":"); } sb.append(accountName); return sb.toString(); } public String getOtpAuthTotpURL(String keyBase64) throws EncoderException{ return getOtpAuthTotpURL("MLJR", "myname@mljr.com", keyBase64); } /** * 生成GoogleAuth认证的URL,便于生成二维码 * @param issuer * @param accountName * @param keyBase32 * @return */ public String getOtpAuthTotpURL(String issuer, String accountName, String keyBase32) throws EncoderException { StringBuilder url = new StringBuilder(); url.append("otpauth://") .append("totp") .append("/").append(formatLabel(issuer, accountName)); Map<String, String> parameter = new HashMap<String, String>(); /** * https://github.com/google/google-authenticator/wiki/Key-Uri-Format * The secret parameter is an arbitrary key value encoded in Base32 according to RFC 3548. */ parameter.put("secret", keyBase32); if (issuer != null) { if (issuer.contains(":")) { throw new IllegalArgumentException("Issuer cannot contain the \':\' character."); } parameter.put("issuer", issuer); } parameter.put("algorithm", "SHA1"); parameter.put("digits", String.valueOf(config.getCodeDigits())); parameter.put("period", String.valueOf(TimeUnit.MILLISECONDS.toSeconds(config.getTimeStepSizeInMillis()))); URLCodec urlCodec = new URLCodec(); if (!parameter.isEmpty()) { url.append("?"); for(String key : parameter.keySet()) { String value = parameter.get(key); if (value == null){ continue; } value = urlCodec.encode(value); url.append(key).append("=").append(value).append("&"); } } return url.toString(); } private static final String DEFAULT_RANDOM_NUMBER_ALGORITHM = "SHA1PRNG"; private static final String DEFAULT_RANDOM_NUMBER_ALGORITHM_PROVIDER = "SUN"; private static final String HMAC_HASH_FUNCTION = "HmacSHA1"; private static final String HMAC_MD5_FUNCTION = "HmacMD5"; /** * 基于时间 生成16位的 code * @param key * @param tm * @return */ public String calculateCode16(byte[] key, long tm) { // Allocating an array of bytes to represent the specified instant // of time. byte[] data = new byte[8]; long value = tm; // Converting the instant of time from the long representation to a // big-endian array of bytes (RFC4226, 5.2. Description). for (int i = 8; i-- > 0; value >>>= 8) { data[i] = (byte) value; } // Building the secret key specification for the HmacSHA1 algorithm. SecretKeySpec signKey = new SecretKeySpec(key, HMAC_HASH_FUNCTION); try { // Getting an HmacSHA1 algorithm implementation from the JCE. Mac mac = Mac.getInstance(HMAC_HASH_FUNCTION); // Initializing the MAC algorithm. mac.init(signKey); // Processing the instant of time and getting the encrypted data. byte[] hash = mac.doFinal(data); // Building the validation code performing dynamic truncation // (RFC4226, 5.3. Generating an HOTP value) int offset = hash[hash.length - 1] & 0xB; // We are using a long because Java hasn't got an unsigned integer type // and we need 32 unsigned bits). long truncatedHash = 0; for (int i = 0; i < 8; ++i) { truncatedHash <<= 8; // Java bytes are signed but we need an unsigned integer: // cleaning off all but the LSB. truncatedHash |= (hash[offset + i] & 0xFF); } truncatedHash &= Long.MAX_VALUE; truncatedHash %= 10000000000000000L; // module with the maximum validation code value. // Returning the validation code to the caller. return String.format("%016d", truncatedHash); } catch (InvalidKeyException e) { throw new GoogleAuthenticatorException("The operation cannot be " + "performed now."); } catch (NoSuchAlgorithmException ex) { // We're not disclosing internal error details to our clients. throw new GoogleAuthenticatorException("The operation cannot be " + "performed now."); } } }
GoogleAuth其他代码 看这里
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。