1概念
開放接口
開放接口是指不需要登錄憑證就允許被第三方系統(tǒng)調(diào)用的接口。為了防止開放接口被惡意調(diào)用,開放接口一般都需要驗簽才能被調(diào)用。提供開放接口的系統(tǒng)下面統(tǒng)一簡稱為"原系統(tǒng)"。
驗簽
驗簽是指第三方系統(tǒng)在調(diào)用接口之前,需要按照原系統(tǒng)的規(guī)則根據(jù)所有請求參數(shù)生成一個簽名(字符串),在調(diào)用接口時攜帶該簽名。原系統(tǒng)會驗證簽名的有效性,只有簽名驗證有效才能正常調(diào)用接口,否則請求會被駁回。
2接口驗簽調(diào)用流程
1. 約定簽名算法
第三方系統(tǒng)作為調(diào)用方,需要與原系統(tǒng)協(xié)商約定簽名算法(下面以SHA256withRSA簽名算法為例)。同時約定一個名稱(callerID),以便在原系統(tǒng)中來唯一標(biāo)識調(diào)用方系統(tǒng)。
2. 頒發(fā)非對稱密鑰對
簽名算法約定后之后,原系統(tǒng)會為每一個調(diào)用方系統(tǒng)專門生成一個專屬的非對稱密鑰對(RSA密鑰對)。私鑰頒發(fā)給調(diào)用方系統(tǒng),公鑰由原系統(tǒng)持有。
注意,調(diào)用方系統(tǒng)需要保管好私鑰(存到調(diào)用方系統(tǒng)的后端)。因為對于原系統(tǒng)而言,調(diào)用方系統(tǒng)是消息的發(fā)送方,其持有的私鑰唯一標(biāo)識了它的身份是原系統(tǒng)受信任的調(diào)用方。調(diào)用方系統(tǒng)的私鑰一旦泄露,調(diào)用方對原系統(tǒng)毫無信任可言。
3. 生成請求參數(shù)簽名
簽名算法約定后之后,生成簽名的原理如下(活動圖)。
為了確保生成簽名的處理細(xì)節(jié)與原系統(tǒng)的驗簽邏輯是匹配的,原系統(tǒng)一般都提供jar包或者代碼片段給調(diào)用方來生成簽名,否則可能會因為一些處理細(xì)節(jié)不一致導(dǎo)致生成的簽名是無效的。
4. 請求攜帶簽名調(diào)用
路徑參數(shù)中放入約定好的callerID,請求頭中放入調(diào)用方自己生成的簽名
3代碼設(shè)計
1. 簽名配置類
相關(guān)的自定義yml配置如下。RSA的公鑰和私鑰可以使用hutool的SecureUtil工具類來生成,注意公鑰和私鑰是base64編碼后的字符串
定義一個配置類來存儲上述相關(guān)的自定義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; /** *簽名的相關(guān)配置 */ @Data @ConditionalOnProperty(value="secure.signature.enable",havingValue="true")//根據(jù)條件注入bean @Component @ConfigurationProperties("secure.signature") publicclassSignatureProps{ privateBooleanenable; privateMapkeyPair; @Data publicstaticclassKeyPairProps{ privateSignAlgorithmalgorithm; privateStringpublicKeyPath; privateStringpublicKey; privateStringprivateKeyPath; privateStringprivateKey; } }
2. 簽名管理類
定義一個管理類,持有上述配置,并暴露生成簽名和校驗簽名的方法。
注意,生成的簽名是將字節(jié)數(shù)組進(jìn)行十六進(jìn)制編碼后的字符串,驗簽時需要將簽名字符串進(jìn)行十六進(jìn)制解碼成字節(jié)數(shù)組
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(); } /** *驗簽。驗證不通過可能拋出運(yùn)行時異常CryptoException * *@paramcallerID調(diào)用方的唯一標(biāo)識 *@paramrawData原數(shù)據(jù) *@paramsignature待驗證的簽名(十六進(jìn)制字符串) *@return驗證是否通過 */ publicbooleanverifySignature(StringcallerID,StringrawData,Stringsignature){ Signsign=getSignByCallerID(callerID); if(ObjectUtils.isEmpty(sign)){ returnfalse; } //使用公鑰驗簽 returnsign.verify(rawData.getBytes(StandardCharsets.UTF_8),HexUtil.decodeHex(signature)); } /** *生成簽名 * *@paramcallerID調(diào)用方的唯一標(biāo)識 *@paramrawData原數(shù)據(jù) *@return簽名(十六進(jìn)制字符串) */ 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;//無效的、不受信任的調(diào)用方 } returnSecureUtil.sign(keyPairProps.getAlgorithm(),keyPairProps.getPrivateKey(),keyPairProps.getPublicKey()); } /** *加載非對稱密鑰對 */ privatevoidloadKeyPairByPath(){ //支持類路徑配置,形如:classpath:secure/public.txt //公鑰和私鑰都是base64編碼后的字符串 signatureProps.getKeyPair() .forEach((key,keyPairProps)->{ //如果配置了XxxKeyPath,則優(yōu)先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.*;
/** *該注解標(biāo)注于Controller類的方法上,表明該請求的參數(shù)需要校驗簽名 */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD,ElementType.TYPE}) public@interfaceVerifySignature{ }4. AOP實現(xiàn)驗簽邏輯
驗簽邏輯不能放在攔截器中,因為攔截器中不能直接讀取body的輸入流,否則會造成后續(xù)@RequestBody的參數(shù)解析器讀取不到body。
由于body輸入流只能讀取一次,因此需要使用ContentCachingRequestWrapper包裝請求,緩存body內(nèi)容(見第5點),但是該類的緩存時機(jī)是在@RequestBody的參數(shù)解析器中。
因此,滿足2個條件才能獲取到ContentCachingRequestWrapper中的body緩存:
接口的入?yún)⒈仨毚嬖贎RequestBody
讀取body緩存的時機(jī)必須在@RequestBody的參數(shù)解析之后,比如說:AOP、Controller層的邏輯內(nèi)。注意攔截器的時機(jī)是在參數(shù)解析之前的
綜上,標(biāo)注了@VerifySignature注解的controlle層方法的入?yún)⒈仨毚嬖贎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);//不受信任的調(diào)用方 } //從請求頭中提取簽名,不存在直接駁回 Stringsignature=request.getHeader(BaseConstant.X_REQUEST_SIGNATURE); if(ObjectUtils.isEmpty(signature)){ thrownewParamInvalidException(SignStatusCode.REQUEST_SIGNATURE_INVALID);//無效簽名 } //提取請求參數(shù) StringrequestParamsStr=extractRequestParams(request); //驗簽。驗簽不通過拋出業(yè)務(wù)異常 verifySignature(callerID,requestParamsStr,signature); } @SuppressWarnings("unchecked") publicStringextractRequestParams(HttpServletRequestrequest){ //@RequestBody Stringbody=null; //驗簽邏輯不能放在攔截器中,因為攔截器中不能直接讀取body的輸入流,否則會造成后續(xù)@RequestBody的參數(shù)解析器讀取不到body //由于body輸入流只能讀取一次,因此需要使用ContentCachingRequestWrapper包裝請求,緩存body內(nèi)容,但是該類的緩存時機(jī)是在@RequestBody的參數(shù)解析器中 //因此滿足2個條件才能使用ContentCachingRequestWrapper中的body緩存 //1.接口的入?yún)⒈仨毚嬖贎RequestBody //2.讀取body緩存的時機(jī)必須在@RequestBody的參數(shù)解析之后,比如說:AOP、Controller層的邏輯內(nèi)。注意攔截器的時機(jī)是在參數(shù)解析之前的 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); } /** *驗證請求參數(shù)的簽名 */ 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);//轉(zhuǎn)換為業(yè)務(wù)異常拋出 } } } 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點中也詳細(xì)描述了,由于它的緩存時機(jī),所以它的使用有限制條件。也可以參考網(wǎng)上的方案,自己實現(xiàn)一個請求的包裝類來緩存請求體
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{ /** *提取所有的請求參數(shù),按照固定規(guī)則拼接成一個字符串 * *@parambodypost請求的請求體 *@paramparamMap路徑參數(shù)(QueryString)。形如:name=zhangsan&age=18&label=A&label=B *@paramuriTemplateVarNap路徑變量(PathVariable)。形如:/{name}/{age} *@return所有的請求參數(shù)按照固定規(guī)則拼接成的一個字符串 */ publicstaticStringextractRequestParams(@NullableStringbody,@NullableMapparamMap, @NullableMap uriTemplateVarNap){ //body:{userID:"xxx"} //路徑參數(shù) //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
-
接口
+關(guān)注
關(guān)注
33文章
8596瀏覽量
151145 -
算法
+關(guān)注
關(guān)注
23文章
4612瀏覽量
92884 -
SpringBoot
+關(guān)注
關(guān)注
0文章
173瀏覽量
178
原文標(biāo)題:SpringBoot 接口簽名校驗實踐
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論