SpringBoot 接口签名加密

2024/1/24 SpringBoot 框架

# 1、接口安全

  • 开放接口是指无需登录即可第三方访问的接口,也就有可能会被恶意的调用,所以对于这类开放接口是不安全的。
  • 对外开放的前端页面也需要访问开放接口,前端是明文请求,也会造成安全问题。
  • 对于开放接口,需要进行签名加密,防止被恶意调用。以确保调用方是一名合法者。

# 2、接口签名加密

  • 接口签名加密主要是为了防止传递参数被恶意修改的一种手段。
  • 对于前端请求开放接口而言这其实不是一个很安全的方式,毕竟前端是能从客户端中找出秘钥、算法、加密规则,然后就伪装参数请求接口。
  • 只能说对于前端来说签名是手段而不是目的,签名是加大攻击者攻击难度的一种手段,至少是可以抵挡大部分简单的攻击。
  • 对于第三方调用,则可以通过非对称密钥的方式进行签名加密,这样可以防止攻击者伪造请求参数。

# 2.1、签名加密算法

在开始接口签名实例之前,我们先需要了解签名加密算法。

  • 前端的签名:我们通过body&param&密钥作为参数,在Sign加密算法下生成签名,然后将签名和参数一起传递给开放接口:
String requestParamsStr = "body&param&密钥";
// 通过sha256加密生成sign签名
String sign = SecureUtil.sha256(requestParamsStr)
1
2
3
  • 第三方签名:我们通过body&param作为参数,在Sign加密算法(加上非对称密钥)下生成签名,然后将签名和参数一起传递给开放接口:
String publicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDoEuE8VVQREfG2ccO0VROiYsFfJ17mb378yaJpbmZbqrBSgmtte5zxUVzuRYPWDVpwWkx+vopyFvgMGqt62GtxUTwOmgORSv22TGYrcqOxX5Tbb9L6jd0INSw8bYm0cqZ3BfP7W6QIjQ/pPnYLjooiyFbj7mrgA7hwFzVsBZxZQIDAQAB";
String privateKey = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMOgS4TxVVBER8bZxw7RVE6JiwV8nXuZvfvzJomluZluqsFKCa217nPFRXO5Fg9YNWnBaTH6+inIW+Awaq3rYa3FRPA6aA5FK/bZMZityo7FflNtv0vqN3Qg1LDxtibRypncF8/tbpAiND+k+dguOiiLIVuPuauADuHAXNWwFnFlAgMBAAECgYAQGIEGMfYfSdLvC02bzEDnylzYKXnqqMpvL8Eou6xC8c5WY4Fa29iACYfuntiwEAWrCykc3eXV6MCYeFtarn6FkONctgmu4IykgV4MUnFUi5e7UZABXaaNpKXJGAIwED1iAVYD+6TUlZfnj1bFRYqoNuOTVsQ8rPqW/THRO5WKMQJBAMhJ7hTQJL+y9cqe3g+RvLEUrh9n0EAQ2dOv4/hKaCN/LeQMpZls1pRGo5RFKpvG/1mCTv8OZ/CWYI73qnsmMPECQQD6ClmjHKVe/iiXemyj0Rxh82l/IZcAzzK8W80vk71UyzzvGvivVbXIodffdTvLQfnYvsOID3nEasYdKv2us0e1AkAYV4vc8bMVrU1cE9TPNZomN2o2HOrdbm7a4Gynd3uSnNlZ9wOFUwn9OVyWH5XfGt9b5I9vRjPxtIUFuyn4D5sxAkEAtKMZkt81EWVoCdcl+UsuyAzD4FZx8uG9c5qWp5KCK2oQgWTo2DKBe4qAnCzjn7nwOAfI1tjnTWEd8yCF2NooKQJAfwjXQpNoHPT6BwLkplyp59HdIlpEqkpn+9PhqFoUxqRnBxBhxF/QUx3QaiXiVadrmAT5wCcKoT6saVXaUaBkDQ==";

byte[] data = "body&param".getBytes();
Sign sign = SecureUtil.sign(SignAlgorithm.SHA256withRSA,privateKey,publicKey);

//签名
byte[] signed = sign.sign(data);
//验证签名
boolean verify = sign.verify(data, signed);
log.info("verify=====>"+verify);
1
2
3
4
5
6
7
8
9
10
11
  • 非对称密钥生成
