Spring Boot整合Sa-Token快速实现API接口签名安全校验

2024-05-12 12:28

本文主要是介绍Spring Boot整合Sa-Token快速实现API接口签名安全校验,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

在涉及跨系统接口调用时,我们容易碰到以下安全问题:

  • 请求身份被伪造
  • 请求参数被篡改
  • 请求被抓包,然后重放攻击

sa-token框架中的 api-sign 模块将帮你轻松解决以上难题。 (此插件是内嵌到 sa-token-core 核心包中的模块,开发者无需再次引入其它依赖,插件直接可用)

本篇将根据假设的需求场景,循序渐进讲明白跨系统接口调用时必做的几个步骤,以及为什么要有这些步骤的原因。

1、需求场景

假设当前有一个业务需求:用户在A系统参与活动成功后,活动奖励以余额的形式下发到B系统。

2、初始方案:直接裸奔

在不考虑安全问题的情况下,我们很容易完成这个需求:

第一步:在B系统开放一个接口

/*** 为指定用户添加指定余额** @param userId 用户Id* @param money  要添加的余额,单位:分* @return*/
@RequestMapping("addMoney")
public SaResult addMoney(long userId, long money) {// 处理业务// ...// 返回return SaResult.ok();
}

第二步:在A系统使用http工具类调用这个接口

long userId = 10001;
long money = 1000;
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money);

上述代码简单的完成了需求,但是很明显它有一个安全问题: 

B系统开放的接口不仅可以被A系统调用,还可以被其它任何人调用,甚至别人可以本地跑一个for循环调用这个接口,为自己无限充值金额。 

3、方案升级:增加secretKey校验 

为防止B系统开放的接口被陌生人任意调用,我们增加一个 secretKey 参数:

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public SaResult addMoney(long userId, long money, String secretKey) {// 1、先校验 secretKey 参数是否正确,如果不正确直接拒绝响应请求if (!check(secretKey)) {return SaResult.error("无效 secretKey,无法响应请求");}// 2、业务代码// 3、返回return SaResult.ok();
}

由于A系统是我们 “自己人”,所以它可以拿着 secretKey 进行合法请求:

long userId = 10001;
long money = 1000;
String secretKey = "xxxxxxxxxxxxxxxxxxxx";
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money + "&secretKey=" + secretKey);

现在,即使B系统的接口被暴露了,也不会被陌生人任意调用了,安全性得到了一定的保证,但是仍然存在一些问题:

  • 如果请求被抓包,secretKey就会泄露,因为每次请求都在url中明文传输了secretKey参数。
  • 如果请求被抓包,请求的其它参数就可以被任意修改,例如可以将 money 参数修改为 9999999,B系统无法确定参数是否被修改过。

4、方案再升级:使用摘要算法生成参数签名

首先,在A系统不要直接发起请求,而是先计算一个 sign 参数:

// 声明变量
long userId = 10001;
long money = 1000;
String secretKey = "xxxxxxxxxxxxxxxxxxxx";// 计算 sign 参数
String sign = md5("money=" + money + "&userId=" + userId + "&key=" + secretKey);// 将 sign 拼接在请求地址后面
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money + "&sign=" + sign);

注意此处计算签名时,需要将所有参数按照字典顺序依次排列(key除外,挂在最后面),以下所有计算签名时同理,不再赘述。 

然后在B系统接收请求时,使用同样的算法、同样的秘钥,生成sign字符串,与参数中的 sign 值进行比较:

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public SaResult addMoney(long userId, long money, String sign) {// 1、在B系统,使用同样的算法、同样的密钥,计算出sign2,与传入的sign进行比对String sign2 = md5("money=" + money + "&userId=" + userId + "&key=" + secretKey);if (!sign2.equals(sign)) {return SaResult.error("无效 sign,无法响应请求");}// 2、业务代码// 3、返回return SaResult.ok();
}

因为sign的值是由userId、money、secretKey三个参数共同决定的,所以只要有一个参数不一致,就会造成最终生成sign也是不一致的,所以根据比对结果:

  • 如果 sign 一致,说明这是个合法请求。
  • 如果 sign 不一致,说明发起请求的客户端秘钥不正确,或者请求参数被篡改过,是个不合法请求。

此方案优点: 

  • 不在 url 中直接传递 secretKey 参数了,避免了泄露风险。
  • 由于 sign 参数的限制,请求中的参数也不可被篡改,B 系统可放心的使用这些参数。

此方案仍然存在以下缺陷: 

被抓包后,请求可以被无限重放,B 系统无法判断请求是真正来自于 A 系统发出的,还是被抓包后重放的。

5、方案再再升级:追加 nonce 随机字符串

首先,在A系统发起调用前,追加一个 nonce 参数,一起参与到签名中:

