⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 江南一点雨 「江南一点雨」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

有时候我们要把自己的服务暴露给第三方去调用,为了防止接口不被授权访问,我们一般采用接口签名的方式去保护接口。

接下来和大家聊一聊这个话题。

一 场景分析

什么时候需要接口签名?

接口签名是一种重要的安全机制,用于确保 API 请求的真实性、数据的完整性以及防止重放攻击。

当我们需要保护 API 接口不被未授权访问、确保传输数据在过程中不被篡改,或者需要防止恶意用户利用 API 进行攻击时,就需要使用接口签名。

松哥来举几个需要做接口签名的例子:

  1. 开放 API 给第三方使用:当你的 API 需要对外开放,让第三方应用或服务调用时,接口签名可以验证请求方的身份,确保只有拥有有效签名的请求才能被接受。
  2. 数据完整性校验:在数据传输过程中,接口签名可以确保数据不被篡改。通过将请求数据与密钥一起进行哈希运算,生成签名值,接收方收到数据后可以用相同的方法生成签名值进行对比,如果一致则数据未被篡改。
  3. 防止重放攻击:通过在签名中加入时间戳或随机数等动态元素,接口签名可以防止攻击者截获并重复发送有效的 API 请求。
  4. 接口防刷:为了防止接口被恶意调用,通常会采用一些防刷策略,比如限制请求频率、使用验证码等。接口签名可以作为防刷策略的一部分,确保请求的合法性。
  5. 敏感操作验证:对于涉及敏感数据或重要操作的 API,如支付、转账等,接口签名提供了额外的安全保障,确保请求的安全性。
  6. API 安全合规:在某些行业,如金融、医疗等,法律法规可能要求对 API 进行严格的安全控制,接口签名是满足这些合规要求的一种方式。

这里有一个很重要的点,就是我们的接口是暴露给对方服务端调用的,而不是暴露给前端调用的,这样的场景需要做接口签名。

二 签名步骤

一般来说,接口签名的步骤是这样的:

  1. 构造待签名字符串:将请求方法、请求 URI、请求参数(包括查询参数和请求体中的参数)、时间戳等关键信息按照一定的规则拼接成待签名字符串。
  2. 生成签名:使用客户端持有的私钥(或密钥)对待签名字符串进行加密(或哈希运算),生成签名。
  3. 发送请求:将生成的签名作为请求的一部分(如请求头)发送给服务器。
  4. 验证签名:服务器收到请求后,使用相同的规则构造待签名字符串,并使用对应的公钥(或密钥)进行验证。如果签名验证通过,则处理请求;否则,拒绝请求。

实现接口签名时,需要注意密钥管理、时间戳检查、错误处理和日志记录等安全实践。

三 代码实践

接下来,基于 SpringBoot3,松哥来给大家演示一个接口签名案例。

首先我们需要一个签名和验签的工具类。这里我们采用 HmacSHA1 算法。

HmacSHA1 是一种基于 SHA-1 哈希算法的加密哈希消息认证码(Hash-based Message Authentication Code,简称 HMAC)算法。HMAC 是一种用于验证数据完整性和认证消息发送者身份的机制。它结合了加密哈希函数和加密密钥,从而提供了一种安全的方式来确认数据的完整性和真实性。

public class SignUtils {

/**
* 使用 HmacSHA1 算法进行签名
* @param secretKey 密钥
* @param data 数据
* @return
*/
public static String signWithHmacSha1(String secretKey, String data) {

try {
SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA1");
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(signingKey);
return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes("UTF-8")));
} catch (NoSuchAlgorithmException | InvalidKeyException | UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}


/**
* 验证签名
* @param secretKey 密钥
* @param data 数据
* @param hmac 签名
* @return
*/
public static boolean verify(String secretKey, String data, String hmac) {
String calculatedHmac = signWithHmacSha1(secretKey, data);
return calculatedHmac.equals(hmac);
}
}

这个类很简单,一个用来生成签名的方法,这个方法按理说可以封装到 SDK 中给到调用者,或者告诉调用者思路,由调用者自行实现。

第二个方法则是一个签名验证的方法,对用户传来的签名信息进行校验。

接下来我需要一个 App 信息的查询类:

@Service
public class AppService {

private static final Map<String, String> APP_INFO = Map.of("app1", "sign1", "app2", "sign2");

public String getAppKey(String appId) {
return APP_INFO.get(appId);
}
}

这个类的作用是这样的:比如我们想要接入微信公众号后台,我们需要先在微信公众号后台配置我们自己的应用信息,配置完成后,微信公众号会给我们一个 appId 和 appSecret,微信自己会把这两个信息存入到数据库中,将来用户请求来的时候,用户会携带上 appId,但是不会携带 appSecret,微信公众号可以根据用户携带的 appId 去数据库中查询到 appSecret,然后进行验签。

松哥这个案例简化了,直接模拟了两个 appId 和 appSecret 存入到 Map 中,这里提供一个根据 appId 查询 appSecret 的函数。

接下来我们定义一个拦截器,在拦截器中对签名进行验证:

public class SignInterceptor implements HandlerInterceptor {
public final static Logger logger = LoggerFactory.getLogger(SignInterceptor.class);
AppService appService;

public SignInterceptor(AppService appService) {
this.appService = appService;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String appId = request.getHeader("appId");
String timestamp = request.getHeader("timestamp");
String sign = request.getHeader("sign");
if (StringUtils.hasText(appId) && StringUtils.hasText(timestamp) && StringUtils.hasText(sign)) {
if (LocalDateTime.now().compareTo(LocalDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(timestamp)), ZoneId.systemDefault()).plusMinutes(1L)) < 0) {
String originalSign = appId + "-" + appService.getAppKey(appId) + "-" + timestamp;
if (SignUtils.verify(appService.getAppKey(appId), originalSign, sign)) {
return true;
} else {
logger.error("签名验证失败");
}
} else {
logger.error("签名已过期");
}
} else {
logger.error("签名信息不完整");
}
response.setStatus(401);
return false;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
}

请求头中主要有三个信息:

  • 应用 id
  • 时间戳
  • 生成的签名

我们先从请求头中取出来这三个信息,检查是否为空;

然后判断一下这个时间戳,要求必须是 1 分钟之内的请求,这个判断目的主要是为了防止重放攻击。

接下来,根据 appId,以及根据 appId 查询出来的 appSecret,以及 timestamp,组成一个字符串,调用验签方法进行验证,如果验证通过,就说明请求没问题。

最后我们配置一下,让这个拦截器生效:

@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
AppService appService;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SignInterceptor(appService))
//只拦截需要接口验签的请求
.addPathPatterns("/app/**");
}
}

OK,大功告成,接下来就可以写接口进行测试了:

@RestController
public class UserController {

@GetMapping("/hello")
public String hello() {
return "hello";
}
}

至于调用方要如何生成签名呢?松哥也给大家一个例子:

@Autowired
AppService appService;
@Test
void contextLoads() {
String appId = "app1";
long timeMillis = System.currentTimeMillis();
String appSecret = appService.getAppKey(appId);
String sign = SignUtils.signWithHmacSha1(appSecret, appId + "-" + appSecret + "-" + timeMillis);
System.out.println("timeMillis = " + timeMillis);
System.out.println("sign = " + sign);
}

appId 和 appSecret 则是对方从我们这里申请得到的。

postman 上测试时,类似这样:

文章目录
  1. 1. 一 场景分析
  2. 2. 二 签名步骤
  3. 3. 三 代码实践