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)一簡(jiǎn)稱為"原系統(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-03
Spring mvc 實(shí)現(xiàn)用戶登錄的方法(攔截器)
這篇文章主要介紹了Spring mvc 實(shí)現(xiàn)用戶登錄的方法(攔截器),小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-07-07
SpringBoot如何動(dòng)態(tài)改變?nèi)罩炯?jí)別
這篇文章主要介紹了SpringBoot如何動(dòng)態(tài)改變?nèi)罩炯?jí)別,幫助大家更好的理解和使用springboot框架,感興趣的朋友可以了解下2020-12-12
struts2如何使用攔截器進(jìn)行用戶權(quán)限控制實(shí)例
本篇文章主要介紹了struts2如何使用攔截器進(jìn)行用戶權(quán)限控制實(shí)例,非常具有實(shí)用價(jià)值,需要的朋友可以參考下2017-05-05