// 声明变量
long userId = 10001;
long money = 1000;
String nonce = SaFoxUtil.getRandomString(32); // 随机32位字符串
String secretKey = "xxxxxxxxxxxxxxxxxxxx";// 计算 sign 参数
String sign = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);// 将 sign 拼接在请求地址后面
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money + "nonce=" + nonce + "&sign=" + sign);

然后在B系统接收请求时,也把 nonce 参数加进去生成 sign 字符串,进行比较:

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public SaResult addMoney(long userId, long money, String nonce, String sign) {// 1、检查此 nonce 是否已被使用过了if (CacheUtil.get("nonce_" + nonce) != null) {return SaResult.error("此 nonce 已被使用过了,请求无效");}// 2、验证签名String sign2 = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);if (!sign2.equals(sign)) {return SaResult.error("无效 sign,无法响应请求");}// 3、将 nonce 记入缓存,防止重复使用CacheUtil.set("nonce_" + nonce, "1");// 4、业务代码// ...// 5、返回return SaResult.ok();
}

代码分析:

  • 为方便理解,我们先看第 3 步:此处在校验签名成功后,将 nonce 随机字符串记入缓存中。
  • 再看第 1 步:每次请求进来,先查看一下缓存中是否已经记录了这个随机字符串,如果是,则立即返回无效请求

这两步的组合,保证了一个 nonce 随机字符串只能被使用一次,如果请求被抓包后重放,是无法通过 nonce 校验的。 

至此,问题似乎已经被解决了吗?

别急,我们还有一个问题没有考虑:这个nonce字符串在缓存中应该被保存多久呢?

  • 保存15分钟?那抓包的人只需要等待15分钟,你的 nonce 记录在缓存中消失,请求就可以被重放了。
  • 那保存24小时?保存一周?保存半个月?好像无论保存多久,都无法从根本上解决这个问题。

你可能会想到,那我永久保存吧。这样确实能解决问题,但显然服务器承载不了这么做,即使再微小的数据量,在时间的累加下,也总一天会超出服务器能够承载的上限。 

6、方案再再再升级:追加 timestamp 时间戳

我们可以再追加一个 timestamp 时间戳参数,将请求的有效性限定在一个有限时间范围内,例如 15分钟。

首先,在A系统追加 timestamp 参数:

