SpringBoot實(shí)現(xiàn)接口校驗(yàn)簽名調(diào)用的項(xiàng)目實(shí)踐
概念
開放接口
開放接口是指不需要登錄憑證就允許被第三方系統(tǒng)調(diào)用的接口。為了防止開放接口被惡意調(diào)用,開放接口一般都需要驗(yàn)簽才能被調(diào)用。提供開放接口的系統(tǒng)下面統(tǒng)一簡稱為"原系統(tǒng)"。
驗(yàn)簽
驗(yàn)簽是指第三方系統(tǒng)在調(diào)用接口之前,需要按照原系統(tǒng)的規(guī)則根據(jù)所有請(qǐng)求參數(shù)生成一個(gè)簽名(字符串),在調(diào)用接口時(shí)攜帶該簽名。原系統(tǒng)會(huì)驗(yàn)證簽名的有效性,只有簽名驗(yàn)證有效才能正常調(diào)用接口,否則請(qǐng)求會(huì)被駁回。
接口驗(yàn)簽調(diào)用流程
1. 約定簽名算法
第三方系統(tǒng)作為調(diào)用方,需要與原系統(tǒng)協(xié)商約定簽名算法(下面以SHA256withRSA簽名算法為例)。同時(shí)約定一個(gè)名稱(callerID),以便在原系統(tǒng)中來唯一標(biāo)識(shí)調(diào)用方系統(tǒng)。
2. 頒發(fā)非對(duì)稱密鑰對(duì)
簽名算法約定后之后,原系統(tǒng)會(huì)為每一個(gè)調(diào)用方系統(tǒng)專門生成一個(gè)專屬的非對(duì)稱密鑰對(duì)(RSA密鑰對(duì))。私鑰頒發(fā)給調(diào)用方系統(tǒng),公鑰由原系統(tǒng)持有。注意,調(diào)用方系統(tǒng)需要保管好私鑰(存到調(diào)用方系統(tǒng)的后端)。因?yàn)閷?duì)于原系統(tǒng)而言,調(diào)用方系統(tǒng)是消息的發(fā)送方,其持有的私鑰唯一標(biāo)識(shí)了它的身份是原系統(tǒng)受信任的調(diào)用方。調(diào)用方系統(tǒng)的私鑰一旦泄露,調(diào)用方對(duì)原系統(tǒng)毫無信任可言。
3. 生成請(qǐng)求參數(shù)簽名
簽名算法約定后之后,生成簽名的原理如下(活動(dòng)圖)。為了確保生成簽名的處理細(xì)節(jié)與原系統(tǒng)的驗(yàn)簽邏輯是匹配的,原系統(tǒng)一般都提供jar包或者代碼片段給調(diào)用方來生成簽名,否則可能會(huì)因?yàn)橐恍┨幚砑?xì)節(jié)不一致導(dǎo)致生成的簽名是無效的。
4. 請(qǐng)求攜帶簽名調(diào)用
路徑參數(shù)中放入約定好的callerID,請(qǐng)求頭中放入調(diào)用方自己生成的簽名
代碼設(shè)計(jì)
1. 簽名配置類
相關(guān)的自定義yml配置如下。RSA的公鑰和私鑰可以使用hutool的SecureUtil工具類來生成,注意公鑰和私鑰是base64編碼后的字符串
定義一個(gè)配置類來存儲(chǔ)上述相關(guān)的自定義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; /** * 簽名的相關(guān)配置 */ @Data @ConditionalOnProperty(value = "secure.signature.enable", havingValue = "true") // 根據(jù)條件注入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. 簽名管理類
定義一個(gè)管理類,持有上述配置,并暴露生成簽名和校驗(yàn)簽名的方法。
注意,生成的簽名是將字節(jié)數(shù)組進(jìn)行十六進(jìn)制編碼后的字符串,驗(yàn)簽時(shí)需要將簽名字符串進(jìn)行十六進(jìn)制解碼成字節(jié)數(shù)組
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(); } /** * 驗(yàn)簽。驗(yàn)證不通過可能拋出運(yùn)行時(shí)異常CryptoException * * @param callerID 調(diào)用方的唯一標(biāo)識(shí) * @param rawData 原數(shù)據(jù) * @param signature 待驗(yàn)證的簽名(十六進(jìn)制字符串) * @return 驗(yàn)證是否通過 */ public boolean verifySignature(String callerID, String rawData, String signature) { Sign sign = getSignByCallerID(callerID); if (ObjectUtils.isEmpty(sign)) { return false; } // 使用公鑰驗(yàn)簽 return sign.verify(rawData.getBytes(StandardCharsets.UTF_8), HexUtil.decodeHex(signature)); } /** * 生成簽名 * * @param callerID 調(diào)用方的唯一標(biāo)識(shí) * @param rawData 原數(shù)據(jù) * @return 簽名(十六進(jìn)制字符串) */ 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; // 無效的、不受信任的調(diào)用方 } return SecureUtil.sign(keyPairProps.getAlgorithm(), keyPairProps.getPrivateKey(), keyPairProps.getPublicKey()); } /** * 加載非對(duì)稱密鑰對(duì) */ private void loadKeyPairByPath() { // 支持類路徑配置,形如: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())) { 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. 自定義驗(yàn)簽注解
有些接口需要驗(yàn)簽,但有些接口并不需要,為了靈活控制哪些接口需要驗(yàn)簽,自定義一個(gè)驗(yàn)簽注解
import java.lang.annotation.*; /** * 該注解標(biāo)注于Controller類的方法上,表明該請(qǐng)求的參數(shù)需要校驗(yàn)簽名 */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.TYPE}) public @interface VerifySignature { }
4. AOP實(shí)現(xiàn)驗(yàn)簽邏輯
驗(yàn)簽邏輯不能放在攔截器中,因?yàn)閿r截器中不能直接讀取body的輸入流,否則會(huì)造成后續(xù)@RequestBody的參數(shù)解析器讀取不到body。
由于body輸入流只能讀取一次,因此需要使用ContentCachingRequestWrapper包裝請(qǐng)求,緩存body內(nèi)容(見第5點(diǎn)),但是該類的緩存時(shí)機(jī)是在@RequestBody的參數(shù)解析器中。
因此,滿足2個(gè)條件才能獲取到ContentCachingRequestWrapper中的body緩存:
- 接口的入?yún)⒈仨毚嬖贎RequestBody
- 讀取body緩存的時(shí)機(jī)必須在@RequestBody的參數(shù)解析之后,比如說:AOP、Controller層的邏輯內(nèi)。注意攔截器的時(shí)機(jī)是在參數(shù)解析之前的
綜上,注意,標(biāo)注了@VerifySignature注解的controlle層方法的入?yún)⒈仨毚嬖贎RequestBody,AOP中驗(yàn)簽時(shí)才能獲取到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); // 不受信任的調(diào)用方 } // 從請(qǐng)求頭中提取簽名,不存在直接駁回 String signature = request.getHeader(BaseConstant.X_REQUEST_SIGNATURE); if (ObjectUtils.isEmpty(signature)) { throw new ParamInvalidException(SignStatusCode.REQUEST_SIGNATURE_INVALID); // 無效簽名 } // 提取請(qǐng)求參數(shù) String requestParamsStr = extractRequestParams(request); // 驗(yàn)簽。驗(yàn)簽不通過拋出業(yè)務(wù)異常 verifySignature(callerID, requestParamsStr, signature); } @SuppressWarnings("unchecked") public String extractRequestParams(HttpServletRequest request) { // @RequestBody String body = null; // 驗(yàn)簽邏輯不能放在攔截器中,因?yàn)閿r截器中不能直接讀取body的輸入流,否則會(huì)造成后續(xù)@RequestBody的參數(shù)解析器讀取不到body // 由于body輸入流只能讀取一次,因此需要使用ContentCachingRequestWrapper包裝請(qǐng)求,緩存body內(nèi)容,但是該類的緩存時(shí)機(jī)是在@RequestBody的參數(shù)解析器中 // 因此滿足2個(gè)條件才能使用ContentCachingRequestWrapper中的body緩存 // 1. 接口的入?yún)⒈仨毚嬖贎RequestBody // 2. 讀取body緩存的時(shí)機(jī)必須在@RequestBody的參數(shù)解析之后,比如說:AOP、Controller層的邏輯內(nèi)。注意攔截器的時(shí)機(jī)是在參數(shù)解析之前的 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); } /** * 驗(yàn)證請(qǐng)求參數(shù)的簽名 */ 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); // 轉(zhuǎn)換為業(yè)務(wù)異常拋出 } } }
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. 解決請(qǐng)求體只能讀取一次
解決方案就是包裝請(qǐng)求,緩存請(qǐng)求體。SpringBoot也提供了ContentCachingRequestWrapper來解決這個(gè)問題。但是第4點(diǎn)中也詳細(xì)描述了,由于它的緩存時(shí)機(jī),所以它的使用有限制條件。也可以參考網(wǎng)上的方案,自己實(shí)現(xiàn)一個(gè)請(qǐng)求的包裝類來緩存請(qǐng)求體
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); } }
注冊(cè)過濾器
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 { /** * 提取所有的請(qǐng)求參數(shù),按照固定規(guī)則拼接成一個(gè)字符串 * * @param body post請(qǐng)求的請(qǐng)求體 * @param paramMap 路徑參數(shù)(QueryString)。形如:name=zhangsan&age=18&label=A&label=B * @param uriTemplateVarNap 路徑變量(PathVariable)。形如:/{name}/{age} * @return 所有的請(qǐng)求參數(shù)按照固定規(guī)則拼接成的一個(gè)字符串 */ public static String extractRequestParams(@Nullable String body, @Nullable Map<String, String[]> paramMap, @Nullable Map<String, String> 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 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 需要拼接的多個(gè)字符串,可以為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(); } }
代碼地址:GitHub - passerbyYSQ/DemoRepository: 各種開發(fā)小demo
到此這篇關(guān)于SpringBoot實(shí)現(xiàn)接口校驗(yàn)簽名調(diào)用的項(xiàng)目實(shí)踐的文章就介紹到這了,更多相關(guān)SpringBoot 接口校驗(yàn)簽名調(diào)用內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Spring注入Map集合實(shí)現(xiàn)策略模式詳解
這篇文章主要介紹了Spring注入Map集合實(shí)現(xiàn)策略模式詳解,Spring提供通過@Resource注解將相同類型的對(duì)象注入到Map集合,并將對(duì)象的名字作為key,對(duì)象作為value封裝進(jìn)入Map,需要的朋友可以參考下2023-11-11關(guān)于注解FeignClient的使用規(guī)范
這篇文章主要介紹了關(guān)于注解FeignClient的使用規(guī)范,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-03-03Spring mvc 實(shí)現(xiàn)用戶登錄的方法(攔截器)
這篇文章主要介紹了Spring mvc 實(shí)現(xiàn)用戶登錄的方法(攔截器),小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-07-07SpringBoot如何動(dòng)態(tài)改變?nèi)罩炯?jí)別
這篇文章主要介紹了SpringBoot如何動(dòng)態(tài)改變?nèi)罩炯?jí)別,幫助大家更好的理解和使用springboot框架,感興趣的朋友可以了解下2020-12-12struts2如何使用攔截器進(jìn)行用戶權(quán)限控制實(shí)例
本篇文章主要介紹了struts2如何使用攔截器進(jìn)行用戶權(quán)限控制實(shí)例,非常具有實(shí)用價(jià)值,需要的朋友可以參考下2017-05-05