How does the Spring Boot project elegantly implement interface signature verification?

1 Concept

1. Open interface

Open interfaces refer to interfaces that are allowed to be called by third-party systems without requiring login credentials. In order to prevent open interfaces from being called maliciously, open interfaces generally require signature verification before they can be called. Systems that provide open interfaces are collectively referred to as “original systems” below.

2. Signature verification

Signature verification means that before calling the interface, the third-party system needs to generate a signature (string) based on all request parameters according to the rules of the original system, and carry the signature when calling the interface. The original system will verify the validity of the signature. Only if the signature verification is valid can the interface be called normally, otherwise the request will be rejected.

2 Interface signature verification calling process

1. Agreed signature algorithm

As the caller, the third-party system needs to negotiate the signature algorithm with the original system (the SHA256withRSA signature algorithm is used as an example below). At the same time, a name (callerID) is agreed upon to uniquely identify the calling system in the original system.

2. Issuing an asymmetric key pair

After the signature algorithm is agreed upon, the original system will generate an exclusive asymmetric key pair (RSA key pair) for each caller system. The private key is issued to the calling system, and the public key is held by the original system.

Note that the caller system needs to keep the private key (stored in the backend of the caller system). Because for the original system, the caller system is the sender of the message, and the private key it holds uniquely identifies it as a trusted caller of the original system. Once the private key of the caller’s system is leaked, the caller has no trust in the original system.

3. Generate request parameter signature

After the signature algorithm is agreed upon, the principle of generating signatures is as follows (activity diagram).

39f6ebb43ce48fac1285c60f348d1b05.png

In order to ensure that the processing details of generating signatures match the signature verification logic of the original system, the original system generally provides jar packages or code snippets to the caller to generate signatures. Otherwise, the generated signatures may be invalid due to inconsistencies in some processing details. .

4. Request to call with signature

Put the agreed callerID in the path parameter, and put the signature generated by the caller in the request header.

2 Code Design

1. Signature configuration class

The relevant custom yml configuration is as follows. The public and private keys of RSA can be generated using the SecureUtil tool class of hugool. Note that the public and private keys are base64-encoded strings.

4578f880196a727aa26d52c8687340e1.png

Define a configuration class to store the above related custom yml configuration

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;
 
/**
 * Signature related configuration
 */
@Data
@ConditionalOnProperty(value = "secure.signature.enable", havingValue = "true") // Inject beans based on conditions
@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. Signature management class

Define a management class that holds the above configuration and exposes methods for generating signatures and verifying signatures.

Recommended address for fishing:

https://www.yoodb.com/slack-off/home.html

Note that the generated signature is a hexadecimal-encoded string of byte arrays. During signature verification, the signature string needs to be hexadecimal-decoded into a byte array.

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();
    }
 
    /**
     * Verification. Failure to pass verification may throw a runtime exception CryptoException
     *
     * @param callerID unique identifier of the caller
     * @param rawData original data
     * @param signature signature to be verified (hex string)
     * @return whether the verification is passed
     */
    public boolean verifySignature(String callerID, String rawData, String signature) {
        Sign sign = getSignByCallerID(callerID);
        if (ObjectUtils.isEmpty(sign)) {
            return false;
        }
 
        // Use public key to verify signature
        return sign.verify(rawData.getBytes(StandardCharsets.UTF_8), HexUtil.decodeHex(signature));
    }
 
    /**
     * Generate signature
     *
     * @param callerID unique identifier of the caller
     * @param rawData original data
     * @return signature (hex string)
     */
    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; // Invalid, untrusted caller
        }
        return SecureUtil.sign(keyPairProps.getAlgorithm(), keyPairProps.getPrivateKey(), keyPairProps.getPublicKey());
    }
 
    /**
     * Load asymmetric key pair
     */
    private void loadKeyPairByPath() {
        // Support classpath configuration, in the form: classpath:secure/public.txt
        // The public key and private key are both base64 encoded strings
        signatureProps.getKeyPair()
                .forEach((key, keyPairProps) -> {
                    // If XxxKeyPath is configured, XxxKeyPath takes precedence
                    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. Customized signature verification annotations

Some interfaces require signature verification, but some interfaces do not. In order to flexibly control which interfaces require signature verification, customize a signature verification annotation.

import java.lang.annotation.*;
 
/**
 * This annotation is marked on the method of the Controller class, indicating that the parameters of the request need to be verified for signature
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface VerifySignature {
}

4. AOP implements signature verification logic

The signature verification logic cannot be placed in the interceptor, because the interceptor cannot directly read the input stream of the body, otherwise the subsequent @RequestBody parameter parser will not be able to read the body.

Since the body input stream can only be read once, you need to use ContentCachingRequestWrapper to wrap the request and cache the body content (see point 5), but the cache timing of this class is in @RequestBody in the parameter parser.

Therefore, two conditions must be met to obtain the body cache in ContentCachingRequestWrapper:

  • The input parameters of the interface must exist @RequestBody

  • The timing of reading the body cache must be after the parameters of @RequestBody are parsed, for example, within the logic of the AOP and Controller layers. Note that the timing of the interceptor is before parameter parsing

  • In addition, we recommend the public account Java Selection, respond to Java interviews, obtain online interview materials, and support answering questions anytime and anywhere.

To sum up, the input parameters of the control layer method annotated with @VerifySignature must exist in @RequestBody, so that the body cache can be obtained during signature verification in AOP!

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() & amp; & amp; (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); // Untrusted caller
        }
 
        // Extract the signature from the request header, if there is no direct rejection
        String signature = request.getHeader(BaseConstant.X_REQUEST_SIGNATURE);
        if (ObjectUtils.isEmpty(signature)) {
            throw new ParamInvalidException(SignStatusCode.REQUEST_SIGNATURE_INVALID); // Invalid signature
        }
 
        //Extract request parameters
        String requestParamsStr = extractRequestParams(request);
        //Verify signature. If the signature verification fails, a business exception will be thrown.
        verifySignature(callerID, requestParamsStr, signature);
    }
 
    @SuppressWarnings("unchecked")
    public String extractRequestParams(HttpServletRequest request) {
        // @RequestBody
        String body = null;
        // The signature verification logic cannot be placed in the interceptor, because the interceptor cannot directly read the input stream of the body, otherwise the subsequent @RequestBody parameter parser will not be able to read the body.
        // Since the body input stream can only be read once, ContentCachingRequestWrapper needs to be used to wrap the request and cache the body content. However, the cache timing of this class is in the parameter parser of @RequestBody.
        // Therefore, two conditions must be met to use the body cache in ContentCachingRequestWrapper.
        // 1. The input parameter of the interface must exist @RequestBody
        // 2. The timing of reading the body cache must be after the parameters of @RequestBody are parsed, for example: within the logic of the AOP and Controller layers. Note that the timing of the interceptor is before parameter parsing
        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);
    }
 
    /**
     * Verify the signature of request parameters
     */
    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); // Convert to business exception and throw
        }
    }
}
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() & amp; & amp; (requestMapping() || postMapping() || getMapping() || putMapping() || deleteMapping())")
    default void apiMethod() {
    }
}