// 声明变量
long userId = 10001;
long money = 1000;
String nonce = SaFoxUtil.getRandomString(32); // 随机32位字符串
long timestamp = System.currentTimeMillis(); // 随机32位字符串
String secretKey = "xxxxxxxxxxxxxxxxxxxx";// 计算 sign 参数
String sign = md5("money=" + money + "&nonce=" + nonce + "&timestamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey);// 将 sign 拼接在请求地址后面
String res = HttpUtil.request("http://b.com/api/addMoney" +"?userId=" + userId + "&money=" + money + "&nonce=" + nonce + "&timestamp=" + timestamp + "&sign=" + sign);

在B系统检测这个 timestamp 是否超出了允许的范围:

    // 为指定用户添加指定余额@RequestMapping("addMoney")public SaResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {// 1、检查 timestamp 是否超出允许的范围(此处假定最大允许15分钟差距)long timestampDisparity = System.currentTimeMillis() - timestamp; // 实际的时间差if (timestampDisparity > 1000 * 60 * 15) {return SaResult.error("timestamp 时间差超出允许的范围,请求无效");}// 2、检查此 nonce 是否已被使用过了// 代码同上,不再赘述// 3、验证签名// 代码同上,不再赘述// 4、将 nonce 记入缓存,ttl 有效期和 allowDisparity 允许时间差一致CacheUtil.set("nonce_" + nonce, "1", 1000 * 60 * 15);// 5、业务代码 ...// 6、返回return SaResult.ok();}

至此,抓包者: 

  • 如果在15分钟内重放攻击,nonce 参数不答应:缓存中可以查出 nonce 值,直接拒绝响应请求。
  • 如果在15分钟后重放攻击,timestamp 参数不答应:超出了允许的 timestamp 时间差,直接拒绝响应请求。

7、服务器的时钟差异造成安全问题 

以上的代码,均假设A系统服务器与B系统服务器的时钟一致,才可以正常完成安全校验,但在实际的开发场景中,有些服务器会存在时钟不准确的问题。

假设A服务器与B服务器的时钟差异为10分钟,即:在A服务器为8:00的时候,B服务器为7:50。

1、A系统发起请求,其生成的时间戳也是代表 8:00。

2、B系统接受到请求后,完成业务处理,此时 nonce 的 ttl 为 15分钟,到期时间为 7:50 + 15分 = 8:05。

3、8:05后,nonce 缓存消失,抓包者重放请求攻击

  •  timestamp 校验通过:因为时间戳差距仅有 8.05 - 8.00 = 5分钟,小于 15 分钟,校验通过。
  • nonce 校验通过:因为此时 nonce 缓存已经消失,可以通过校验。
  • sign 校验通过:因为这本来就是由 A 系统构建的一个合法签名。

此时由于时间差问题,已完成重放攻击。 

要解决上述问题,有两种方案:

  • 方案一:修改服务器时钟,使两个服务器时钟保持一致。

  • 方案二:在代码层面兼容时钟不一致的场景。

要采用方案一的处理方式可自行搜索一下同步时钟的方法,在此暂不赘述,此处详细阐述一下方案二。 

我们只需简单修改一下,B系统校验参数的代码即可:

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public SaResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {// 1、检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp);if (timestampDisparity > 1000 * 60 * 15) {return SaResult.error("timestamp 时间差超出允许的范围,请求无效");}// 2、检查此 nonce 是否已被使用过了// 代码同上,不再赘述// 3、验证签名// 代码同上,不再赘述// 4、将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 )CacheUtil.set("nonce_" + nonce, "1", (1000 * 60 * 15) * 2);// 5、业务代码 ...// 6、返回return SaResult.ok();
}

8、最终版方案

A 系统(发起请求端):

// 声明变量
long userId = 10001;
long money = 1000;
String nonce = SaFoxUtil.getRandomString(32); // 随机32位字符串
long timestamp = System.currentTimeMillis(); // 当前时间戳
String secretKey = "xxxxxxxxxxxxxxxxxxxx";// 计算 sign 参数
String sign = md5("money=" + money + "&nonce=" + nonce + "&timestamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey);// 将 sign 拼接在请求地址后面
String res = HttpUtil.request("http://b.com/api/addMoney" +"?userId=" + userId + "&money=" + money + "&nonce=" + nonce + "&timestamp=" + timestamp + "&sign=" + sign);

B 系统(接收请求端): 

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public SaResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {// 1、检查 timestamp 是否超出允许的范围long allowDisparity = 1000 * 60 * 15;    // 允许的时间差:15分钟long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp); // 实际的时间差if (timestampDisparity > allowDisparity) {return SaResult.error("timestamp 时间差超出允许的范围,请求无效");}// 2、检查此 nonce 是否已被使用过了if (CacheUtil.get("nonce_" + nonce) != null) {return SaResult.error("此 nonce 已被使用过了,请求无效");}// 3、验证签名String sign2 = md5("money=" + money + "&nonce=" + nonce + "&timestamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey);if (!sign2.equals(sign)) {return SaResult.error("无效 sign,无法响应请求");}// 4、将 nonce 记入缓存,防止重复使用,注意此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2CacheUtil.set("nonce_" + nonce, "1", allowDisparity * 2);// 5、业务代码 ...// 6、返回return SaResult.ok();
}

9、使用 Sa-Token 框架完成 API 参数签名

接下来步入正题,使用 Sa-Token 框架内置的 sign 模块,方便的完成 API 签名创建、校验等步骤:

  • 不限制请求的参数数量,方便组织业务需求代码。
  • 自动补全 nonce、timestamp 参数,省时省力。
  • 自动构建签名,并序列化参数为字符串。
  • 一句代码完成 nonce、timestamp、sign 的校验,防伪造请求调用、防参数篡改、防重放攻击。

9.1、引入依赖

api-sign 模块已内嵌到核心包,只需要引入 sa-token 本身依赖即可:(请求发起端和接收端都需要引入)

<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot-starter</artifactId><version>1.37.0</version>
</dependency>

Sa-Token 权限认证,在线文档:

https://sa-token.cc

9.2、配置秘钥

请求发起端和接收端需要配置一个相同的秘钥,在 application.yml 中配置:

sa-token: sign:# API 接口签名秘钥 (随便乱摁几个字母即可)secret-key: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor

9.3、请求发起端构建签名 

import cn.dev33.satoken.sign.SaSignUtil;
import cn.hutool.http.HttpUtil;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;import java.util.LinkedHashMap;
import java.util.Map;@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class DemoTest {@Testpublic void addMoney() {// 请求地址String url = "http://127.0.0.1:10086/demo/addMoney";// 请求参数Map<String, Object> paramMap = new LinkedHashMap<>();paramMap.put("userId", 10001);paramMap.put("money", 1000);// 更多参数,不限制数量...// 补全 timestamp、nonce、sign 参数,并序列化为 kv 字符串String paramStr = SaSignUtil.addSignParamsAndJoin(paramMap);// 将参数字符串拼接在请求地址后面url += "?" + paramStr;// 发送请求String res = HttpUtil.get(url);// 根据返回值做后续处理System.out.println("server 端返回信息:" + res);}
}

9.4、请求接受端校验签名

import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.sign.SaSignUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;@RestController
@RequestMapping("/demo")
public class DemoController {// 为指定用户添加指定余额@GetMapping("addMoney")public SaResult addMoney(long userId, long money) {// 1、校验请求中的签名SaSignUtil.checkRequest(SaHolder.getRequest());// 2、校验通过,处理业务System.out.println("userId=" + userId);System.out.println("money=" + money);// 3、返回return SaResult.ok();}
}

如上代码便可简单方便的完成 API 接口参数签名校验,当请求端的秘钥不对,或者请求参数被篡改、请求被重放时,均无法通过 SaSignUtil.checkRequest 校验。 

{"timestamp": "2024-05-12T04:41:14.237+0000","status": 500,"error": "Internal Server Error","message": "无效签名:f089c163b2158976f6db37f50998564f","path": "/demo/addMoney"
}

这篇关于Spring Boot整合Sa-Token快速实现API接口签名安全校验的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/982626

相关文章

在Ubuntu上部署SpringBoot应用的操作步骤

《在Ubuntu上部署SpringBoot应用的操作步骤》随着云计算和容器化技术的普及,Linux服务器已成为部署Web应用程序的主流平台之一,Java作为一种跨平台的编程语言,具有广泛的应用场景,本... 目录一、部署准备二、安装 Java 环境1. 安装 JDK2. 验证 Java 安装三、安装 mys

Springboot的ThreadPoolTaskScheduler线程池轻松搞定15分钟不操作自动取消订单

《Springboot的ThreadPoolTaskScheduler线程池轻松搞定15分钟不操作自动取消订单》:本文主要介绍Springboot的ThreadPoolTaskScheduler线... 目录ThreadPoolTaskScheduler线程池实现15分钟不操作自动取消订单概要1,创建订单后

JAVA中整型数组、字符串数组、整型数和字符串 的创建与转换的方法

《JAVA中整型数组、字符串数组、整型数和字符串的创建与转换的方法》本文介绍了Java中字符串、字符数组和整型数组的创建方法,以及它们之间的转换方法,还详细讲解了字符串中的一些常用方法,如index... 目录一、字符串、字符数组和整型数组的创建1、字符串的创建方法1.1 通过引用字符数组来创建字符串1.2

python使用watchdog实现文件资源监控

《python使用watchdog实现文件资源监控》watchdog支持跨平台文件资源监控,可以检测指定文件夹下文件及文件夹变动,下面我们来看看Python如何使用watchdog实现文件资源监控吧... python文件监控库watchdogs简介随着Python在各种应用领域中的广泛使用,其生态环境也

el-select下拉选择缓存的实现

《el-select下拉选择缓存的实现》本文主要介绍了在使用el-select实现下拉选择缓存时遇到的问题及解决方案,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的... 目录项目场景:问题描述解决方案:项目场景:从左侧列表中选取字段填入右侧下拉多选框,用户可以对右侧

SpringCloud集成AlloyDB的示例代码

《SpringCloud集成AlloyDB的示例代码》AlloyDB是GoogleCloud提供的一种高度可扩展、强性能的关系型数据库服务,它兼容PostgreSQL,并提供了更快的查询性能... 目录1.AlloyDBjavascript是什么?AlloyDB 的工作原理2.搭建测试环境3.代码工程1.

Java调用Python代码的几种方法小结

《Java调用Python代码的几种方法小结》Python语言有丰富的系统管理、数据处理、统计类软件包,因此从java应用中调用Python代码的需求很常见、实用,本文介绍几种方法从java调用Pyt... 目录引言Java core使用ProcessBuilder使用Java脚本引擎总结引言python

SpringBoot操作spark处理hdfs文件的操作方法

《SpringBoot操作spark处理hdfs文件的操作方法》本文介绍了如何使用SpringBoot操作Spark处理HDFS文件,包括导入依赖、配置Spark信息、编写Controller和Ser... 目录SpringBoot操作spark处理hdfs文件1、导入依赖2、配置spark信息3、cont

springboot整合 xxl-job及使用步骤

《springboot整合xxl-job及使用步骤》XXL-JOB是一个分布式任务调度平台,用于解决分布式系统中的任务调度和管理问题,文章详细介绍了XXL-JOB的架构,包括调度中心、执行器和Web... 目录一、xxl-job是什么二、使用步骤1. 下载并运行管理端代码2. 访问管理页面,确认是否启动成功

Java中的密码加密方式

《Java中的密码加密方式》文章介绍了Java中使用MD5算法对密码进行加密的方法,以及如何通过加盐和多重加密来提高密码的安全性,MD5是一种不可逆的哈希算法,适合用于存储密码,因为其输出的摘要长度固... 目录Java的密码加密方式密码加密一般的应用方式是总结Java的密码加密方式密码加密【这里采用的