API 签名认证算法
一. 概述
1. 概念
如果我们自己开发了一套 API 并且将这套 API 开放给他人使用的话, 就必须保证 API 调用的合法性:
API 签名认证算法就是一种用于验证 API 请求的合法性和完整性的安全机制.
2. 作用
给接口使用 API 签名认证算法, 可以增强 API 的安全性, 防止未经授权的用户访问, 防止恶意用户篡改请求数据.
二. 认证算法演变进程
(1) ak + sk 认证
① 基本流程:
一般服务端会给用户签发一个 ak 和一个 sk (其中 ak accessKey 表示用户标识, sk secretKey 表示密钥).
请求方向服务端发起请求的时候携带 ak 和 sk, 服务端收到 ak 和 sk 之后, 根据 ak 从数据库中查出 sk 并作对比, 如果相同则认证通过.
② 遇到的问题:
如果请求在传递过程中被拦截, sk 就会被其他人获取到, 造成密钥泄漏, 进而服务器有可能收到攻击.
③ 如何解决:
严禁 sk 明文传输, sk 必须和其它参数进行加密签名之后才能传输
(2) 签名认证
① 基本流程:
对 sk + 用户参数 进行加密签名 (一般使用 SHA256 (不可逆) ), 得到签名 sign.
请求方发送 ak, 用户参数 和 签名 (3个参数). 服务端接收到之后, 根据 ak 从数据库中查出 sk. 然后将 sk + 用户参数 再次进行加密签名, 将得到的签名和请求方发来的签名对比, 如果相同则认证通过.
这样的过程就全程没有暴露 sk 并且也可以得到正确校验.
② 遇到的问题:
使用签名认证有可能会遇到 ++重放攻击++ : 如果别人拦截请求, 获取到请求头中的签名, 那么他就可以复制签名并重新发送请求, 从而达到攻击服务器的目的.
③ 如何解决:
(1) 添加随机数 nonce: 这个随机数只能用一次, 每请求携带的随机数是不一样的 (服务端要保存用过的随机数).
使用 Redis 存储已经处理过的随机数. 当接收到新请求时, 先检查该请求中的随机数是否已经存在:
- ++如果存在++ --> 说明处理过当前请求 (该请求可能是重放攻击, 拒绝处理).
- ++如果不存在++ --> 将该随机数存储下来, 并设置一个过期时间 (通常与时间戳的有效时间一致), 以便后续清理过期数据.
(2) 添加 timestamp 时间戳: 请求方在请求中携带时间戳 timestamp, 服务端接收到请求后 检查时间戳与当前系统时间的差值是否在允许范围内 (如 5 分钟).
!TIP
注:
- 在过期时间内, 服务端的随机数一定是仍然存在的. 所以如果重放攻击的话一定会被拒绝.
- 在过期时间外, 服务端保存的随机数也过期了, 但是 服务端检验时间戳得到的时间差值一定不在允许范围内, 重放攻击还是会被拒绝.
三. 实现步骤
对 sk 进行签名 和 添加随机数, 时间戳 分别解决了 sk 泄露问题 和 重放攻击问题, 最终得到的算法就没有任何的安全问题了, 具体实现步骤如下:
- 生成密钥对: 给每个用户生成唯一的密钥对 (accessKey 和 secretKey), 并保存到数据库中. 仅用户本人可查看自己的 ak 和 sk.
- 请求方生成签名: 请求方 (客户端) 对 secretKey 和 请求参数 进行签名. 签名的内容包括 请求参数 (body), 时间戳 (timestamp), 随机数 (nonce) 等. 签名加密算法有 MD5, SHA256 等
- 请求方发送请求: 请求方将 请求参数, 签名, 用户标识 (ak) 一起发送给服务端. 通常会把签名等元信息放到请求头参数中传递. 注意千万不要传递 secretKey.
- 服务端验证签名: 在 API 网关中, 通过请求头获取到相关参数, 再从数据库中查到该用户对应的 ak 和 sk, 并使用相同的签名算法生成签名, 和请求中的签名进行对比. 如果签名一致, 则服务端 (API 提供者) 可以信任请求方, 请求方可以进行后续操作.
!NOTE
注: API 签名认证是一个很灵活的设计, 具体有哪些参数视情况而定. 通常情况有6个参数:
参数1: ak (accessKey)
参数2: sk (secretkey)
参数3: 用户参数 (body)
参数4: 随机数 (nonce)
参数5: 时间戳 (timestamp)
参数6: 签名 (sign)
共6个参数, 其中 sk 不能直接传递到服务端, 其中 (sk + 用户参数 + 随机数 + 时间戳) 通过签名加密算法生成签名.
所以最后向服务端传递的参数有: ++ak, 用户参数, 随机数, 时间戳, 签名++ 5个参数 (以请求头的方式传递).
四. 代码示例
(1) 请求方 (客户端 Client)
java
import cn.hutool.core.util.RandomUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONUtil;
import com.wang.wangapiclientsdk.model.User;
import java.util.HashMap;
import java.util.Map;
import static com.wang.wangapiclientsdk.utils.SignUtil.genSign;
/**
* @author yandong
*/
public class WangApiClient {
private String accessKey;
private String secretKey;
public WangApiClient(String accessKey, String secretKey) {
this.accessKey = accessKey;
this.secretKey = secretKey;
}
/**
* 向服务端发送请求 (并携带API签名认证)
* @param user
* @return
*/
public String getUsernameByPost(User user) {
String userJsonStr = JSONUtil.toJsonStr(user);
HttpResponse response = HttpRequest.post("http://localhost:8080/name/postJson")
.addHeaders(getHeaderMap(userJsonStr))
.body(userJsonStr)
.execute();
System.out.println(response.getStatus());
String result = response.body();
System.out.println(result);
return result;
}
/**
* 构造请求头
* @param body 用户参数
* @return 请求头
*/
private Map<String, String> getHeaderMap(String body) {
Map<String, String> headerMap = new HashMap<>();
headerMap.put("accessKey", accessKey); // ak (注意: sk一定不能发送给后端)
headerMap.put("body", body); // 用户参数
headerMap.put("nonce", RandomUtil.randomNumbers(4)); // 随机数
headerMap.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000)); //时间戳
headerMap.put("sign", genSign(body, secretKey)); // 签名
return headerMap;
}
}
(2) API 提供方 (服务端 Server)
java
import cn.hutool.json.JSONUtil;
import com.wang.wangapiclientsdk.model.User;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import static com.wang.wangapiclientsdk.utils.SignUtil.genSign;
@RestController
@RequestMapping("/name")
public class NameController {
@PostMapping("/postJson")
public String getUsernameByPost(@RequestBody User user, HttpServletRequest request) {
String accessKey = request.getHeader("accessKey");
String body = request.getHeader("body");
String nonce = request.getHeader("nonce");
String timestamp = request.getHeader("timestamp");
String sign = request.getHeader("sign");
/*
(1) 校验 ak
TODO 实际情况是从数据库中查出 ak
*/
if (!StringUtils.hasText(accessKey)) {
throw new RuntimeException("accessKey不能为空");
}
/*
(2) 校验body 确保header中的body与实际请求体一致, 防篡改
*/
if (!StringUtils.hasText(body)) {
throw new RuntimeException("body不能为空");
}
try {
// 将请求体User对象序列化为JSON, 与header中的body对比
String requestBodyJson = JSONUtil.toJsonStr(user);
if (!body.equals(requestBodyJson)) {
throw new RuntimeException("请求体与body不一致,可能被篡改");
}
} catch (RuntimeException e) {
throw new RuntimeException("请求体序列化失败", e);
}
/*
(3) 校验nonce随机数, 防重放攻击 (确保同一nonce在有效时间内不重复)
*/
if (!StringUtils.hasText(nonce)) {
throw new RuntimeException("nonce不能为空");
}
/*
(4) 校验 timestamp 时间戳
时间和当前时间间隔不能超过5分钟
*/
if (!StringUtils.hasText(timestamp)) {
throw new RuntimeException("timestamp不能为空");
}
/*
(5) 校验签名
TODO 实际情况是从数据库中查出 sk
*/
if (!StringUtils.hasText(sign)) {
throw new RuntimeException("sign不能为空");
}
String serverSign = genSign(body, "123456");
if (!sign.equals(serverSign)) {
throw new RuntimeException("签名不一致, 无权限");
}
return "POST JSON 你的名字是 " + user.getUsername();
}
}