这篇文章上次修改于 192 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

SpringBoot 接口签名校验实践


[1概念]

[开放接口]

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

[验签]

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

[2接口验签调用流程]

[1. 约定签名算法]

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

[2. 颁发非对称密钥对]

签名算法约定后之后,原系统会为每一个调用方系统专门生成一个专属的非对称密钥对(RSA密钥对)。私钥颁发给调用方系统,公钥由原系统持有。

注意,调用方系统需要保管好私钥(存到调用方系统的后端)。因为对于原系统而言,调用方系统是消息的发送方,其持有的私钥唯一标识了它的身份是原系统受信任的调用方。调用方系统的私钥一旦泄露,调用方对原系统毫无信任可言。

[3. 生成请求参数签名]

签名算法约定后之后,生成签名的原理如下(活动图)。

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

[4. 请求携带签名调用]

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

[3代码设计]

[1. 签名配置类]

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

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



`import cn.hutool.crypto.asymmetric.SignAlgorithm; import lombok.Data; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component;   import java.util.Map;   /**  * 签名的相关配置  */ @Data @ConditionalOnProperty(value = "secure.signature.enable", havingValue = "true")  // 根据条件注入bean @Component @ConfigurationProperties("secure.signature") public class SignatureProps {     private Boolean enable;     private Map<String, KeyPairProps> keyPair;       @Data     public static class KeyPairProps {         private SignAlgorithm algorithm;         private String publicKeyPath;         private String publicKey;         private String privateKeyPath;         private String privateKey;     } } `

[2. 签名管理类]

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

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


`import cn.hutool.core.io.IoUtil; import cn.hutool.core.io.resource.ResourceUtil; import cn.hutool.core.util.HexUtil; import cn.hutool.crypto.SecureUtil; import cn.hutool.crypto.asymmetric.Sign; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import top.ysqorz.signature.model.SignatureProps;   import java.nio.charset.StandardCharsets;   @ConditionalOnBean(SignatureProps.class) @Component public class SignatureManager {     private final SignatureProps signatureProps;       public SignatureManager(SignatureProps signatureProps) {         this.signatureProps = signatureProps;         loadKeyPairByPath();     }       /**      * 验签。验证不通过可能抛出运行时异常CryptoException      *      * @param callerID  调用方的唯一标识      * @param rawData   原数据      * @param signature 待验证的签名(十六进制字符串)      * @return 验证是否通过      */     public boolean verifySignature(String callerID, String rawData, String signature) {         Sign sign = getSignByCallerID(callerID);         if (ObjectUtils.isEmpty(sign)) {             return false;         }           // 使用公钥验签         return sign.verify(rawData.getBytes(StandardCharsets.UTF_8), HexUtil.decodeHex(signature));     }       /**      * 生成签名      *      * @param callerID 调用方的唯一标识      * @param rawData  原数据      * @return 签名(十六进制字符串)      */     public String sign(String callerID, String rawData) {         Sign sign = getSignByCallerID(callerID);         if (ObjectUtils.isEmpty(sign)) {             return null;         }         return sign.signHex(rawData);     }       public SignatureProps getSignatureProps() {         return signatureProps;     }       public SignatureProps.KeyPairProps getKeyPairPropsByCallerID(String callerID) {         return signatureProps.getKeyPair().get(callerID);     }       private Sign getSignByCallerID(String callerID) {         SignatureProps.KeyPairProps keyPairProps = signatureProps.getKeyPair().get(callerID);         if (ObjectUtils.isEmpty(keyPairProps)) {             return null; // 无效的、不受信任的调用方         }         return SecureUtil.sign(keyPairProps.getAlgorithm(), keyPairProps.getPrivateKey(), keyPairProps.getPublicKey());     }       /**      * 加载非对称密钥对      */     private void loadKeyPairByPath() {         // 支持类路径配置,形如:classpath:secure/public.txt         // 公钥和私钥都是base64编码后的字符串         signatureProps.getKeyPair()                 .forEach((key, keyPairProps) -> {                     // 如果配置了XxxKeyPath,则优先XxxKeyPath                     keyPairProps.setPublicKey(loadKeyByPath(keyPairProps.getPublicKeyPath()));                     keyPairProps.setPrivateKey(loadKeyByPath(keyPairProps.getPrivateKeyPath()));                     if (ObjectUtils.isEmpty(keyPairProps.getPublicKey()) ||                             ObjectUtils.isEmpty(keyPairProps.getPrivateKey())) {                         throw new RuntimeException("No public and private key files configured");                     }                 });     }       private String loadKeyByPath(String path) {         if (ObjectUtils.isEmpty(path)) {             return null;         }         return IoUtil.readUtf8(ResourceUtil.getStream(path));     } } `

