SpringBoot interface signature verification practice

Technology Old Boy 2023-10-24 08:23 Published in Guangdong

included in collection

#java215

#Springboot135

#Architecture/System Design 145

Technical Old Boy

Share every bit of technology on the road, focus on back-end technology, and help developers grow. Welcome to pay attention.

54 pieces of original content

No public

1Concept

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.

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. Issuance of 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).

Picture

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.

3 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.

Picture

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.

Note that the generated signature is a hexadecimal-encoded string of byte arrays. When verifying the signature, you need to hexadecimal-decode the signature string 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

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. But it is also described in detail in point 4. Due to its caching timing, its use

There are 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

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

Technical Old Boy

Share every bit of technology on the road, focus on back-end technology, and help developers grow. Welcome to pay attention.

54 pieces of original content

No public