SpringBoot 接口签名校验实践
👉 这是一个或许对你有用的社群
🐱 一对一交流/面试小册/简历优化/求职解惑,欢迎加入「芋道快速开发平台」知识星球。下面是星球提供的部分资料:
《项目实战(视频)》:从书中学,往事上“练” 《互联网高频面试题》:面朝简历学习,春暖花开 《架构 x 系统设计》:摧枯拉朽,掌控面试高频场景题 《精进 Java 学习指南》:系统学习,互联网主流技术栈 《必读 Java 源码专栏》:知其然,知其所以然
👉这是一个或许对你有用的开源项目
国产 Star 破 10w+ 的开源项目,前端包括管理后台 + 微信小程序,后端支持单体和微服务架构。
功能涵盖 RBAC 权限、SaaS 多租户、数据权限、商城、支付、工作流、大屏报表、微信公众号等等功能:
Boot 地址:https://gitee.com/zhijiantianya/ruoyi-vue-pro Cloud 地址:https://gitee.com/zhijiantianya/yudao-cloud 视频教程:https://doc.iocoder.cn
1概念
开放接口
开放接口是指不需要登录凭证就允许被第三方系统调用的接口。为了防止开放接口被恶意调用,开放接口一般都需要验签才能被调用。提供开放接口的系统下面统一简称为"原系统"。
验签
验签是指第三方系统在调用接口之前,需要按照原系统的规则根据所有请求参数生成一个签名(字符串),在调用接口时携带该签名。原系统会验证签名的有效性,只有签名验证有效才能正常调用接口,否则请求会被驳回。
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/ruoyi-vue-pro 视频教程:https://doc.iocoder.cn/video/
2接口验签调用流程
1. 约定签名算法
第三方系统作为调用方,需要与原系统协商约定签名算法(下面以SHA256withRSA
签名算法为例)。同时约定一个名称(callerID),以便在原系统中来唯一标识调用方系统。
2. 颁发非对称密钥对
签名算法约定后之后,原系统会为每一个调用方系统专门生成一个专属的非对称密钥对(RSA密钥对)。私钥颁发给调用方系统,公钥由原系统持有。
注意,调用方系统需要保管好私钥(存到调用方系统的后端)。因为对于原系统而言,调用方系统是消息的发送方,其持有的私钥唯一标识了它的身份是原系统受信任的调用方。调用方系统的私钥一旦泄露,调用方对原系统毫无信任可言。
3. 生成请求参数签名
签名算法约定后之后,生成签名的原理如下(活动图)。
为了确保生成签名的处理细节与原系统的验签逻辑是匹配的,原系统一般都提供jar包或者代码片段给调用方来生成签名,否则可能会因为一些处理细节不一致导致生成的签名是无效的。
4. 请求携带签名调用
路径参数中放入约定好的callerID,请求头中放入调用方自己生成的签名
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/yudao-cloud 视频教程:https://doc.iocoder.cn/video/
3代码设计
1. 签名配置类
相关的自定义yml配置如下。RSA的公钥和私钥可以使用hutool的SecureUtil工具类来生成,注意公钥和私钥是base64编码后的字符串
定义一个配置类来存储上述相关的自定义yml配置
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;
/**
* 签名的相关配置
*/
@Data
@ConditionalOnProperty(value = "secure.signature.enable", havingValue = "true") // 根据条件注入bean
@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. 签名管理类
定义一个管理类,持有上述配置,并暴露生成签名和校验签名的方法。
注意,生成的签名是将字节数组进行十六进制编码后的字符串,验签时需要将签名字符串进行十六进制解码成字节数组
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();
}
/**
* 验签。验证不通过可能抛出运行时异常CryptoException
*
* @param callerID 调用方的唯一标识
* @param rawData 原数据
* @param signature 待验证的签名(十六进制字符串)
* @return 验证是否通过
*/
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));
}
/**
* 生成签名
*
* @param callerID 调用方的唯一标识
* @param rawData 原数据
* @return 签名(十六进制字符串)
*/
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; // 无效的、不受信任的调用方
}
return SecureUtil.sign(keyPairProps.getAlgorithm(), keyPairProps.getPrivateKey(), keyPairProps.getPublicKey());
}
/**
* 加载非对称密钥对
*/
private void loadKeyPairByPath() {
// 支持类路径配置,形如:classpath:secure/public.txt
// 公钥和私钥都是base64编码后的字符串
signatureProps.getKeyPair()
.forEach((key, keyPairProps) -> {
// 如果配置了XxxKeyPath,则优先XxxKeyPath
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. 自定义验签注解
有些接口需要验签,但有些接口并不需要,为了灵活控制哪些接口需要验签,自定义一个验签注解
import java.lang.annotation.*;
/**
* 该注解标注于Controller类的方法上,表明该请求的参数需要校验签名
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface VerifySignature {
}
4. AOP实现验签逻辑
验签逻辑不能放在拦截器中,因为拦截器中不能直接读取body的输入流,否则会造成后续@RequestBody
的参数解析器读取不到body。
由于body输入流只能读取一次,因此需要使用ContentCachingRequestWrapper
包装请求,缓存body内容(见第5点),但是该类的缓存时机是在@RequestBody
的参数解析器中。
因此,满足2个条件才能获取到ContentCachingRequestWrapper
中的body缓存:
接口的入参必须存在 @RequestBody
读取body缓存的时机必须在 @RequestBody
的参数解析之后,比如说:AOP、Controller层的逻辑内。注意拦截器的时机是在参数解析之前的
综上,标注了@VerifySignature
注解的controlle层方法的入参必须存在@RequestBody
,AOP中验签时才能获取到body的缓存!
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() && (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) {
// @RequestBody
String body = null;
// 验签逻辑不能放在拦截器中,因为拦截器中不能直接读取body的输入流,否则会造成后续@RequestBody的参数解析器读取不到body
// 由于body输入流只能读取一次,因此需要使用ContentCachingRequestWrapper包装请求,缓存body内容,但是该类的缓存时机是在@RequestBody的参数解析器中
// 因此满足2个条件才能使用ContentCachingRequestWrapper中的body缓存
// 1. 接口的入参必须存在@RequestBody
// 2. 读取body缓存的时机必须在@RequestBody的参数解析之后,比如说:AOP、Controller层的逻辑内。注意拦截器的时机是在参数解析之前的
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);
}
/**
* 验证请求参数的签名
*/
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); // 转换为业务异常抛出
}
}
}
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() && (requestMapping() || postMapping() || getMapping() || putMapping() || deleteMapping())")
default void apiMethod() {
}
}
5. 解决请求体只能读取一次
解决方案就是包装请求,缓存请求体。SpringBoot也提供了ContentCachingRequestWrapper
来解决这个问题。但是第4点中也详细描述了,由于它的缓存时机,所以它的使用有限制条件。也可以参考网上的方案,自己实现一个请求的包装类来缓存请求体
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 && !(request instanceof ContentCachingRequestWrapper)) {
requestWrapper = new ContentCachingRequestWrapper(request);
}
filterChain.doFilter(requestWrapper, response);
}
}
注册过滤器
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. 自定义工具类
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 {
/**
* 提取所有的请求参数,按照固定规则拼接成一个字符串
*
* @param body post请求的请求体
* @param paramMap 路径参数(QueryString)。形如:name=zhangsan&age=18&label=A&label=B
* @param uriTemplateVarNap 路径变量(PathVariable)。形如:/{name}/{age}
* @return 所有的请求参数按照固定规则拼接成的一个字符串
*/
public static String extractRequestParams(@Nullable String body, @Nullable Map<String, String[]> paramMap,
@Nullable Map<String, String> uriTemplateVarNap) {
// body: { userID: "xxx" }
// 路径参数
// name=zhangsan&age=18&label=A&label=B
// => ["name=zhangsan", "age=18", "label=A,B"]
// => name=zhangsan&age=18&label=A,B
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("&"));
}
// 路径变量
// /{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&age=18&label=A,B#zhangsan,18
return joinStr("#", body, paramStr, uriVarStr);
}
/**
* 使用指定分隔符,拼接字符串
*
* @param delimiter 分隔符
* @param strs 需要拼接的多个字符串,可以为null
* @return 拼接后的新字符串
*/
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) && i < strs.length - 1 && !ObjectUtils.isEmpty(strs[i + 1])) {
sbd.append(delimiter);
}
}
return sbd.toString();
}
}
本文代码
https://github.com/passerbyYSQ/DemoRepository
欢迎加入我的知识星球,全面提升技术能力。
👉 加入方式,“长按”或“扫描”下方二维码噢:
星球的内容包括:项目实战、面试招聘、源码解析、学习路线。
文章有帮助的话,在看,转发吧。
谢谢支持哟 (*^__^*)
微信扫码关注该文公众号作者