1概念
开放接口
开放接口是指不需要登录凭证就允许被第三方系统调用的接口。为了防止开放接口被恶意调用,开放接口一般都需要验签才能被调用。提供开放接口的系统下面统一简称为"原系统"。
验签
验签是指第三方系统在调用接口之前,需要按照原系统的规则根据所有请求参数生成一个签名(字符串),在调用接口时携带该签名。原系统会验证签名的有效性,只有签名验证有效才能正常调用接口,否则请求会被驳回。
2接口验签调用流程
1. 约定签名算法
第三方系统作为调用方,需要与原系统协商约定签名算法(下面以SHA256withRSA签名算法为例)。同时约定一个名称(callerID),以便在原系统中来唯一标识调用方系统。
2. 颁发非对称密钥对
签名算法约定后之后,原系统会为每一个调用方系统专门生成一个专属的非对称密钥对(RSA密钥对)。私钥颁发给调用方系统,公钥由原系统持有。
注意,调用方系统需要保管好私钥(存到调用方系统的后端)。因为对于原系统而言,调用方系统是消息的发送方,其持有的私钥唯一标识了它的身份是原系统受信任的调用方。调用方系统的私钥一旦泄露,调用方对原系统毫无信任可言。
3. 生成请求参数签名
签名算法约定后之后,生成签名的原理如下(活动图)。
为了确保生成签名的处理细节与原系统的验签逻辑是匹配的,原系统一般都提供jar包或者代码片段给调用方来生成签名,否则可能会因为一些处理细节不一致导致生成的签名是无效的。
4. 请求携带签名调用
路径参数中放入约定好的callerID,请求头中放入调用方自己生成的签名
3代码设计
1. 签名配置类
相关的自定义yml配置如下。RSA的公钥和私钥可以使用hutool的SecureUtil工具类来生成,注意公钥和私钥是base64编码后的字符串
定义一个配置类来存储上述相关的自定义yml配置
importcn.hutool.crypto.asymmetric.SignAlgorithm; importlombok.Data; importorg.springframework.boot.autoconfigure.condition.ConditionalOnProperty; importorg.springframework.boot.context.properties.ConfigurationProperties; importorg.springframework.stereotype.Component; importjava.util.Map; /** *签名的相关配置 */ @Data @ConditionalOnProperty(value="secure.signature.enable",havingValue="true")//根据条件注入bean @Component @ConfigurationProperties("secure.signature") publicclassSignatureProps{ privateBooleanenable; privateMapkeyPair; @Data publicstaticclassKeyPairProps{ privateSignAlgorithmalgorithm; privateStringpublicKeyPath; privateStringpublicKey; privateStringprivateKeyPath; privateStringprivateKey; } }
2. 签名管理类
定义一个管理类,持有上述配置,并暴露生成签名和校验签名的方法。
注意,生成的签名是将字节数组进行十六进制编码后的字符串,验签时需要将签名字符串进行十六进制解码成字节数组
importcn.hutool.core.io.IoUtil; importcn.hutool.core.io.resource.ResourceUtil; importcn.hutool.core.util.HexUtil; importcn.hutool.crypto.SecureUtil; importcn.hutool.crypto.asymmetric.Sign; importorg.springframework.boot.autoconfigure.condition.ConditionalOnBean; importorg.springframework.stereotype.Component; importorg.springframework.util.ObjectUtils; importtop.ysqorz.signature.model.SignatureProps; importjava.nio.charset.StandardCharsets; @ConditionalOnBean(SignatureProps.class) @Component publicclassSignatureManager{ privatefinalSignaturePropssignatureProps; publicSignatureManager(SignaturePropssignatureProps){ this.signatureProps=signatureProps; loadKeyPairByPath(); } /** *验签。验证不通过可能抛出运行时异常CryptoException * *@paramcallerID调用方的唯一标识 *@paramrawData原数据 *@paramsignature待验证的签名(十六进制字符串) *@return验证是否通过 */ publicbooleanverifySignature(StringcallerID,StringrawData,Stringsignature){ Signsign=getSignByCallerID(callerID); if(ObjectUtils.isEmpty(sign)){ returnfalse; } //使用公钥验签 returnsign.verify(rawData.getBytes(StandardCharsets.UTF_8),HexUtil.decodeHex(signature)); } /** *生成签名 * *@paramcallerID调用方的唯一标识 *@paramrawData原数据 *@return签名(十六进制字符串) */ publicStringsign(StringcallerID,StringrawData){ Signsign=getSignByCallerID(callerID); if(ObjectUtils.isEmpty(sign)){ returnnull; } returnsign.signHex(rawData); } publicSignaturePropsgetSignatureProps(){ returnsignatureProps; } publicSignatureProps.KeyPairPropsgetKeyPairPropsByCallerID(StringcallerID){ returnsignatureProps.getKeyPair().get(callerID); } privateSigngetSignByCallerID(StringcallerID){ SignatureProps.KeyPairPropskeyPairProps=signatureProps.getKeyPair().get(callerID); if(ObjectUtils.isEmpty(keyPairProps)){ returnnull;//无效的、不受信任的调用方 } returnSecureUtil.sign(keyPairProps.getAlgorithm(),keyPairProps.getPrivateKey(),keyPairProps.getPublicKey()); } /** *加载非对称密钥对 */ privatevoidloadKeyPairByPath(){ //支持类路径配置,形如: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())){ thrownewRuntimeException("Nopublicandprivatekeyfilesconfigured"); } }); } privateStringloadKeyByPath(Stringpath){ if(ObjectUtils.isEmpty(path)){ returnnull; } returnIoUtil.readUtf8(ResourceUtil.getStream(path)); } }
3. 自定义验签注解
有些接口需要验签,但有些接口并不需要,为了灵活控制哪些接口需要验签,自定义一个验签注解
importjava.lang.annotation.*;
/** *该注解标注于Controller类的方法上,表明该请求的参数需要校验签名 */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD,ElementType.TYPE}) public@interfaceVerifySignature{ }4. AOP实现验签逻辑
验签逻辑不能放在拦截器中,因为拦截器中不能直接读取body的输入流,否则会造成后续@RequestBody的参数解析器读取不到body。
由于body输入流只能读取一次,因此需要使用ContentCachingRequestWrapper包装请求,缓存body内容(见第5点),但是该类的缓存时机是在@RequestBody的参数解析器中。
因此,满足2个条件才能获取到ContentCachingRequestWrapper中的body缓存:
接口的入参必须存在@RequestBody
读取body缓存的时机必须在@RequestBody的参数解析之后,比如说:AOP、Controller层的逻辑内。注意拦截器的时机是在参数解析之前的
综上,标注了@VerifySignature注解的controlle层方法的入参必须存在@RequestBody,AOP中验签时才能获取到body的缓存!
importcn.hutool.crypto.CryptoException; importlombok.extern.slf4j.Slf4j; importorg.aspectj.lang.annotation.Aspect; importorg.aspectj.lang.annotation.Before; importorg.aspectj.lang.annotation.Pointcut; importorg.springframework.boot.autoconfigure.condition.ConditionalOnBean; importorg.springframework.stereotype.Component; importorg.springframework.util.ObjectUtils; importorg.springframework.web.context.request.RequestAttributes; importorg.springframework.web.context.request.ServletWebRequest; importorg.springframework.web.servlet.HandlerMapping; importorg.springframework.web.util.ContentCachingRequestWrapper; importtop.ysqorz.common.constant.BaseConstant; importtop.ysqorz.config.SpringContextHolder; importtop.ysqorz.config.aspect.PointCutDef; importtop.ysqorz.exception.auth.AuthorizationException; importtop.ysqorz.exception.param.ParamInvalidException; importtop.ysqorz.signature.model.SignStatusCode; importtop.ysqorz.signature.model.SignatureProps; importtop.ysqorz.signature.util.CommonUtils; importjavax.annotation.Resource; importjavax.servlet.http.HttpServletRequest; importjava.nio.charset.StandardCharsets; importjava.util.Map; @ConditionalOnBean(SignatureProps.class) @Component @Slf4j @Aspect publicclassRequestSignatureAspectimplementsPointCutDef{ @Resource privateSignatureManagersignatureManager; @Pointcut("@annotation(top.ysqorz.signature.enumeration.VerifySignature)") publicvoidannotatedMethod(){ } @Pointcut("@within(top.ysqorz.signature.enumeration.VerifySignature)") publicvoidannotatedClass(){ } @Before("apiMethod()&&(annotatedMethod()||annotatedClass())") publicvoidverifySignature(){ HttpServletRequestrequest=SpringContextHolder.getRequest(); StringcallerID=request.getParameter(BaseConstant.PARAM_CALLER_ID); if(ObjectUtils.isEmpty(callerID)){ thrownewAuthorizationException(SignStatusCode.UNTRUSTED_CALLER);//不受信任的调用方 } //从请求头中提取签名,不存在直接驳回 Stringsignature=request.getHeader(BaseConstant.X_REQUEST_SIGNATURE); if(ObjectUtils.isEmpty(signature)){ thrownewParamInvalidException(SignStatusCode.REQUEST_SIGNATURE_INVALID);//无效签名 } //提取请求参数 StringrequestParamsStr=extractRequestParams(request); //验签。验签不通过抛出业务异常 verifySignature(callerID,requestParamsStr,signature); } @SuppressWarnings("unchecked") publicStringextractRequestParams(HttpServletRequestrequest){ //@RequestBody Stringbody=null; //验签逻辑不能放在拦截器中,因为拦截器中不能直接读取body的输入流,否则会造成后续@RequestBody的参数解析器读取不到body //由于body输入流只能读取一次,因此需要使用ContentCachingRequestWrapper包装请求,缓存body内容,但是该类的缓存时机是在@RequestBody的参数解析器中 //因此满足2个条件才能使用ContentCachingRequestWrapper中的body缓存 //1.接口的入参必须存在@RequestBody //2.读取body缓存的时机必须在@RequestBody的参数解析之后,比如说:AOP、Controller层的逻辑内。注意拦截器的时机是在参数解析之前的 if(requestinstanceofContentCachingRequestWrapper){ ContentCachingRequestWrapperrequestWrapper=(ContentCachingRequestWrapper)request; body=newString(requestWrapper.getContentAsByteArray(),StandardCharsets.UTF_8); } //@RequestParam MapparamMap=request.getParameterMap(); //@PathVariable ServletWebRequestwebRequest=newServletWebRequest(request,null); Map uriTemplateVarNap=(Map )webRequest.getAttribute( HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE,RequestAttributes.SCOPE_REQUEST); returnCommonUtils.extractRequestParams(body,paramMap,uriTemplateVarNap); } /** *验证请求参数的签名 */ publicvoidverifySignature(StringcallerID,StringrequestParamsStr,Stringsignature){ try{ booleanverified=signatureManager.verifySignature(callerID,requestParamsStr,signature); if(!verified){ thrownewCryptoException("Thesignatureverificationresultisfalse."); } }catch(Exceptionex){ log.error("Failedtoverifysignature",ex); thrownewAuthorizationException(SignStatusCode.REQUEST_SIGNATURE_INVALID);//转换为业务异常抛出 } } } importorg.aspectj.lang.annotation.Pointcut; publicinterfacePointCutDef{ @Pointcut("execution(public*top.ysqorz..controller.*.*(..))") defaultvoidcontrollerMethod(){ } @Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)") defaultvoidpostMapping(){ } @Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)") defaultvoidgetMapping(){ } @Pointcut("@annotation(org.springframework.web.bind.annotation.PutMapping)") defaultvoidputMapping(){ } @Pointcut("@annotation(org.springframework.web.bind.annotation.DeleteMapping)") defaultvoiddeleteMapping(){ } @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping)") defaultvoidrequestMapping(){ } @Pointcut("controllerMethod()&&(requestMapping()||postMapping()||getMapping()||putMapping()||deleteMapping())") defaultvoidapiMethod(){ } }
5. 解决请求体只能读取一次
解决方案就是包装请求,缓存请求体。SpringBoot也提供了ContentCachingRequestWrapper来解决这个问题。但是第4点中也详细描述了,由于它的缓存时机,所以它的使用有限制条件。也可以参考网上的方案,自己实现一个请求的包装类来缓存请求体
importlombok.NonNull; importorg.springframework.boot.autoconfigure.condition.ConditionalOnBean; importorg.springframework.stereotype.Component; importorg.springframework.web.filter.OncePerRequestFilter; importorg.springframework.web.util.ContentCachingRequestWrapper; importtop.ysqorz.signature.model.SignatureProps; importjavax.servlet.FilterChain; importjavax.servlet.ServletException; importjavax.servlet.http.HttpServletRequest; importjavax.servlet.http.HttpServletResponse; importjava.io.IOException; @ConditionalOnBean(SignatureProps.class) @Component publicclassRequestCachingFilterextendsOncePerRequestFilter{ /** *This{@codedoFilter}implementationstoresarequestattributefor *"alreadyfiltered",proceedingwithoutfilteringagainifthe *attributeisalreadythere. * *@paramrequestrequest *@paramresponseresponse *@paramfilterChainfilterChain *@see#getAlreadyFilteredAttributeName *@see#shouldNotFilter *@see#doFilterInternal */ @Override protectedvoiddoFilterInternal(@NonNullHttpServletRequestrequest,@NonNullHttpServletResponseresponse,@NonNullFilterChainfilterChain) throwsServletException,IOException{ booleanisFirstRequest=!isAsyncDispatch(request); HttpServletRequestrequestWrapper=request; if(isFirstRequest&&!(requestinstanceofContentCachingRequestWrapper)){ requestWrapper=newContentCachingRequestWrapper(request); } filterChain.doFilter(requestWrapper,response); } }
注册过滤器
importorg.springframework.boot.autoconfigure.condition.ConditionalOnBean; importorg.springframework.boot.web.servlet.FilterRegistrationBean; importorg.springframework.context.annotation.Bean; importorg.springframework.context.annotation.Configuration; importtop.ysqorz.signature.model.SignatureProps; @Configuration publicclassFilterConfig{ @ConditionalOnBean(SignatureProps.class) @Bean publicFilterRegistrationBeanrequestCachingFilterRegistration( RequestCachingFilterrequestCachingFilter){ FilterRegistrationBean bean=newFilterRegistrationBean<>(requestCachingFilter); bean.setOrder(1); returnbean; } }
6. 自定义工具类
importcn.hutool.core.util.StrUtil; importorg.springframework.lang.Nullable; importorg.springframework.util.ObjectUtils; importjava.util.Arrays; importjava.util.Map; importjava.util.stream.Collectors; publicclassCommonUtils{ /** *提取所有的请求参数,按照固定规则拼接成一个字符串 * *@parambodypost请求的请求体 *@paramparamMap路径参数(QueryString)。形如:name=zhangsan&age=18&label=A&label=B *@paramuriTemplateVarNap路径变量(PathVariable)。形如:/{name}/{age} *@return所有的请求参数按照固定规则拼接成的一个字符串 */ publicstaticStringextractRequestParams(@NullableStringbody,@NullableMapparamMap, @NullableMap 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 StringparamStr=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); returnentry.getKey()+"="+joinStr(",",sortedValue); }) .collect(Collectors.joining("&")); } //路径变量 ///{name}/{age}=>/zhangsan/18=>zhangsan,18 StringuriVarStr=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 returnjoinStr("#",body,paramStr,uriVarStr); } /** *使用指定分隔符,拼接字符串 * *@paramdelimiter分隔符 *@paramstrs需要拼接的多个字符串,可以为null *@return拼接后的新字符串 */ publicstaticStringjoinStr(Stringdelimiter,@NullableString...strs){ if(ObjectUtils.isEmpty(strs)){ returnStrUtil.EMPTY; } StringBuildersbd=newStringBuilder(); for(inti=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(); } } 编辑:黄飞
-
接口
+关注
关注
33文章
8497浏览量
150835 -
算法
+关注
关注
23文章
4599浏览量
92643 -
SpringBoot
+关注
关注
0文章
173浏览量
168
原文标题:SpringBoot 接口签名校验实践
文章出处:【微信号:芋道源码,微信公众号:芋道源码】欢迎添加关注!文章转载请注明出处。
发布评论请先 登录
相关推荐
评论