当前位置:   article > 正文

最详细的SpringBoot实现接口校验签名调用_springboot接口加签验签

springboot接口加签验签

代码地址:GitHub - passerbyYSQ/DemoRepository: 各种开发小demo

目录

概念

开放接口

验签

接口验签调用流程

1. 约定签名算法

2. 颁发非对称密钥对

3. 生成请求参数签名

4. 请求携带签名调用

代码设计

1. 签名配置类

2. 签名管理类

3. 自定义验签注解

4. AOP实现验签逻辑

5. 解决请求体只能读取一次

6. 自定义工具类

概念

开放接口

        开放接口是指不需要登录凭证就允许被第三方系统调用的接口。为了防止开放接口被恶意调用,开放接口一般都需要验签才能被调用。提供开放接口的系统下面统一简称为"原系统"。

验签

      验签是指第三方系统在调用接口之前,需要按照原系统的规则根据所有请求参数生成一个签名(字符串),在调用接口时携带该签名。原系统会验证签名的有效性,只有签名验证有效才能正常调用接口,否则请求会被驳回。

接口验签调用流程

1. 约定签名算法

        第三方系统作为调用方,需要与原系统协商约定签名算法(下面以SHA256withRSA签名算法为例)。同时约定一个名称(callerID),以便在原系统中来唯一标识调用方系统。

2. 颁发非对称密钥对

        签名算法约定后之后,原系统会为每一个调用方系统专门生成一个专属的非对称密钥对(RSA密钥对)。私钥颁发给调用方系统,公钥由原系统持有。注意,调用方系统需要保管好私钥(存到调用方系统的后端)。因为对于原系统而言,调用方系统是消息的发送方,其持有的私钥唯一标识了它的身份是原系统受信任的调用方。调用方系统的私钥一旦泄露,调用方对原系统毫无信任可言。

3. 生成请求参数签名

        签名算法约定后之后,生成签名的原理如下(活动图)。为了确保生成签名的处理细节与原系统的验签逻辑是匹配的,原系统一般都提供jar包或者代码片段给调用方来生成签名,否则可能会因为一些处理细节不一致导致生成的签名是无效的。

4. 请求携带签名调用

路径参数中放入约定好的callerID,请求头中放入调用方自己生成的签名

代码设计

1. 签名配置类

相关的自定义yml配置如下。RSA的公钥和私钥可以使用hutool的SecureUtil工具类来生成,注意公钥和私钥是base64编码后的字符串

定义一个配置类来存储上述相关的自定义yml配置

  1. import cn.hutool.crypto.asymmetric.SignAlgorithm;
  2. import lombok.Data;
  3. import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
  4. import org.springframework.boot.context.properties.ConfigurationProperties;
  5. import org.springframework.stereotype.Component;
  6. import java.util.Map;
  7. /**
  8. * 签名的相关配置
  9. */
  10. @Data
  11. @ConditionalOnProperty(value = "secure.signature.enable", havingValue = "true") // 根据条件注入bean
  12. @Component
  13. @ConfigurationProperties("secure.signature")
  14. public class SignatureProps {
  15. private Boolean enable;
  16. private Map<String, KeyPairProps> keyPair;
  17. @Data
  18. public static class KeyPairProps {
  19. private SignAlgorithm algorithm;
  20. private String publicKeyPath;
  21. private String publicKey;
  22. private String privateKeyPath;
  23. private String privateKey;
  24. }
  25. }

2. 签名管理类

定义一个管理类,持有上述配置,并暴露生成签名和校验签名的方法。