// 生成RSA密钥对
KeyPair rsa = SecureUtil.generateKeyPair("RSA");
// 获取公钥和私钥的字节数组
byte[] publicKeyBytes = rsa.getPublic().getEncoded();
byte[] privateKeyBytes = rsa.getPrivate().getEncoded();
// 将字节数组进行Base64编码
String publicKeyBase64 = Base64.encode(publicKeyBytes);
String privateKeyBase64 = Base64.encode(privateKeyBytes);
System.out.println("Public Key Base64: " + publicKeyBase64);
System.out.println("Private Key Base64: " + privateKeyBase64);
1
2
3
4
5
6
7
8
9
10

# 2.2、签名验证流程

接口签名验证流程

# 3、接口签名案例

# 3.1、自定义签名注解

在需要验证签名的接口上,使用自定义的签名注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SignatureVerify {
}
1
2
3
4

# 3.2、AOP拦截验证签名

签名验证的核心还是通过AOP方式进行统一拦截,验证签名是否合法。这里做了两套验证,一个是前端的签名验证,另一个是服务端的签名验证。

  • 引入AOP依赖
<!-- aop -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
    <version>3.2.1</version>
</dependency>
1
2
3
4
5
6
  • 创建签名AOP拦截
@Aspect
@Component
@ConfigurationProperties(prefix = "sign")
@Data
public class SignAspect {

    /**
     * header请求头签名,前端用
     */
    private String WEB_HEADER = "XYGALAXY-SIGN-WEB";

    /**
     * 签名秘钥
     */
    private String WEB_SECRET = "ZZq8C7tWZ9";

    /**
     * header请求头签名,第三方用
     */
    private String SERVICE_HEADER = "XYGALAXY-SIGN-SERVICE";
    /**
     * 服务端公钥
     */
    private String SERVICE_PUBLIC_KEY = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDoEuE8VVQREfG2ccO0VROiYsFfJ17mb378yaJpbmZbqrBSgmtte5zxUVzuRYPWDVpwWkx+vopyFvgMGqt62GtxUTwOmgORSv22TGYrcqOxX5Tbb9L6jd0INSw8bYm0cqZ3BfP7W6QIjQ/pPnYLjooiyFbj7mrgA7hwFzVsBZxZQIDAQAB";

    /**
     * 服务端私钥
     */
    private String SERVICE_PRIVATE_KEY = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMOgS4TxVVBER8bZxw7RVE6JiwV8nXuZvfvzJomluZluqsFKCa217nPFRXO5Fg9YNWnBaTH6+inIW+Awaq3rYa3FRPA6aA5FK/bZMZityo7FflNtv0vqN3Qg1LDxtibRypncF8/tbpAiND+k+dguOiiLIVuPuauADuHAXNWwFnFlAgMBAAECgYAQGIEGMfYfSdLvC02bzEDnylzYKXnqqMpvL8Eou6xC8c5WY4Fa29iACYfuntiwEAWrCykc3eXV6MCYeFtarn6FkONctgmu4IykgV4MUnFUi5e7UZABXaaNpKXJGAIwED1iAVYD+6TUlZfnj1bFRYqoNuOTVsQ8rPqW/THRO5WKMQJBAMhJ7hTQJL+y9cqe3g+RvLEUrh9n0EAQ2dOv4/hKaCN/LeQMpZls1pRGo5RFKpvG/1mCTv8OZ/CWYI73qnsmMPECQQD6ClmjHKVe/iiXemyj0Rxh82l/IZcAzzK8W80vk71UyzzvGvivVbXIodffdTvLQfnYvsOID3nEasYdKv2us0e1AkAYV4vc8bMVrU1cE9TPNZomN2o2HOrdbm7a4Gynd3uSnNlZ9wOFUwn9OVyWH5XfGt9b5I9vRjPxtIUFuyn4D5sxAkEAtKMZkt81EWVoCdcl+UsuyAzD4FZx8uG9c5qWp5KCK2oQgWTo2DKBe4qAnCzjn7nwOAfI1tjnTWEd8yCF2NooKQJAfwjXQpNoHPT6BwLkplyp59HdIlpEqkpn+9PhqFoUxqRnBxBhxF/QUx3QaiXiVadrmAT5wCcKoT6saVXaUaBkDQ==";

    /**
     * 没带签名提示
     */
    private String NO_SIGNATURE = "没有签名认证";

    /**
     * 无效签名提示
     */
    private String INVALID_SIGNATURE = "无效的签名";

