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
-
接口
+關注
關注
33文章
8691瀏覽量
151703 -
算法
+關注
關注
23文章
4629瀏覽量
93193 -
SpringBoot
+關注
關注
0文章
174瀏覽量
193
原文標題:SpringBoot 接口簽名校驗實踐
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論