通过学习之前的博客:
API 接口安全设计:https://blog.csdn.net/BiandanLoveyou/article/details/117487626
信息加密技术(对称加密、非对称加密):https://blog.csdn.net/BiandanLoveyou/article/details/117524716
现在我们来讲解 API 接口增加签名的内容。不像网上讲解的那种那么难理解,不用那么复杂。
如何保证 API 接口的安全性,一般来说,解决办法有以下几种:
1、API 请求使用加签名方式,防止篡改数据。
2、使用Https 协议加密传输。
3、搭建OAuth2.0认证授权平台。
4、使用令牌方式 accessToken。
5、搭建网关实现黑名单和白名单。
我们今天讲解第 1 种。API 接口增加验签功能,可以防止篡改数据。我们验签不通过,就认为数据被篡改,拒绝处理本次请求的数据。
下载本次案例的脚手架:https://pan.baidu.com/s/1bxw5TMJo6vVLFt1kru0JAg 提取码:wetr
本案例中,我们创建一个项目 study-sign,有3个模块:send-server(发送端服务),receive-server(接收端服务),common-core(公共包)。
公共依赖 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.study</groupId>
<artifactId>study-sign</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>send-server</module>
<module>receive-server</module>
<module>common-core</module>
</modules>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.2.RELEASE</version>
</parent>
<dependencies>
<!--Spring boot 集成包-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- SpringBoot整合Web组件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 阿里巴巴 fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
<!-- feignClient 支持 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- apache commons -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
签名的工具类:
package com.study.util;
import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.util.StringUtils;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
/**
* 签名工具类
*/
public class SignUtil {
//要加入签名的字段
private static final String[] strArr = new String[]{"timestamp", "appId", "key"};
/**
* 生成签名
*
* @param appId
* @param key
* @return
* @throws Exception
*/
public static String createSign(String appId, String key) {
if (StringUtils.isEmpty(appId) || StringUtils.isEmpty(key)) {
return null;
}
Long time = System.currentTimeMillis();//当前时间戳
//毫秒数除以5000得到加密的需要参数,精确到5秒内
String timestamp = String.valueOf(time / 5000);
System.out.println("timestamp="+timestamp);
StringBuffer sb = new StringBuffer();
Arrays.sort(strArr);//把需要加密的字符串按照字母排序
for (String str : strArr) {
if ("appId".equals(str)) {
sb.append(str).append("=").append(appId).append("&");
}
if ("key".equals(str)) {
sb.append(str).append("=").append(key).append("&");
}
if ("timestamp".equals(str)) {
sb.append(str).append("=").append(timestamp).append("&");
}
}
//去掉最后一个 & 符号
String data = sb.toString().substring(0, sb.length() - 1);
//使用 MD5 加密,并全部转成大写,得到签名
String sign = DigestUtils.md5Hex(data).toUpperCase();
return sign;
}
/**
* 校验签名是否正确
* @param appId
* @param key
* @param sign
* @return
*/
public static Boolean checkSign(String appId, String key, String sign) {
if (StringUtils.isEmpty(appId) || StringUtils.isEmpty(key) || StringUtils.isEmpty(sign)) {
return false;
}
Long time = System.currentTimeMillis();//当前时间戳
//避免临界值的出现:比如发送方生成签名的时候是5秒,通过网络传输后6秒到达,因此允许误差5-10秒内都有效
Long timestamp = time / 5000;
Long timestamp_sub = timestamp - 1;
System.out.println("timestamp="+timestamp+",timestamp_sub="+timestamp_sub);
String timestamp_1 = String.valueOf(timestamp);
String timestamp_2 = String.valueOf(timestamp_sub);
StringBuffer sb_1 = new StringBuffer();
StringBuffer sb_2 = new StringBuffer();
Arrays.sort(strArr);//把需要加密的字符串按照字母排序
for (String str : strArr) {
if ("appId".equals(str)) {
sb_1.append(str).append("=").append(appId).append("&");
sb_2.append(str).append("=").append(appId).append("&");
}
if ("key".equals(str)) {
sb_1.append(str).append("=").append(key).append("&");
sb_2.append(str).append("=").append(key).append("&");
}
if ("timestamp".equals(str)) {
sb_1.append(str).append("=").append(timestamp_1).append("&");
sb_2.append(str).append("=").append(timestamp_2).append("&");
}
}
//去掉最后一个 & 符号
String data_1 = sb_1.toString().substring(0, sb_1.length() - 1);
String data_2 = sb_2.toString().substring(0, sb_2.length() - 1);
//使用 MD5 加密,并全部转成大写,得到签名
String sign_1 = DigestUtils.md5Hex(data_1).toUpperCase();
String sign_2 = DigestUtils.md5Hex(data_2).toUpperCase();
if(sign.equals(sign_1) || sign.equals(sign_2)){
return true;
}
return false;
}
public static void main(String[] args) throws Exception {
String sign = SignUtil.createSign("good123", "123456");
System.out.println("生成签名:sign=" + sign);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
for (int i = 0; i < 10; i++) {
Long time = System.currentTimeMillis();//当前时间戳
//毫秒数除以5000得到加密的需要参数,精确到5秒内
String timestamp = String.valueOf(time / 5000);
System.out.println("time=" + time + ",除以 5000 timestamp=" + timestamp+",时间:" + sdf.format(new Date()));
Boolean flag = SignUtil.checkSign("good123", "123456", sign);
System.out.println("校验签名:flag=" + flag);
System.out.println("****************************");
Thread.sleep(1000);
}
}
}
说明:①我们加入签名有3个字段{"timestamp", "appId", "key"},当然,可以根据实际情况增加更多,不过需要做非空的判断,就是某个值为空的话不加入签名校验。
②时间戳我们除以 5000(除以1000得到的是秒数),得到误差为±5秒的时间戳。
③Arrays.sort(strArr);//把需要加密的字符串按照字母排序
④校验签名的时候,我们校验了误差为 5~10秒内的签名,避免由于网络传输导致验签不成功,要验签成功还要求双方的机器时钟误差不能太大,因此需要联调。
⑤程序保证请求在5~10秒内都可以验签成功,如图:
发送端服务 send-server:
整体代码结构:
pom.xml 配置,引用 common-core 包,因此需要把 core 包 install 到本地仓库,否则找不到依赖!
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>study-sign</artifactId>
<groupId>com.study</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>send-server</artifactId>
<properties>
<com.study.common-core>1.0-SNAPSHOT</com.study.common-core>
</properties>
<dependencies>
<dependency>
<groupId>com.study</groupId>
<artifactId>common-core</artifactId>
<version>${com.study.common-core}</version>
</dependency>
</dependencies>
</project>
SendTest 类的包层次,需要与启动类的相同,否则报错如下:
main] DEBUG org.springframework.test.context.support.ActiveProfilesUtils - Could not find an 'annotation declaring class' for annotation type [org.springframework.test.context.ActiveProfiles] and class [SendTest]
java.lang.IllegalStateException: Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=...) with your test
at org.springframework.util.Assert.state(Assert.java:73)
SendTest 代码:
package com.study;
import com.alibaba.fastjson.JSONObject;
import com.study.api.SendApi;
import com.study.util.SignUtil;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
/**
* @author biandan
* @description 测试类
* @signature 让天下没有难写的代码
* @create 2021-06-04 下午 9:56
*/
@RunWith(SpringRunner.class)
@SpringBootTest
public class SendTest {
@Value("${sign.appId}")
private String appId;
@Value("${sign.key}")
private String key;
@Autowired
private SendApi sendApi;
@Test
public void testSend(){
String sign = SignUtil.createSign(appId, key);
System.out.println(sign);
String productName = "霸王防脱洗发液";
Float price = 55.55F;
JSONObject jsonObject = new JSONObject();
jsonObject.put("productName",productName);
jsonObject.put("price",price);
String result = sendApi.sendToReceive(sign,jsonObject.toJSONString());
System.out.println("result="+result);
}
}
SendApi 代码:
package com.study.api;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* @author biandan
* @description 发送给接收服务器
* @signature 让天下没有难写的代码
* @create 2021-06-04 下午 11:56
*/
@Component
@FeignClient(url = "http://127.0.0.1:9090",name = "receive-server")
public interface SendApi {
@PostMapping("/fromSendServer")
String sendToReceive(@RequestParam("sign") String sign,@RequestParam("data") String data);
}
说明:需要增加注解 FeignClient 远程调用 receive-server,我们这里不启动 Eureka 注册中心,因此需要配置完整的 URL。
启动类 SendApplication:
package com.study;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* @author biandan
* @description
* @signature 让天下没有难写的代码
* @create 2021-06-04 下午 6:02
*/
@SpringBootApplication
@EnableFeignClients
public class SendApplication {
public static void main(String[] args) {
SpringApplication.run(SendApplication.class,args);
}
}
注意:需要增加注解 @EnableFeignClients,否则报错,无法使用 @FeignClient。
application.yml 配置
server:
port: 8080
# 将SpringBoot项目作为单实例部署调试时,不需要注册到注册中心
eureka:
client:
fetch-registry: false
register-with-eureka: false
spring:
application:
name: receive-server
sign:
appId: good123
key: 123456
receive-server 模块:
其它的配置跟 send-server 的差不多,可以看脚手架。这里只列出 Controller 层
package com.study.controller;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.study.util.SignUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* @author biandan
* @description
* @signature 让天下没有难写的代码
* @create 2021-06-05 上午 12:15
*/
@RestController
public class ReceiveController {
@Value("${sign.appId}")
private String appId;
@Value("${sign.key}")
private String key;
@PostMapping(value = "/fromSendServer")
public String receive(@RequestParam("sign") String sign, @RequestParam("data") String data) {
Boolean flag = SignUtil.checkSign(appId, key, sign);
String result;
if (flag) {
JSONObject jsonObject = JSON.parseObject(data);
String productName = jsonObject.getString("productName");
Float price = Float.parseFloat(jsonObject.getString("price"));
System.out.println("productName=" + productName + ",price=" + price);
result = "验签通过,数据已被处理。";
} else {
result = "数据被篡改,拒绝处理!";
}
System.out.println(result);
return result;
}
}
OK,先启动接收服务 receive-server,再启动发送服务 send-server 的测试方法。
OK,验签通过!
我们在 receive-server 的 Controller 打断点或者线程休眠,模拟超时的情况。
注意 Thread 需要抛出异常。我们让线程休眠 11 秒肯定超时了,然后重启接收服务,继续测试。
超过规定的 5~10秒,验签不通过,判断数据被篡改,拒绝处理。
OK,验签说到这。