0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

SpringBoot 接口签名算法代码设计

jf_ro2CN3Fa 来源:csdn 2023-11-07 15:11 次阅读

1概念

开放接口

开放接口是指不需要登录凭证就允许被第三方系统调用的接口。为了防止开放接口被恶意调用,开放接口一般都需要验签才能被调用。提供开放接口的系统下面统一简称为"原系统"。

验签

验签是指第三方系统在调用接口之前,需要按照原系统的规则根据所有请求参数生成一个签名(字符串),在调用接口时携带该签名。原系统会验证签名的有效性,只有签名验证有效才能正常调用接口,否则请求会被驳回。

2接口验签调用流程

1. 约定签名算法

第三方系统作为调用方,需要与原系统协商约定签名算法(下面以SHA256withRSA签名算法为例)。同时约定一个名称(callerID),以便在原系统中来唯一标识调用方系统。

2. 颁发非对称密钥对

签名算法约定后之后,原系统会为每一个调用方系统专门生成一个专属的非对称密钥对(RSA密钥对)。私钥颁发给调用方系统,公钥由原系统持有。

注意,调用方系统需要保管好私钥(存到调用方系统的后端)。因为对于原系统而言,调用方系统是消息的发送方,其持有的私钥唯一标识了它的身份是原系统受信任的调用方。调用方系统的私钥一旦泄露,调用方对原系统毫无信任可言。

3. 生成请求参数签名

签名算法约定后之后,生成签名的原理如下(活动图)。

abc46c7e-7567-11ee-939d-92fbcf53809c.png

为了确保生成签名的处理细节与原系统的验签逻辑是匹配的,原系统一般都提供jar包或者代码片段给调用方来生成签名,否则可能会因为一些处理细节不一致导致生成的签名是无效的。

4. 请求携带签名调用

路径参数中放入约定好的callerID,请求头中放入调用方自己生成的签名

3代码设计

1. 签名配置类

相关的自定义yml配置如下。RSA的公钥和私钥可以使用hutool的SecureUtil工具类来生成,注意公钥和私钥是base64编码后的字符串

abd6939a-7567-11ee-939d-92fbcf53809c.png

定义一个配置类来存储上述相关的自定义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);
MapuriTemplateVarNap=(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){
FilterRegistrationBeanbean=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,
@NullableMapuriTemplateVarNap){
//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

    文章

    7651

    浏览量

    148547
  • 算法
    +关注

    关注

    23

    文章

    4458

    浏览量

    90766
  • SpringBoot
    +关注

    关注

    0

    文章

    172

    浏览量

    106

原文标题:SpringBoot 接口签名校验实践

文章出处:【微信号:芋道源码,微信公众号:芋道源码】欢迎添加关注!文章转载请注明出处。

收藏 人收藏

    评论

    相关推荐

    基于椭圆曲线算法的数字签名技术研究

    基于椭圆曲线算法的数字签名技术的基本原理及其安全性,展望了公钥密码体制未来的发展方向。【关键词】:椭圆曲线算法;;数字签名;;网络安全【DOI】:CNKI:SUN:GSKJ.0.201
    发表于 04-23 11:29

    SpringBoot知识总结

    SpringBoot干货学习总结
    发表于 08-01 10:40

    怎么学习SpringBoot

    SpringBoot学习之路(X5)- 整合JPA
    发表于 06-10 14:52

    怎样去使用springboot

    怎样去使用springboot呢?学习springboot需要懂得哪些?
    发表于 10-25 07:13

    在哪里可以找到用于导出AN10957上显示的结果的确切CMAC签名/mac代码算法

    得到与 AN10957 不匹配的结果 (mac)。有谁知道我在哪里可以找到用于导出 AN10957 上显示的结果的确切 CMAC 签名/mac 代码算法
    发表于 04-06 06:25

    什么是数字签名算法(DSA)

    什么是数字签名算法(DSA) DSA(Digital Signature Algorithm,数字签名算法,用作数字签名标准的一部分),它
    发表于 04-03 16:01 3431次阅读

    一种错误签名混合筛选算法

    针对分级身份密码( HIBC)批验签过程中的错误签名快速识别问题,设计实现了一种错误签名混合筛选算法。针对HIBC签名算法不完全聚合的特点,
    发表于 12-07 15:36 0次下载

    MuSig签名方案可替代当前比特币的ECDSA签名算法

    当前,比特币和其他区块链普遍采用的是ECDSA签名验证算法。这显然是中本聪在2008年根据当时广泛使用和未授权的数字签名系统所做出的技术决定。然而,ECDSA签名存在一些严重的技术限制
    发表于 02-20 13:34 1437次阅读

    Schnorr签名与比特币多签详细介绍

    数字签名是对签名的数字模拟。最早的数字签名算法是由Rivest、Shamir、Adleman三人于1978年提出的RSA签名
    发表于 04-26 11:28 3106次阅读
    Schnorr<b class='flag-5'>签名</b>与比特币多签详细介绍

    schnorr签名算法相比ECDSA具有哪些优势

    schnorr 签名算法相比 ECDSA 来讲,对于上述的优点,除了尚未标准化之外几乎没有缺点。而且由于两种算法都基于同一个椭圆曲线,整个关于签名的升级成本也是很低的。
    发表于 08-08 11:22 3236次阅读

    基于ECDSA原理的FISCO BCOS交易签名算法解析

    FISCO BCOS交易签名算法基于ECDSA原理进行设计,ECDSA也是比特币和以太坊采用的交易签名算法
    发表于 02-19 16:46 1704次阅读
    基于ECDSA原理的FISCO BCOS交易<b class='flag-5'>签名</b><b class='flag-5'>算法</b>解析

    基于ElGamal数字签名算法的区块链共识算法

    联盟链是一种允许授权节点加入网络的区块链,当存在网络状况不理想等状况时,会出现节点动态加入退出的问题。为此,在环签名理论、 Elgamal数字签名算法与PBFT算法的基础上,提出一种
    发表于 05-19 11:51 10次下载

    SpringBoot如何实现启动过程中执行代码

    目前开发的SpringBoot项目在启动的时候需要预加载一些资源。而如何实现启动过程中执行代码,或启动成功后执行,是有很多种方式可以选择,我们可以在static代码块中实现,也可以在构造方法里实现,也可以使用@PostConst
    的头像 发表于 06-20 17:32 1135次阅读

    什么是 SpringBoot

    本文从为什么要有 `SpringBoot`,以及 `SpringBoot` 到底方便在哪里开始入手,逐步分析了 `SpringBoot` 自动装配的原理,最后手写了一个简单的 `start` 组件,通过实战来体会了 `
    的头像 发表于 04-07 11:28 1037次阅读
    什么是 <b class='flag-5'>SpringBoot</b>?

    一个注解搞定SpringBoot接口防刷

    技术要点:springboot的基本知识,redis基本操作,
    的头像 发表于 11-28 10:46 224次阅读