Backend Development 20 min read

Implementing Open API Signature Verification with Spring Boot and AOP

This article explains the concept of open interfaces and signature verification, outlines the end‑to‑end signing and verification flow, and provides a complete Spring Boot implementation including configuration properties, a signature manager, custom annotation, AOP aspect, request‑caching filter and utility classes, all illustrated with code snippets.

Top Architect
Top Architect
Top Architect
Implementing Open API Signature Verification with Spring Boot and AOP

Concept

Open interfaces are APIs that can be called by third‑party systems without login credentials, but they must be signed to prevent malicious use. The system providing the open interface is referred to as the "original system".

Signature verification (验签) requires the caller to generate a signature string from all request parameters according to the original system's rules; the original system validates the signature before processing the request.

Signature Verification Call Flow

1. Agree on Signature Algorithm

The caller and the original system agree on a signature algorithm, e.g., SHA256withRSA , and a unique caller ID (callerID) to identify the caller.

2. Issue Asymmetric Key Pair

After the algorithm is agreed, the original system generates an RSA key pair for each caller. The private key is given to the caller, and the public key is kept by the original system.

Note: The caller must keep the private key secure; if it leaks, the original system will no longer trust the caller.

3. Generate Request Parameter Signature

The caller uses the agreed algorithm and private key to sign the request parameters. The original system usually provides a JAR or code snippet to ensure the signing process matches its verification logic.

4. Send Request with Signature

The caller includes the agreed callerID in the path parameters and the generated signature in the request header.

Code Design

1. Signature Configuration Class

Custom yml configuration stores RSA keys (base64‑encoded) and the signature algorithm. Example configuration class:

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")
@Component
@ConfigurationProperties("secure.signature")
public class SignatureProps {
    private Boolean enable;
    private Map
keyPair;
    
    @Data
    public static class KeyPairProps {
        private SignAlgorithm algorithm;
        private String publicKeyPath;
        private String publicKey;
        private String privateKeyPath;
        private String privateKey;
    }
}

2. Signature Manager Class

Manages the configuration and provides methods to generate and verify signatures.

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();
    }

    /** Verify signature */
    public boolean verifySignature(String callerID, String rawData, String signature) {
        Sign sign = getSignByCallerID(callerID);
        if (ObjectUtils.isEmpty(sign)) {
            return false;
        }
        return sign.verify(rawData.getBytes(StandardCharsets.UTF_8), HexUtil.decodeHex(signature));
    }

    /** Generate signature */
    public String sign(String callerID, String rawData) {
        Sign sign = getSignByCallerID(callerID);
        if (ObjectUtils.isEmpty(sign)) {
            return null;
        }
        return sign.signHex(rawData);
    }

    private Sign getSignByCallerID(String callerID) {
        SignatureProps.KeyPairProps keyPairProps = signatureProps.getKeyPair().get(callerID);
        if (ObjectUtils.isEmpty(keyPairProps)) {
            return null;
        }
        return SecureUtil.sign(keyPairProps.getAlgorithm(), keyPairProps.getPrivateKey(), keyPairProps.getPublicKey());
    }

    private void loadKeyPairByPath() {
        signatureProps.getKeyPair().forEach((key, kp) -> {
            kp.setPublicKey(loadKeyByPath(kp.getPublicKeyPath()));
            kp.setPrivateKey(loadKeyByPath(kp.getPrivateKeyPath()));
            if (ObjectUtils.isEmpty(kp.getPublicKey()) || ObjectUtils.isEmpty(kp.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. Custom Verification Annotation

Define @VerifySignature to mark controller methods that require signature verification.

import java.lang.annotation.*;

/** Mark controller methods that need signature verification */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface VerifySignature {}

4. AOP Implementation of Verification Logic

The verification cannot be placed in an interceptor because the request body can only be read once. An AOP aspect reads the cached body (via ContentCachingRequestWrapper ) after the @RequestBody resolver.

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.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);
        }
        String signature = request.getHeader(BaseConstant.X_REQUEST_SIGNATURE);
        if (ObjectUtils.isEmpty(signature)) {
            throw new ParamInvalidException(SignStatusCode.REQUEST_SIGNATURE_INVALID);
        }
        String requestParamsStr = extractRequestParams(request);
        verifySignature(callerID, requestParamsStr, signature);
    }

    @SuppressWarnings("unchecked")
    public String extractRequestParams(HttpServletRequest request) {
        String body = null;
        if (request instanceof ContentCachingRequestWrapper) {
            ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request;
            body = new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
        }
        Map
paramMap = request.getParameterMap();
        ServletWebRequest webRequest = new ServletWebRequest(request, null);
        Map
uriTemplateVarMap = (Map
) webRequest.getAttribute(
                HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
        return CommonUtils.extractRequestParams(body, paramMap, uriTemplateVarMap);
    }

    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);
        }
    }
}

5. Solving Single‑Read Request Body

Wrap the request with ContentCachingRequestWrapper in a filter so the body can be read multiple times.

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 {
    @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);
    }
}

6. Registering the 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
requestCachingFilterRegistration(RequestCachingFilter requestCachingFilter) {
        FilterRegistrationBean
bean = new FilterRegistrationBean<>(requestCachingFilter);
        bean.setOrder(1);
        return bean;
    }
}

7. Utility for Parameter Extraction

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 with a fixed rule */
    public static String extractRequestParams(@Nullable String body, @Nullable Map
paramMap,
                                          @Nullable Map
uriTemplateVarMap) {
        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("&"));
        }
        String uriVarStr = null;
        if (!ObjectUtils.isEmpty(uriTemplateVarMap)) {
            uriVarStr = joinStr(",", uriTemplateVarMap.values().stream().sorted().toArray(String[]::new));
        }
        return joinStr("#", body, paramStr, uriVarStr);
    }

    /** Join strings with a delimiter */
    public static String joinStr(String delimiter, @Nullable String... strs) {
        if (ObjectUtils.isEmpty(strs)) {
            return StrUtil.EMPTY;
        }
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < strs.length; i++) {
            if (ObjectUtils.isEmpty(strs[i])) {
                continue;
            }
            sb.append(strs[i].trim());
            if (i < strs.length - 1 && !ObjectUtils.isEmpty(strs[i + 1])) {
                sb.append(delimiter);
            }
        }
        return sb.toString();
    }
}

The article concludes with a link to the full source repository: https://github.com/passerbyYSQ/DemoRepository .

JavaAOPSpring BootAPI securityOpenAPISignature Verification
Top Architect
Written by

Top Architect

Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.