[3. 自定义验签注解]

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



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

[4. AOP实现验签逻辑]

验签逻辑不能放在拦截器中,因为拦截器中不能直接读取body的输入流,否则会造成后续@RequestBody的参数解析器读取不到body。

由于body输入流只能读取一次,因此需要使用ContentCachingRequestWrapper包装请求,缓存body内容(见第5点),但是该类的缓存时机是在@RequestBody的参数解析器中。

因此,满足2个条件才能获取到ContentCachingRequestWrapper中的body缓存:

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

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



`import cn.hutool.crypto.CryptoException; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Pointcut; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.util.ContentCachingRequestWrapper; import top.ysqorz.common.constant.BaseConstant; import top.ysqorz.config.SpringContextHolder; import top.ysqorz.config.aspect.PointCutDef; import top.ysqorz.exception.auth.AuthorizationException; import top.ysqorz.exception.param.ParamInvalidException; import top.ysqorz.signature.model.SignStatusCode; import top.ysqorz.signature.model.SignatureProps; import top.ysqorz.signature.util.CommonUtils;   import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.nio.charset.StandardCharsets; import java.util.Map;   @ConditionalOnBean(SignatureProps.class) @Component @Slf4j @Aspect public class RequestSignatureAspect implements PointCutDef {     @Resource     private SignatureManager signatureManager;       @Pointcut("@annotation(top.ysqorz.signature.enumeration.VerifySignature)")     public void annotatedMethod() {     }       @Pointcut("@within(top.ysqorz.signature.enumeration.VerifySignature)")     public void annotatedClass() {     }       @Before("apiMethod() && (annotatedMethod() || annotatedClass())")     public void verifySignature() {         HttpServletRequest request = SpringContextHolder.getRequest();           String callerID = request.getParameter(BaseConstant.PARAM_CALLER_ID);         if (ObjectUtils.isEmpty(callerID)) {             throw new AuthorizationException(SignStatusCode.UNTRUSTED_CALLER); // 不受信任的调用方         }           // 从请求头中提取签名,不存在直接驳回         String signature = request.getHeader(BaseConstant.X_REQUEST_SIGNATURE);         if (ObjectUtils.isEmpty(signature)) {             throw new ParamInvalidException(SignStatusCode.REQUEST_SIGNATURE_INVALID); // 无效签名         }           // 提取请求参数         String requestParamsStr = extractRequestParams(request);         // 验签。验签不通过抛出业务异常         verifySignature(callerID, requestParamsStr, signature);     }       @SuppressWarnings("unchecked")     public String extractRequestParams(HttpServletRequest request) {         // @RequestBody         String body = null;         // 验签逻辑不能放在拦截器中,因为拦截器中不能直接读取body的输入流,否则会造成后续@RequestBody的参数解析器读取不到body         // 由于body输入流只能读取一次,因此需要使用ContentCachingRequestWrapper包装请求,缓存body内容,但是该类的缓存时机是在@RequestBody的参数解析器中         // 因此满足2个条件才能使用ContentCachingRequestWrapper中的body缓存         // 1. 接口的入参必须存在@RequestBody         // 2. 读取body缓存的时机必须在@RequestBody的参数解析之后,比如说:AOP、Controller层的逻辑内。注意拦截器的时机是在参数解析之前的         if (request instanceof ContentCachingRequestWrapper) {             ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) request;             body = new String(requestWrapper.getContentAsByteArray(), StandardCharsets.UTF_8);         }           // @RequestParam         Map<String, String[]> paramMap = request.getParameterMap();           // @PathVariable         ServletWebRequest webRequest = new ServletWebRequest(request, null);         Map<String, String> uriTemplateVarNap = (Map<String, String>) webRequest.getAttribute(                 HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);           return CommonUtils.extractRequestParams(body, paramMap, uriTemplateVarNap);     }       /**      * 验证请求参数的签名      */     public void verifySignature(String callerID, String requestParamsStr, String signature) {         try {             boolean verified = signatureManager.verifySignature(callerID, requestParamsStr, signature);             if (!verified) {                 throw new CryptoException("The signature verification result is false.");             }         } catch (Exception ex) {             log.error("Failed to verify signature", ex);             throw new AuthorizationException(SignStatusCode.REQUEST_SIGNATURE_INVALID); // 转换为业务异常抛出         }     } } import org.aspectj.lang.annotation.Pointcut;   public interface PointCutDef {     @Pointcut("execution(public * top.ysqorz..controller.*.*(..))")     default void controllerMethod() {     }       @Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")     default void postMapping() {     }       @Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")     default void getMapping() {     }       @Pointcut("@annotation(org.springframework.web.bind.annotation.PutMapping)")     default void putMapping() {     }       @Pointcut("@annotation(org.springframework.web.bind.annotation.DeleteMapping)")     default void deleteMapping() {     }       @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping)")     default void requestMapping() {     }       @Pointcut("controllerMethod() && (requestMapping() || postMapping() || getMapping() || putMapping() || deleteMapping())")     default void apiMethod() {     } } `

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

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