注意,生成的签名是将字节数组进行十六进制编码后的字符串,验签时需要将签名字符串进行十六进制解码成字节数组

  1. import cn.hutool.core.io.IoUtil;
  2. import cn.hutool.core.io.resource.ResourceUtil;
  3. import cn.hutool.core.util.HexUtil;
  4. import cn.hutool.crypto.SecureUtil;
  5. import cn.hutool.crypto.asymmetric.Sign;
  6. import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
  7. import org.springframework.stereotype.Component;
  8. import org.springframework.util.ObjectUtils;
  9. import top.ysqorz.signature.model.SignatureProps;
  10. import java.nio.charset.StandardCharsets;
  11. @ConditionalOnBean(SignatureProps.class)
  12. @Component
  13. public class SignatureManager {
  14. private final SignatureProps signatureProps;
  15. public SignatureManager(SignatureProps signatureProps) {
  16. this.signatureProps = signatureProps;
  17. loadKeyPairByPath();
  18. }
  19. /**
  20. * 验签。验证不通过可能抛出运行时异常CryptoException
  21. *
  22. * @param callerID 调用方的唯一标识
  23. * @param rawData 原数据
  24. * @param signature 待验证的签名(十六进制字符串)
  25. * @return 验证是否通过
  26. */
  27. public boolean verifySignature(String callerID, String rawData, String signature) {
  28. Sign sign = getSignByCallerID(callerID);
  29. if (ObjectUtils.isEmpty(sign)) {
  30. return false;
  31. }
  32. // 使用公钥验签
  33. return sign.verify(rawData.getBytes(StandardCharsets.UTF_8), HexUtil.decodeHex(signature));
  34. }
  35. /**
  36. * 生成签名
  37. *
  38. * @param callerID 调用方的唯一标识
  39. * @param rawData 原数据
  40. * @return 签名(十六进制字符串)
  41. */
  42. public String sign(String callerID, String rawData) {
  43. Sign sign = getSignByCallerID(callerID);
  44. if (ObjectUtils.isEmpty(sign)) {
  45. return null;
  46. }
  47. return sign.signHex(rawData);
  48. }
  49. public SignatureProps getSignatureProps() {
  50. return signatureProps;
  51. }
  52. public SignatureProps.KeyPairProps getKeyPairPropsByCallerID(String callerID) {
  53. return signatureProps.getKeyPair().get(callerID);
  54. }
  55. private Sign getSignByCallerID(String callerID) {
  56. SignatureProps.KeyPairProps keyPairProps = signatureProps.getKeyPair().get(callerID);
  57. if (ObjectUtils.isEmpty(keyPairProps)) {
  58. return null; // 无效的、不受信任的调用方
  59. }
  60. return SecureUtil.sign(keyPairProps.getAlgorithm(), keyPairProps.getPrivateKey(), keyPairProps.getPublicKey());
  61. }
  62. /**
  63. * 加载非对称密钥对
  64. */
  65. private void loadKeyPairByPath() {
  66. // 支持类路径配置,形如:classpath:secure/public.txt
  67. // 公钥和私钥都是base64编码后的字符串
  68. signatureProps.getKeyPair()
  69. .forEach((key, keyPairProps) -> {
  70. // 如果配置了XxxKeyPath,则优先XxxKeyPath
  71. keyPairProps.setPublicKey(loadKeyByPath(keyPairProps.getPublicKeyPath()));
  72. keyPairProps.setPrivateKey(loadKeyByPath(keyPairProps.getPrivateKeyPath()));
  73. if (ObjectUtils.isEmpty(keyPairProps.getPublicKey()) ||
  74. ObjectUtils.isEmpty(keyPairProps.getPrivateKey())) {
  75. throw new RuntimeException("No public and private key files configured");
  76. }
  77. });
  78. }
  79. private String loadKeyByPath(String path) {
  80. if (ObjectUtils.isEmpty(path)) {
  81. return null;
  82. }
  83. return IoUtil.readUtf8(ResourceUtil.getStream(path));
  84. }
  85. }

3. 自定义验签注解

有些接口需要验签,但有些接口并不需要,为了灵活控制哪些接口需要验签,自定义一个验签注解

  1. import java.lang.annotation.*;
  2. /**
  3. * 该注解标注于Controller类的方法上,表明该请求的参数需要校验签名
  4. */
  5. @Documented
  6. @Retention(RetentionPolicy.RUNTIME)
  7. @Target({ElementType.METHOD, ElementType.TYPE})
  8. public @interface VerifySignature {
  9. }