5. Solve the problem that the request body can only be read once

The solution is to wrap the request and cache the request body. SpringBoot also provides ContentCachingRequestWrapper to solve this problem. However, as also described in detail in point 4, due to its caching timing, its use has restrictions. You can also refer to online solutions and implement a request wrapper class to cache the request body.

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 & amp; & amp; !(request instanceof ContentCachingRequestWrapper)) {
            requestWrapper = new ContentCachingRequestWrapper(request);
        }
        filterChain.doFilter(requestWrapper, response);
    }
}

Register filter

Recommended address for fishing:

https://www.yoodb.com/slack-off/home.html

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. Custom tool class

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 {
    /**
     * Extract all request parameters and concatenate them into a string according to fixed rules
     *
     * @param body The request body of the post request
     * @param paramMap path parameter (QueryString). In the form: name=zhangsan & amp;age=18 & amp;label=A & amp;label=B
     * @param uriTemplateVarNap path variable (PathVariable). In the form: /{name}/{age}
     * @return All request parameters are spliced into a string according to fixed rules
     */
    public static String extractRequestParams(@Nullable String body, @Nullable Map<String, String[]> paramMap,
                                              @Nullable Map<String, String> uriTemplateVarNap) {
        // body: { userID: "xxx" }
 
        // path parameters
        // name=zhangsan & amp;age=18 & amp;label=A & amp;label=B
        // => ["name=zhangsan", "age=18", "label=A,B"]
        // => name=zhangsan & amp;age=18 & amp;label=A,B
        String paramStr = null;
        if (!ObjectUtils.isEmpty(paramMap)) {
            paramStr = paramMap.entrySet().stream()
                    .sorted(Map.Entry.comparingByKey())
                    .map(entry -> {
                        // Make a copy and sort them in ascending lexicographic order
                        String[] sortedValue = Arrays.stream(entry.getValue()).sorted().toArray(String[]::new);
                        return entry.getKey() + "=" + joinStr(",", sortedValue);
                    })
                    .collect(Collectors.joining(" & amp;"));
        }
 
        // path variable
        // /{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 & amp;age=18 & amp;label=A,B#zhangsan,18
        return joinStr("#", body, paramStr, uriVarStr);
    }
 
    /**
     * Use the specified delimiter to concatenate strings
     *
     * @param delimiter delimiter
     * @param strs Multiple strings to be spliced, can be null
     * @return the new string after concatenation
     */
    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) & amp; & amp; i < strs.length - 1 & amp; & amp; !ObjectUtils.isEmpty(strs[i + 1])) {
                sbd.append(delimiter);
            }
        }
        return sbd.toString();
    }
}
Code of this article

https://github.com/passerbyYSQ/DemoRepository

Author: passerbyYSQ

https://blog.csdn.net/qq_43290318/article/details/131516099

If the source of the content published in the public account "Java Selection" is indicated, the copyright belongs to the original source (the content whose copyright cannot be verified or the source is not indicated is from the Internet and is reprinted. The purpose of reprinting is to convey more information. The copyright belongs to the original author. If there is any infringement, please contact us and we will delete it as soon as possible!
Recently, many people have asked if there is a reader exchange group! The method to join is very simple, just select the public account Java and reply "Add group" to join the group!

Java Selected Interview Questions: 3000+ interview questions, including Java basics, concurrency, JVM, threads, MQ series, Redis, Spring series, Elasticsearch, Docker, K8s, Flink, Spark, architecture design, etc., online at any time Refresh the questions!
------ Special recommendation ------
Special recommendation: "Big Shot Notes" is a public account that focuses on sharing the most cutting-edge technology and information, preparing for overtaking in corners, and various open source projects and high-efficiency software. It focuses on digging out good things and is very worthy of everyone's attention. Click on the public account card below to follow.

Click "Read the original text" to learn more exciting content! If the article is helpful, please click to read and forward it! 

The knowledge points of the article match the official knowledge files, and you can further learn related knowledge. Java Skill TreeHomepageOverview 139041 people are learning the system