    /**
     * 切点 @SignatureVerify
     */
    @Pointcut("execution(@com.xygalaxy.annotation.SignatureVerify * *(..))")
    private void verifySignPointCut() {

    }

    /**
     * 校验
     */
    @Before("verifySignPointCut()")
    public void verify() {
        // 标签类型,默认是WEB
        String signHeaderType = WEB_HEADER;

        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        String signHeader = request.getHeader(WEB_HEADER);

        if(CharSequenceUtil.isBlank(signHeader)){
            signHeader = request.getHeader(SERVICE_HEADER);
            if(CharSequenceUtil.isNotBlank(signHeader)){
                signHeaderType = SERVICE_HEADER;
            }
        }

        // 必须有签名认证,否则不通过
        if (CharSequenceUtil.isBlank(signHeader)) {
            throw new BusinessException(NO_SIGNATURE);
        }

        // 校验签名
        try {
            // 拼接请求参数
            String requestParamsStr = generatedRequestParams(request,signHeaderType);
            // Web验证
            if(WEB_HEADER.equals(signHeaderType)){
                if(!signHeader.equals(SecureUtil.sha256(requestParamsStr))){
                    throw new BusinessException(INVALID_SIGNATURE);
                }
            }else {
                // Service验证
                // 获取签名认证
                Sign sign = SecureUtil.sign(SignAlgorithm.SHA256withRSA,SERVICE_PRIVATE_KEY,SERVICE_PUBLIC_KEY);
                // 验证参数是否和头签名一致
                boolean verifyPass = sign.verify(requestParamsStr.getBytes(StandardCharsets.UTF_8), HexUtil.decodeHex(signHeader));
                if (!verifyPass) {
                    throw new BusinessException(INVALID_SIGNATURE);
                }
            }
        } catch (Throwable throwable) {
            throw new BusinessException(INVALID_SIGNATURE);
        }
    }

