问题引入
我们为开发者提供了接口,却对调用者一无所知
假设我们的服务器只能允许 100 个人同时调用接口。如果有攻击者疯狂地请求这个接口,那是很危险的。一方面这可能会损害安全性,另一方面耗尽服务器性能,影响正常用户的使用。
因此我们必须为接口设置保护措施,例如限制每个用户每秒只能调用十次接口,即实施请求频次的限额控制。所以我们必须知道谁在调用接口,并且不能让无权限的人随意调用。
在我们之前开发后端时,我们会进行一些权限检查。例如,当管理员执行删除操作时,后端需要检查这个用户是否为管理员,直接从后端的 session 中获取的。但问题来了,比如我是前端直接发起请求,没有登录操作,没有输入用户名和密码,我怎么去调用呢?
API 签名认证
API 签名认证过程
签发签名 -> 使用签名或校验签名
为什么需要API签名认证
为了保证安全性,不能让任何人都能调用接口。
适用于无需保存登录态的场景。只认签名,不关注用户登录态(为了更通用)。
如何在后端实现签名认证?
需要两个东西,即 accessKey 和 secretKey,来标识用户
和用户名和密码类似,不过每次调用接口都需要带上,实现无状态的请求。
“无状态”指的是每个请求都是独立的,服务器不会保存客户端的任何状态信息。每次请求都包含所有必要的信息来完成该请求。这种设计使得系统更易于扩展和管理。
签发 accessKey 和 secretKey
一般来说,accessKey 和 secretKey 需要尽可能复杂无规律,防止黑客尝试破解,特别是密码。
签名认证实现
通过 http request header 头传递参数。
- 参数 1:accessKey:调用的标识 userA, userB(复杂、无序、无规律)
- 参数 2:secretKey:密钥(复杂、无序、无规律)该参数不能放到请求头中
- 参数 3:用户请求参数
- 参数 4:签名
加密方式:
对称加密、非对称加密、md5 签名(不可解密)
用户参数 + 密钥 => 签名生成算法(MD5、HMac、Sha1) => 不可解密的值
怎么知道这个签名对不对?
服务端用一模一样的参数和算法去生成签名,只要和用户传的的一致,就表示一致。
怎么防重放?
- 参数 5:加 nonce 随机数,只能用一次,(存在问题:服务端要保存使用过的随机数)
所以配合
- 参数 6:加 timestamp 时间戳,校验时间戳是否过期。
API 签名认证是一个很灵活的设计,具体要有哪些参数、参数名如何一定要根据场景来。
为什么需要两个 key?
如果仅凭一个 key 就可以调用接口,那么任何拿到这个 key 的人都可以无限制地调用这个接口。这就好比,为什么你在登录网站时需要输入密码,而不是只输入用户名就可以了?其实这两者的原理是一样的。如果像 token 一样,一个 key 不行吗?token 本质上也是不安全的,有可能会通过重放等等方式来攻破的。
TODO:关于 accessKey、secretKey 的生成方法,自行编写代码实现
实践:
在客户端 拿到 accessKey、secretKey
需要获取用户传递的 accessKey 和 secretKey。
对于这种数据,建议不要直接在 URL 中传递,而是选择在请求头中传递会更为妥当。
因为 GET 请求的 URL 存在最大长度限制,如果你传递的其他参数过多,可能会导致关键数据被挤出。
@PostMapping("/user")
public String getUserNameByPost(@RequestBody User user, HttpServletRequest request) {
// 从请求头中获取名为 "accessKey" 的值
String accessKey = request.getHeader("accessKey");
// 从请求头中获取名为 "secretKey" 的值
String secretKey = request.getHeader("secretKey");
// 如果 accessKey 不等于 "sujie" 或者 secretKey 不等于 "abcdefgh"
if (!accessKey.equals("sujie") || !secretKey.equals("abcdefgh")){
// 抛出一个运行时异常,表示权限不足
throw new RuntimeException("无权限");
}
// 如果权限校验通过,返回 "POST 用户名字是" + 用户名
return "POST 用户名字是" + user.getUsername();
}
改造一下 SuApiClient.java,发请求可以带上 header,用这个就可以去添加很多的请求头
// 使用POST方法向服务器发送User对象,并获取服务器返回的结果
public String getUserNameByPost(@RequestBody User user) {
// 将User对象转换为JSON字符串
String json = JSONUtil.toJsonStr(user);
// 使用HttpRequest工具发起POST请求,并获取服务器的响应
HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name/user")
// 添加前面构造的请求头
.addHeaders(getHeaderMap())
.body(json) // 将JSON字符串设置为请求体
.execute(); // 执行请求
// 打印服务器返回的状态码
System.out.println(httpResponse.getStatus());
// 获取服务器返回的结果
String result = httpResponse.body();
// 打印服务器返回的结果
System.out.println(result);
// 返回服务器返回的结果
return result;
}
// 创建一个私有方法,用于构造请求头
private Map<String, String> getHeaderMap() {
// 创建一个新的 HashMap 对象
Map<String, String> hashMap = new HashMap<>();
// 将 "accessKey" 和其对应的值放入 map 中
hashMap.put("accessKey", accessKey);
// 将 "secretKey" 和其对应的值放入 map 中
hashMap.put("secretKey", secretKey);
// 返回构造的请求头 map
return hashMap;
}
安全传递
存在的问题:
我们的请求有可能被人拦截,我们将密码放在请求头中,如果有中间人拦截到了你的请求,他们就可以直接从请求头中获取你的密码,然后使用你的密码发送请求。
密码绝对不能传递。也就是说,在向对方发送请求时,密码绝对不能以明文的方式传递,必须通过特殊的方式进行传递。
我们需要对该密码进行加密,这里通常称之为签名。
可以将用户传递的参数与该密钥拼接在一起,然后使用单向签名算法进行加密。
如何防止重放请求有两种方式可以考虑:
第一种方式是通过加入一个随机数实现标准的签名认证。每次请求时,发送一个随机数给后端。后端只接受并认可该随机数一次,一旦随机数被使用过,后端将不再接受相同的随机数。这种方式解决了请求重放的问题,因为即使对方使用之前的时间和随机数进行请求,后端会认识到该请求已经被处理过,不会再次处理。然而,这种方法需要后端额外开发来保存已使用的随机数。并且,如果接口的并发量很大,每次请求都需要一个随机数,那么可能会面临处理百万、千万甚至亿级别请求的情况。因此,除了使用随机数之外,我们还需要其他机制来定期清理已使用的随机数。
第二种方式是加入一个时间戳(timestamp)。每个请求在发送时携带一个时间戳,并且后端会验证该时间戳是否在指定的时间范围内,例如不超过10分钟或5分钟。这可以防止对方使用昨天的请求在今天进行重放。通过这种方式,我们可以一定程度上控制随机数的过期时间。因为后端需要同时验证这两个参数,只要时间戳过期或随机数被使用过,后端会拒绝该请求。因此,时间戳可以在一定程度上减轻后端保存随机数的负担。通常情况下,这两种方法可以相互配合使用。
因此,在标准的签名认证算法中,建议至少添加以下五个参数:accessKey、secretKey、sign、nonce(随机数)、timestamp(时间戳)。此外,建议将用户请求的其他参数,例如接口中的 name 参数,也添加到签名中,以增加安全性。
安全传递实现
新建一个 utils 包
,在 utils 包下新建 SignUtils.java
(签名工具)
这个 hashmap 还需要进行拼接,我们传递的是用户的这些参数,但其实没有必要传递那么多参数,直接将 body 作为参数传递进来(在这里,我们也可以传递 hashmap,只要有一些共同的参数,能让客户端和服务端之间保持一致即可)。
/**
* 签名工具
*/
public class SignUtils {
/**
* 生成签名
* @param hashMap 包含需要签名的参数的哈希映射
* @param secretKey 密钥
* @return 生成的签名字符串
*/
public static String genSign(Map<String, String> hashMap, String secretKey) {
// 使用SHA256算法的Digester
Digester md5 = new Digester(DigestAlgorithm.SHA256);
// 构建签名内容,将哈希映射转换为字符串并拼接密钥
String content = hashMap.toString() + "." + secretKey;
// 计算签名的摘要并返回摘要的十六进制表示形式
return md5.digestHex(content);
}
}
刚刚客户端只有这两个参数 accessKey、secretKey
现在再加几个参数
/**
* 获取请求头的哈希映射
* @param body 请求体内容
* @return 包含请求头参数的哈希映射
*/
private Map<String, String> getHeaderMap(String body) {
Map<String, String> hashMap = new HashMap<>();
hashMap.put("accessKey", accessKey);
// 注意:不能直接发送密钥
// hashMap.put("secretKey", secretKey);
// 生成随机数(生成一个包含100个随机数字的字符串)
hashMap.put("nonce", RandomUtil.randomNumbers(4));
// 请求体内容
hashMap.put("body", body);
// 当前时间戳
// System.currentTimeMillis()返回当前时间的毫秒数。通过除以1000,可以将毫秒数转换为秒数,以得到当前时间戳的秒级表示
// String.valueOf()方法用于将数值转换为字符串。在这里,将计算得到的时间戳(以秒为单位)转换为字符串
hashMap.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
// 生成签名
hashMap.put("sign", genSign(body, secretKey));
return hashMap;
}
/**
* 通过POST请求获取用户名
* @param user 用户对象
* @return 从服务器获取的用户名
*/
public String getUserNameByPost(@RequestBody User user) {
// 将用户对象转换为JSON字符串
String json = JSONUtil.toJsonStr(user);
HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name/user")
// 添加请求头
.addHeaders(getHeaderMap(json))
// 设置请求体
.body(json)
// 发送POST请求
.execute();
// 打印响应状态码
System.out.println(httpResponse.getStatus());
// 打印响应体内容
String result = httpResponse.body();
System.out.println(result);
return result;
}
接下来服务端
@PostMapping("/user")
public String getUserNameByPost(@RequestBody User user, HttpServletRequest request) {
// 1.拿到这五个我们可以一步一步去做校验,比如 accessKey 我们先去数据库中查一下
// 从请求头中获取参数
String accessKey = request.getHeader("accessKey");
String nonce = request.getHeader("nonce");
String timestamp = request.getHeader("timestamp");
String sign = request.getHeader("sign");
String body = request.getHeader("body");
// 不能直接获取秘钥
// String secretKey = request.getHeader("secretKey");
// TODO 2.校验权限,这里模拟一下,直接判断 accessKey 是否为"yupi",实际应该查询数据库验证权限
if (!accessKey.equals("sujie")){
throw new RuntimeException("无权限");
}
// TODO 3.校验一下随机数,因为时间有限,就不带大家再到后端去存储了,后端存储用hashmap或redis都可以
// 校验随机数,模拟一下,直接判断nonce是否大于10000
if (Long.parseLong(nonce) > 10000) {
throw new RuntimeException("无权限");
}
// TODO 4.校验时间戳与当前时间的差距,交给大家自己实现
//
// if (timestamp) {}
// TODO 5. 从实际数据库中取得用户secretKey
String serverSign = SignUtils.genSign(body, "abcdefgh");
if (!serverSign.equals(sign)) {
throw new RuntimeException("无权限");
}
return "POST 用户名字是" + user.getUsername();
}
整个签名认证算法的流程就是这样
需要强调的是,API签名认证是一种非常灵活的设计,具体需要哪些参数以及参数名的选择都应根据具体场景来确定。尽量避免在前端进行签名认证,而是由服务端来处理
例如,某些公司或项目的签名认证可能会包含 userId 字段以区分用户。还可能包含 appId 和 version 字段来表示应用程序的版本号。有时还会添加一些固定的盐值等等。