代码地址:GitHub - passerbyYSQ/DemoRepository: 各种开发小demo
概念
开放接口
验签
接口验签调用流程
1. 约定签名算法
2. 颁发非对称密钥对
3. 生成请求参数签名
4. 请求携带签名调用
代码设计
1. 签名配置类
2. 签名管理类
3. 自定义验签注解
4. AOP实现验签逻辑
5. 解决请求体只能读取一次
6. 自定义工具类
开放接口是指不需要登录凭证就允许被第三方系统调用的接口。为了防止开放接口被恶意调用,开放接口一般都需要验签才能被调用。提供开放接口的系统下面统一简称为"原系统"。
验签是指第三方系统在调用接口之前,需要按照原系统的规则根据所有请求参数生成一个签名(字符串),在调用接口时携带该签名。原系统会验证签名的有效性,只有签名验证有效才能正常调用接口,否则请求会被驳回。
第三方系统作为调用方,需要与原系统协商约定签名算法(下面以SHA256withRSA签名算法为例)。同时约定一个名称(callerID),以便在原系统中来唯一标识调用方系统。
签名算法约定后之后,原系统会为每一个调用方系统专门生成一个专属的非对称密钥对(RSA密钥对)。私钥颁发给调用方系统,公钥由原系统持有。注意,调用方系统需要保管好私钥(存到调用方系统的后端)。因为对于原系统而言,调用方系统是消息的发送方,其持有的私钥唯一标识了它的身份是原系统受信任的调用方。调用方系统的私钥一旦泄露,调用方对原系统毫无信任可言。
签名算法约定后之后,生成签名的原理如下(活动图)。为了确保生成签名的处理细节与原系统的验签逻辑是匹配的,原系统一般都提供jar包或者代码片段给调用方来生成签名,否则可能会因为一些处理细节不一致导致生成的签名是无效的。
路径参数中放入约定好的callerID,请求头中放入调用方自己生成的签名
相关的自定义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 MapkeyPair; @Data public static class KeyPairProps { private SignAlgorithm algorithm; private String publicKeyPath; private String publicKey; private String privateKeyPath; private String privateKey; } }
定义一个管理类,持有上述配置,并暴露生成签名和校验签名的方法。
注意,生成的签名是将字节数组进行十六进制编码后的字符串,验签时需要将签名字符串进行十六进制解码成字节数组
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)); } }
有些接口需要验签,但有些接口并不需要,为了灵活控制哪些接口需要验签,自定义一个验签注解
import java.lang.annotation.*; /** * 该注解标注于Controller类的方法上,表明该请求的参数需要校验签名 */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.TYPE}) public @interface VerifySignature { }
验签逻辑不能放在拦截器中,因为拦截器中不能直接读取body的输入流,否则会造成后续@RequestBody的参数解析器读取不到body。
由于body输入流只能读取一次,因此需要使用ContentCachingRequestWrapper包装请求,缓存body内容(见第5点),但是该类的缓存时机是在@RequestBody的参数解析器中。
因此,满足2个条件才能获取到ContentCachingRequestWrapper中的body缓存:
综上,注意,标注了@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 MapparamMap = request.getParameterMap(); // @PathVariable ServletWebRequest webRequest = new ServletWebRequest(request, null); Map uriTemplateVarNap = (Map ) 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() { } }
解决方案就是包装请求,缓存请求体。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 FilterRegistrationBeanrequestCachingFilterRegistration( RequestCachingFilter requestCachingFilter) { FilterRegistrationBean bean = new FilterRegistrationBean<>(requestCachingFilter); bean.setOrder(1); return bean; } }
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 MapparamMap, @Nullable Map 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(); } }