`import lombok.NonNull; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; import top.ysqorz.signature.model.SignatureProps;   import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException;   @ConditionalOnBean(SignatureProps.class) @Component public class RequestCachingFilter extends OncePerRequestFilter {     /**      * This {@code doFilter} implementation stores a request attribute for      * "already filtered", proceeding without filtering again if the      * attribute is already there.      *      * @param request     request      * @param response    response      * @param filterChain filterChain      * @see #getAlreadyFilteredAttributeName      * @see #shouldNotFilter      * @see #doFilterInternal      */     @Override     protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)             throws ServletException, IOException {         boolean isFirstRequest = !isAsyncDispatch(request);         HttpServletRequest requestWrapper = request;         if (isFirstRequest && !(request instanceof ContentCachingRequestWrapper)) {             requestWrapper = new ContentCachingRequestWrapper(request);         }         filterChain.doFilter(requestWrapper, response);     } } `

注册过滤器



`import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import top.ysqorz.signature.model.SignatureProps;   @Configuration public class FilterConfig {     @ConditionalOnBean(SignatureProps.class)     @Bean     public FilterRegistrationBean<RequestCachingFilter> requestCachingFilterRegistration(             RequestCachingFilter requestCachingFilter) {         FilterRegistrationBean<RequestCachingFilter> bean = new FilterRegistrationBean<>(requestCachingFilter);         bean.setOrder(1);         return bean;     } } `

[6. 自定义工具类]



`import cn.hutool.core.util.StrUtil; import org.springframework.lang.Nullable; import org.springframework.util.ObjectUtils;   import java.util.Arrays; import java.util.Map; import java.util.stream.Collectors;   public class CommonUtils {     /**      * 提取所有的请求参数,按照固定规则拼接成一个字符串      *      * @param body              post请求的请求体      * @param paramMap          路径参数(QueryString)。形如:name=zhangsan&age=18&label=A&label=B      * @param uriTemplateVarNap 路径变量(PathVariable)。形如:/{name}/{age}      * @return 所有的请求参数按照固定规则拼接成的一个字符串      */     public static String extractRequestParams(@Nullable String body, @Nullable Map<String, String[]> paramMap,                                               @Nullable Map<String, String> uriTemplateVarNap) {         // body: { userID: "xxx" }           // 路径参数         // name=zhangsan&age=18&label=A&label=B         // => ["name=zhangsan", "age=18", "label=A,B"]         // => name=zhangsan&age=18&label=A,B         String paramStr = null;         if (!ObjectUtils.isEmpty(paramMap)) {             paramStr = paramMap.entrySet().stream()                     .sorted(Map.Entry.comparingByKey())                     .map(entry -> {                         // 拷贝一份按字典序升序排序                         String[] sortedValue = Arrays.stream(entry.getValue()).sorted().toArray(String[]::new);                         return entry.getKey() + "=" + joinStr(",", sortedValue);                     })                     .collect(Collectors.joining("&"));         }           // 路径变量         // /{name}/{age} => /zhangsan/18 => zhangsan,18         String uriVarStr = null;         if (!ObjectUtils.isEmpty(uriTemplateVarNap)) {             uriVarStr = joinStr(",", uriTemplateVarNap.values().stream().sorted().toArray(String[]::new));         }           // { userID: "xxx" }#name=zhangsan&age=18&label=A,B#zhangsan,18         return joinStr("#", body, paramStr, uriVarStr);     }       /**      * 使用指定分隔符,拼接字符串      *      * @param delimiter 分隔符      * @param strs      需要拼接的多个字符串,可以为null      * @return 拼接后的新字符串      */     public static String joinStr(String delimiter, @Nullable String... strs) {         if (ObjectUtils.isEmpty(strs)) {             return StrUtil.EMPTY;         }         StringBuilder sbd = new StringBuilder();         for (int i = 0; i < strs.length; i++) {             if (ObjectUtils.isEmpty(strs[i])) {                 continue;             }             sbd.append(strs[i].trim());             if (!ObjectUtils.isEmpty(sbd) && i < strs.length - 1 && !ObjectUtils.isEmpty(strs[i + 1])) {                 sbd.append(delimiter);             }         }         return sbd.toString();     } } `