4. AOP实现验签逻辑

        验签逻辑不能放在拦截器中,因为拦截器中不能直接读取body的输入流,否则会造成后续@RequestBody的参数解析器读取不到body。
        由于body输入流只能读取一次,因此需要使用ContentCachingRequestWrapper包装请求,缓存body内容(见第5点),但是该类的缓存时机是在@RequestBody的参数解析器中。
        因此,满足2个条件才能获取到ContentCachingRequestWrapper中的body缓存:

  • 接口的入参必须存在@RequestBody
  • 读取body缓存的时机必须在@RequestBody的参数解析之后,比如说:AOP、Controller层的逻辑内。注意拦截器的时机是在参数解析之前的

        综上,注意,标注了@VerifySignature注解的controlle层方法的入参必须存在@RequestBody,AOP中验签时才能获取到body的缓存!

  1. import cn.hutool.crypto.CryptoException;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.aspectj.lang.annotation.Aspect;
  4. import org.aspectj.lang.annotation.Before;
  5. import org.aspectj.lang.annotation.Pointcut;
  6. import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
  7. import org.springframework.stereotype.Component;
  8. import org.springframework.util.ObjectUtils;
  9. import org.springframework.web.context.request.RequestAttributes;
  10. import org.springframework.web.context.request.ServletWebRequest;
  11. import org.springframework.web.servlet.HandlerMapping;
  12. import org.springframework.web.util.ContentCachingRequestWrapper;
  13. import top.ysqorz.common.constant.BaseConstant;
  14. import top.ysqorz.config.SpringContextHolder;
  15. import top.ysqorz.config.aspect.PointCutDef;
  16. import top.ysqorz.exception.auth.AuthorizationException;
  17. import top.ysqorz.exception.param.ParamInvalidException;
  18. import top.ysqorz.signature.model.SignStatusCode;
  19. import top.ysqorz.signature.model.SignatureProps;
  20. import top.ysqorz.signature.util.CommonUtils;
  21. import javax.annotation.Resource;
  22. import javax.servlet.http.HttpServletRequest;
  23. import java.nio.charset.StandardCharsets;
  24. import java.util.Map;
  25. @ConditionalOnBean(SignatureProps.class)
  26. @Component
  27. @Slf4j
  28. @Aspect
  29. public class RequestSignatureAspect implements PointCutDef {
  30. @Resource
  31. private SignatureManager signatureManager;
  32. @Pointcut("@annotation(top.ysqorz.signature.enumeration.VerifySignature)")
  33. public void annotatedMethod() {
  34. }
  35. @Pointcut("@within(top.ysqorz.signature.enumeration.VerifySignature)")
  36. public void annotatedClass() {
  37. }
  38. @Before("apiMethod() && (annotatedMethod() || annotatedClass())")
  39. public void verifySignature() {
  40. HttpServletRequest request = SpringContextHolder.getRequest();
  41. String callerID = request.getParameter(BaseConstant.PARAM_CALLER_ID);
  42. if (ObjectUtils.isEmpty(callerID)) {
  43. throw new AuthorizationException(SignStatusCode.UNTRUSTED_CALLER); // 不受信任的调用方
  44. }
  45. // 从请求头中提取签名,不存在直接驳回
  46. String signature = request.getHeader(BaseConstant.X_REQUEST_SIGNATURE);
  47. if (ObjectUtils.isEmpty(signature)) {
  48. throw new ParamInvalidException(SignStatusCode.REQUEST_SIGNATURE_INVALID); // 无效签名
  49. }
  50. // 提取请求参数
  51. String requestParamsStr = extractRequestParams(request);
  52. // 验签。验签不通过抛出业务异常
  53. verifySignature(callerID, requestParamsStr, signature);
  54. }
  55. @SuppressWarnings("unchecked")
  56. public String extractRequestParams(HttpServletRequest request) {
  57. // @RequestBody
  58. String body = null;
  59. // 验签逻辑不能放在拦截器中,因为拦截器中不能直接读取body的输入流,否则会造成后续@RequestBody的参数解析器读取不到body
  60. // 由于body输入流只能读取一次,因此需要使用ContentCachingRequestWrapper包装请求,缓存body内容,但是该类的缓存时机是在@RequestBody的参数解析器中
  61. // 因此满足2个条件才能使用ContentCachingRequestWrapper中的body缓存
  62. // 1. 接口的入参必须存在@RequestBody
  63. // 2. 读取body缓存的时机必须在@RequestBody的参数解析之后,比如说:AOP、Controller层的逻辑内。注意拦截器的时机是在参数解析之前的
  64. if (request instanceof ContentCachingRequestWrapper) {
  65. ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) request;
  66. body = new String(requestWrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
  67. }
  68. // @RequestParam
  69. Map<String, String[]> paramMap = request.getParameterMap();
  70. // @PathVariable
  71. ServletWebRequest webRequest = new ServletWebRequest(request, null);
  72. Map<String, String> uriTemplateVarNap = (Map<String, String>) webRequest.getAttribute(
  73. HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
  74. return CommonUtils.extractRequestParams(body, paramMap, uriTemplateVarNap);
  75. }
  76. /**
  77. * 验证请求参数的签名
  78. */
  79. public void verifySignature(String callerID, String requestParamsStr, String signature) {
  80. try {
  81. boolean verified = signatureManager.verifySignature(callerID, requestParamsStr, signature);
  82. if (!verified) {
  83. throw new CryptoException("The signature verification result is false.");
  84. }
  85. } catch (Exception ex) {
  86. log.error("Failed to verify signature", ex);
  87. throw new AuthorizationException(SignStatusCode.REQUEST_SIGNATURE_INVALID); // 转换为业务异常抛出
  88. }
  89. }
  90. }
  1. import org.aspectj.lang.annotation.Pointcut;
  2. public interface PointCutDef {
  3. @Pointcut("execution(public * top.ysqorz..controller.*.*(..))")
  4. default void controllerMethod() {
  5. }
  6. @Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
  7. default void postMapping() {
  8. }
  9. @Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")
  10. default void getMapping() {
  11. }
  12. @Pointcut("@annotation(org.springframework.web.bind.annotation.PutMapping)")
  13. default void putMapping() {
  14. }
  15. @Pointcut("@annotation(org.springframework.web.bind.annotation.DeleteMapping)")
  16. default void deleteMapping() {
  17. }
  18. @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
  19. default void requestMapping() {
  20. }
  21. @Pointcut("controllerMethod() && (requestMapping() || postMapping() || getMapping() || putMapping() || deleteMapping())")
  22. default void apiMethod() {
  23. }
  24. }

5. 解决请求体只能读取一次

        解决方案就是包装请求,缓存请求体。SpringBoot也提供了ContentCachingRequestWrapper来解决这个问题。但是第4点中也详细描述了,由于它的缓存时机,所以它的使用有限制条件。也可以参考网上的方案,自己实现一个请求的包装类来缓存请求体

  1. import lombok.NonNull;
  2. import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
  3. import org.springframework.stereotype.Component;
  4. import org.springframework.web.filter.OncePerRequestFilter;
  5. import org.springframework.web.util.ContentCachingRequestWrapper;
  6. import top.ysqorz.signature.model.SignatureProps;
  7. import javax.servlet.FilterChain;
  8. import javax.servlet.ServletException;
  9. import javax.servlet.http.HttpServletRequest;
  10. import javax.servlet.http.HttpServletResponse;
  11. import java.io.IOException;
  12. @ConditionalOnBean(SignatureProps.class)
  13. @Component
  14. public class RequestCachingFilter extends OncePerRequestFilter {
  15. /**
  16. * This {@code doFilter} implementation stores a request attribute for
  17. * "already filtered", proceeding without filtering again if the
  18. * attribute is already there.
  19. *
  20. * @param request request
  21. * @param response response
  22. * @param filterChain filterChain
  23. * @see #getAlreadyFilteredAttributeName
  24. * @see #shouldNotFilter
  25. * @see #doFilterInternal
  26. */
  27. @Override
  28. protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
  29. throws ServletException, IOException {
  30. boolean isFirstRequest = !isAsyncDispatch(request);
  31. HttpServletRequest requestWrapper = request;
  32. if (isFirstRequest && !(request instanceof ContentCachingRequestWrapper)) {
  33. requestWrapper = new ContentCachingRequestWrapper(request);
  34. }
  35. filterChain.doFilter(requestWrapper, response);
  36. }
  37. }

注册过滤器

  1. import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
  2. import org.springframework.boot.web.servlet.FilterRegistrationBean;
  3. import org.springframework.context.annotation.Bean;
  4. import org.springframework.context.annotation.Configuration;
  5. import top.ysqorz.signature.model.SignatureProps;
  6. @Configuration
  7. public class FilterConfig {
  8. @ConditionalOnBean(SignatureProps.class)
  9. @Bean
  10. public FilterRegistrationBean<RequestCachingFilter> requestCachingFilterRegistration(
  11. RequestCachingFilter requestCachingFilter) {
  12. FilterRegistrationBean<RequestCachingFilter> bean = new FilterRegistrationBean<>(requestCachingFilter);
  13. bean.setOrder(1);
  14. return bean;
  15. }
  16. }

6. 自定义工具类

  1. import cn.hutool.core.util.StrUtil;
  2. import org.springframework.lang.Nullable;
  3. import org.springframework.util.ObjectUtils;
  4. import java.util.Arrays;
  5. import java.util.Map;
  6. import java.util.stream.Collectors;
  7. public class CommonUtils {
  8. /**
  9. * 提取所有的请求参数,按照固定规则拼接成一个字符串
  10. *
  11. * @param body post请求的请求体
  12. * @param paramMap 路径参数(QueryString)。形如:name=zhangsan&age=18&label=A&label=B
  13. * @param uriTemplateVarNap 路径变量(PathVariable)。形如:/{name}/{age}
  14. * @return 所有的请求参数按照固定规则拼接成的一个字符串
  15. */
  16. public static String extractRequestParams(@Nullable String body, @Nullable Map<String, String[]> paramMap,
  17. @Nullable Map<String, String> uriTemplateVarNap) {
  18. // body: { userID: "xxx" }
  19. // 路径参数
  20. // name=zhangsan&age=18&label=A&label=B
  21. // => ["name=zhangsan", "age=18", "label=A,B"]
  22. // => name=zhangsan&age=18&label=A,B
  23. String paramStr = null;
  24. if (!ObjectUtils.isEmpty(paramMap)) {
  25. paramStr = paramMap.entrySet().stream()
  26. .sorted(Map.Entry.comparingByKey())
  27. .map(entry -> {
  28. // 拷贝一份按字典序升序排序
  29. String[] sortedValue = Arrays.stream(entry.getValue()).sorted().toArray(String[]::new);
  30. return entry.getKey() + "=" + joinStr(",", sortedValue);
  31. })
  32. .collect(Collectors.joining("&"));
  33. }
  34. // 路径变量
  35. // /{name}/{age} => /zhangsan/18 => zhangsan,18
  36. String uriVarStr = null;
  37. if (!ObjectUtils.isEmpty(uriTemplateVarNap)) {
  38. uriVarStr = joinStr(",", uriTemplateVarNap.values().stream().sorted().toArray(String[]::new));
  39. }
  40. // { userID: "xxx" }#name=zhangsan&age=18&label=A,B#zhangsan,18
  41. return joinStr("#", body, paramStr, uriVarStr);
  42. }
  43. /**
  44. * 使用指定分隔符,拼接字符串
  45. *
  46. * @param delimiter 分隔符
  47. * @param strs 需要拼接的多个字符串,可以为null
  48. * @return 拼接后的新字符串
  49. */
  50. public static String joinStr(String delimiter, @Nullable String... strs) {
  51. if (ObjectUtils.isEmpty(strs)) {
  52. return StrUtil.EMPTY;
  53. }
  54. StringBuilder sbd = new StringBuilder();
  55. for (int i = 0; i < strs.length; i++) {
  56. if (ObjectUtils.isEmpty(strs[i])) {
  57. continue;
  58. }
  59. sbd.append(strs[i].trim());
  60. if (!ObjectUtils.isEmpty(sbd) && i < strs.length - 1 && !ObjectUtils.isEmpty(strs[i + 1])) {
  61. sbd.append(delimiter);
  62. }
  63. }
  64. return sbd.toString();
  65. }
  66. }

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/你好赵伟/article/detail/157240?site
推荐阅读
相关标签
  

闽ICP备14008679号