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.
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 .
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.