    /**
     * 根据request拼接请求参数(body&param&密钥)
     * @param request
     * @return
     * @throws IOException
     */
    private String generatedRequestParams(HttpServletRequest request,String signHeaderType) throws IOException {
        // 拼接请求参数(body&param&密钥)
        StringBuilder requestStringBuilder = new StringBuilder();

        // @RequestBody参数
        if (request instanceof ContentCachingRequestWrapper) {
            String bodyParam = new String(((ContentCachingRequestWrapper) request).getContentAsByteArray(), StandardCharsets.UTF_8);
            if (CharSequenceUtil.isNotBlank(bodyParam)) {
                requestStringBuilder.append(bodyParam).append('#');
            }
        }

        // @RequestParam参数
        Map<String, String[]> requestParameterMap = request.getParameterMap();
        if (!CollectionUtils.isEmpty(requestParameterMap)) {
            requestParameterMap.entrySet()
                    .stream()
                    .sorted(Map.Entry.comparingByKey())
                    .forEach(paramEntry -> {
                        String paramValue = String.join(",", Arrays.stream(paramEntry.getValue()).sorted().toArray(String[]::new));
                        requestStringBuilder.append(paramEntry.getKey()).append("=").append(paramValue).append('#');
                    });
        }

        // @PathVariable
        String[] paths = null;
        ServletWebRequest webRequest = new ServletWebRequest(request, null);
        Map<String, String> uriTemplateVars = (Map<String, String>) webRequest.getAttribute(
                HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
        if (!CollectionUtils.isEmpty(uriTemplateVars)) {
            paths = uriTemplateVars.values().toArray(new String[0]);
            if (ArrayUtil.isNotEmpty(paths)) {
                String pathValues = String.join(",", Arrays.stream(paths).sorted().toArray(String[]::new));
                requestStringBuilder.append(pathValues).append('#');
            }
        }

        // 前端验证拼接签名秘钥,第三方服务端的话用秘钥就行
        if(WEB_HEADER.equals(signHeaderType)){
            requestStringBuilder.append(WEB_SECRET);
        }

        return requestStringBuilder.toString();
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147

# 3.3、Request封装

Request封装是为了解决HttpServletRequest 流数据不可重复读问题

可参考:解决HttpServletRequest 流数据不可重复读 (opens new window)

在某些业务中可能会需要多次读取 HTTP 请求中的参数,比如说前置的 API 签名校验。这个时候我们可能会在拦截器或者过滤器中实现这个逻辑,但是尝试之后就会发现,如果在拦截器中通过 getInputStream() 读取过参数后,在 Controller 中就无法重复读取了,会抛出以下几种异常:

HttpMessageNotReadableException: Required request body is missing
IllegalStateException: getInputStream() can't be called after getReader()
1
2

解决方案:ServletRequest 数据封装

  • 自定义RequestCachingFilter

  • 简单理解就是将第一次请求的request封装到ContentCachingRequestWrapper,然后用ContentCachingRequestWrapper接着去请求后面的流程。
  • ContentCachingRequestWrapper类的作用就是将 InputStream 缓存到 ByteArrayOutputStream 中,通过调用 getContentAsByteArray() 实现流数据的可重复读取。
@Slf4j
public class RequestCachingFilter extends OncePerRequestFilter {

    /**
     * 解决请求体只能读取一次问题
     * @param request     request
     * @param response    response
     * @param filterChain filterChain
     * @throws ServletException ServletException
     * @throws IOException      IOException
     * @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);
        }
        try {
            filterChain.doFilter(requestWrapper, response);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  • RequestCachingFilter过滤器进行注册
@Configuration
public class FilterConfig {
    @Bean
    public RequestCachingFilter requestCachingFilter() {
        return new RequestCachingFilter();
    }

    @Bean
    public FilterRegistrationBean requestCachingFilterRegistration(
            RequestCachingFilter requestCachingFilter) {
        FilterRegistrationBean bean = new FilterRegistrationBean(requestCachingFilter);
        bean.setOrder(1);
        return bean;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 3.4、application.yml配置

以下是签名验证的默认配置,可以通过application.yml配置文件进行修改,如不配置则会按默认值。

# 签名认证
sign:
  no-signature: 没有签名认证            # 没有签名提示
  invalid-signature: 无效的签名         # 无效签名提示
  web-header: XYGALAXY-SIGN-WEB          # web端签名参数名称
  web-secret: ZZq8C7tWZ9                 # web端签名秘钥
  service-header: XYGALAXY-SIGN-SERVICE  # 服务端签名参数名称
  service-public-key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDoEuE8VVQREfG2ccO0VROiYsFfJ17mb378yaJpbmZbqrBSgmtte5zxUVzuRYPWDVpwWkx+vopyFvgMGqt62GtxUTwOmgORSv22TGYrcqOxX5Tbb9L6jd0INSw8bYm0cqZ3BfP7W6QIjQ/pPnYLjooiyFbj7mrgA7hwFzVsBZxZQIDAQAB
  service-private-key: MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMOgS4TxVVBER8bZxw7RVE6JiwV8nXuZvfvzJomluZluqsFKCa217nPFRXO5Fg9YNWnBaTH6+inIW+Awaq3rYa3FRPA6aA5FK/bZMZityo7FflNtv0vqN3Qg1LDxtibRypncF8/tbpAiND+k+dguOiiLIVuPuauADuHAXNWwFnFlAgMBAAECgYAQGIEGMfYfSdLvC02bzEDnylzYKXnqqMpvL8Eou6xC8c5WY4Fa29iACYfuntiwEAWrCykc3eXV6MCYeFtarn6FkONctgmu4IykgV4MUnFUi5e7UZABXaaNpKXJGAIwED1iAVYD+6TUlZfnj1bFRYqoNuOTVsQ8rPqW/THRO5WKMQJBAMhJ7hTQJL+y9cqe3g+RvLEUrh9n0EAQ2dOv4/hKaCN/LeQMpZls1pRGo5RFKpvG/1mCTv8OZ/CWYI73qnsmMPECQQD6ClmjHKVe/iiXemyj0Rxh82l/IZcAzzK8W80vk71UyzzvGvivVbXIodffdTvLQfnYvsOID3nEasYdKv2us0e1AkAYV4vc8bMVrU1cE9TPNZomN2o2HOrdbm7a4Gynd3uSnNlZ9wOFUwn9OVyWH5XfGt9b5I9vRjPxtIUFuyn4D5sxAkEAtKMZkt81EWVoCdcl+UsuyAzD4FZx8uG9c5qWp5KCK2oQgWTo2DKBe4qAnCzjn7nwOAfI1tjnTWEd8yCF2NooKQJAfwjXQpNoHPT6BwLkplyp59HdIlpEqkpn+9PhqFoUxqRnBxBhxF/QUx3QaiXiVadrmAT5wCcKoT6saVXaUaBkDQ==
1
2
3
4
5
6
7
8
9

# 3.5、测试准备

按照正确请求的方式,生成正确的签名

@SneakyThrows
@ResponseResultApi
@RequestMapping("/webSign")
public String webSign(HttpServletRequest request){
    SignAspect signAspect = new SignAspect();
    String params = signAspect.generatedRequestParams(request,"XYGALAXY-SIGN-WEB");
    String webSign = SecureUtil.sha256(params);
    log.info("webSign:{}",webSign);
    return webSign;
}
/** 返回结果:
{
	"data": "7b8bbb01ff612211f75389debab356c703396ef9ef2f96161f865038e27e3634",
	"message": "success",
	"status": "200",
	"timestamp": 1706170609758
}
**/

@SneakyThrows
@ResponseResultApi
@RequestMapping("/serviceSign")
public String serviceSign(HttpServletRequest request){
    SignAspect signAspect = new SignAspect();
    String params = signAspect.generatedRequestParams(request,"XYGALAXY-SIGN-SERVICE");
    // 根据参数、publicKey、privateKey获取签名
    String publicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDoEuE8VVQREfG2ccO0VROiYsFfJ17mb378yaJpbmZbqrBSgmtte5zxUVzuRYPWDVpwWkx+vopyFvgMGqt62GtxUTwOmgORSv22TGYrcqOxX5Tbb9L6jd0INSw8bYm0cqZ3BfP7W6QIjQ/pPnYLjooiyFbj7mrgA7hwFzVsBZxZQIDAQAB";
    String privateKey = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMOgS4TxVVBER8bZxw7RVE6JiwV8nXuZvfvzJomluZluqsFKCa217nPFRXO5Fg9YNWnBaTH6+inIW+Awaq3rYa3FRPA6aA5FK/bZMZityo7FflNtv0vqN3Qg1LDxtibRypncF8/tbpAiND+k+dguOiiLIVuPuauADuHAXNWwFnFlAgMBAAECgYAQGIEGMfYfSdLvC02bzEDnylzYKXnqqMpvL8Eou6xC8c5WY4Fa29iACYfuntiwEAWrCykc3eXV6MCYeFtarn6FkONctgmu4IykgV4MUnFUi5e7UZABXaaNpKXJGAIwED1iAVYD+6TUlZfnj1bFRYqoNuOTVsQ8rPqW/THRO5WKMQJBAMhJ7hTQJL+y9cqe3g+RvLEUrh9n0EAQ2dOv4/hKaCN/LeQMpZls1pRGo5RFKpvG/1mCTv8OZ/CWYI73qnsmMPECQQD6ClmjHKVe/iiXemyj0Rxh82l/IZcAzzK8W80vk71UyzzvGvivVbXIodffdTvLQfnYvsOID3nEasYdKv2us0e1AkAYV4vc8bMVrU1cE9TPNZomN2o2HOrdbm7a4Gynd3uSnNlZ9wOFUwn9OVyWH5XfGt9b5I9vRjPxtIUFuyn4D5sxAkEAtKMZkt81EWVoCdcl+UsuyAzD4FZx8uG9c5qWp5KCK2oQgWTo2DKBe4qAnCzjn7nwOAfI1tjnTWEd8yCF2NooKQJAfwjXQpNoHPT6BwLkplyp59HdIlpEqkpn+9PhqFoUxqRnBxBhxF/QUx3QaiXiVadrmAT5wCcKoT6saVXaUaBkDQ==";
    String serviceSign = SecureUtil.sign(SignAlgorithm.SHA256withRSA, privateKey, publicKey).signHex(params);
    log.info("serviceSign:{}",serviceSign);
    return serviceSign;
}

/** 返回结果:
{
	"data": "834b16a686c5643c713d63838d5e5265dba041f51df8b78555879ba68b08a0f3609b631cc8663775d0ba680362f2dab6a99b8a8f71fca6e09c82fc99ebec0b0527a63eaa621113cbbd8ad993050bd92a49bc051203b6119b3ba3a9230c8d71d3e0a2b07e851d58a05ef411b17f14f03824f4a6aedd1d213c7c8d0893264e7805",
	"message": "success",
	"status": "200",
	"timestamp": 1706170659003
}
**/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

# 3.6、测试签名

加上@SignatureVerify注解,说明该接口需要签名认证。

@ResponseResultApi
@SignatureVerify
@RequestMapping("/test")
public String test(){
    return "test success";
}
1
2
3
4
5
6
  • 没加签名认证

  • 签名错误

  • Web请求接口签名认证

  • Service请求接口签名认证