本质
签发签名
认证签名(使用签名或校验码。这就像一些短信接口的 key 一样 别纠结名字)
accessKey secretKey / appKey appSecret 一样
1. 思考(场景)
如果说我们把这个接口提供给开发者,但是我们现在是不是根 本不知道是谁来调用的。假如说我们的服务器只能允许100个 人来调用。假如说有一个攻击者来了,他就刷量了,他想疯狂 的刷我的服务器,那是不是非常的不安全? 另外一方面就是你的服务器的性能肯定会受到损耗,也会影响 正常用户。所以我们肯定要给这个接口,要去做一些保护。比 如说每个用户只能去每秒只能调用十次,这种我们要去做一个 限额。而且后续可能你做大了还要去做收费,对不对?所以我 们肯定要知道是谁来调用的这个接口,而且不能让随便一个没 有权限的人都来找你。 所以,我们怎么知道是谁调用的接口?
即 保证安全性不能随便一个人就能调用
2. 为什么选择 API 签名认证
- 保证安全性,不能让任何人都能调用接口
- 适用于无需保存登录态的场景。只认签名,不关注用户登录态。(不关心你是男是女,只要你有 “证” 就行)
3. 实现
3.1 通过 HTTP request header 头传参
参数1:accessKey:调用的标识(尽量复杂)
参数2:secretKey:密钥
`(类似于用户名、密码,区别:ak\sk无状态)
根据 accessKey 识别调用者的身份
根据 secretKey 识别出签名是否合法
在数据库用户表中新增两个字段 accessKey和secretKey,让用户每次发送请求时,将accessKey 和 secretKey 添加在请求头上一发送
3.1.1 demo 实现(使用 hutool 简介 | Hutool)
- 创建 danhuaapiClient 客户端,并加入两个变量、并创建构造函数
private String accessKey;
private String secretKey;
public danhuaapiClient(String accessKey, String secretKey) {
this.accessKey = accessKey;
this.secretKey = secretKey;
}
- 客户端发送请求时携带请求头
.addHeaders(getHeaders())
private Map<String, String> getHeaders() {
Map<String, String> headers = new HashMap<>();
headers.put("accessKey", accessKey);
headers.put("secretKey", secretKey);
return headers;
}
public String postNameJson(User user){
String json = JSONUtil.toJsonStr(user);
HttpResponse execute = HttpRequest.post("http://localhost:8123/api/name/user")
.addHeaders(getHeaders())
.body(json)
.execute();
System.out.println("execute :" + execute.getStatus());
String body = execute.body();
System.out.println("body :" + body);
return body;
}
- 编写测试类
String accessKey = "aikun";
String secretKey = "hd9ehxjue";
danhuaapiClient client = new danhuaapiClient(accessKey, secretKey);
String name1 = client.postNameJson(new User("篮球", "ctrl"));
System.out.println(name1);
- 服务端校验
这里从请求头里面获取信息,这里的校验只是测试,真实情况应该 是从数据库中去取。
@PostMapping("/user")
public String postNameJson(@RequestBody User user, HttpServletRequest request){
String accessKey = request.getHeader("accessKey");
String secretKey = request.getHeader("secretKey");
if (!"aikun".equals(accessKey) || !"hd9ehxjue".equals(secretKey)){
throw new RuntimeException("无权限! 小黑子能不能不要再黑我家哥哥啦~");
}
return "post 方法 json 参数 用户的名字: " + user.getUsername();
}
3.1.2 存在的问题
如果仅仅通过参数1,参数2放在请求头的方式进行传递,那么
别人很可能通过重放(二次请求,黑客通过抓包获取到了请求的HTTP报文,然后黑客自己编写了一个类似的HTTP请求,发送给服务器。也就是说服务器处理了两个请求,先处理了正常的HTTP请求,然后又处理了黑客发送的篡改过的HTTP请求)等方式破解
或者拦截你的请求
就如同get请求登录,你把用户名和密码直接拼在地址后面,这肯定是不安全的
重放
f12 -> 网络 -> 选择一个请求 重放XHR
记住!
密码千万不能把放在服务器之间传递
一般是根据密钥生成签名sign
3.2 优化校验
既然以上方法是因为 accessKey 和 secretKey 是明文所导致的,那能不能像,用户登录一样,将secretKey 进行加密,而且采用单向加密(md5,sha256等等)
3.2.1 参数增加
参数3:用户请求参数
参数4:sign 签名
3.2.2 具体做法
假如我们有用户参数,我们用密钥和他拼接,用签名算法得到一个 不可解密的值。
用户参数+密钥 => 签名生成算法(MD5,HMac,Sha1) => 不可解 密的值
例子: abc+abcdefg => deskdjhs(得到的值是随机的)
怎么知道签名对不对?
服务器用一模一样的参数和算法去生成签名,只要和用户传的一 致,就标识密钥一致
怎么防止重放?(怎么防止别人拿我们之前发布的请求信息以后再重新再发一次)
- 加一个 参数5 nonce 随机数 随机数就我们每次请求的时候发一个随机数。后端记录。后端它只认一 次,后端只接受并认可该随机数一次,一旦被使用过,将不再接受 相同的随机数,解决了重放问题。但后端要保存用过 的随机数,如果接口并发量大,我们需要处理大量的请求,还要定时清理
- 加一个 参数6 timestamp 时间戳,校验时间戳是否过期 发请求时携带一个时间戳,后端会验证其是否在指定的时间范围, 可以控制随机数的过期时间。后端需要同时验证这两个参数,只要时间戳过期或随机数被使用过,后端就会拒绝请求
- 这两个参数还可以联合起来,nonce 不是要定期清理吗,timestamp 过时了就不能用了,那不是就可以根据 timestamp 时间戳的时间进行定期清理 建议redis ,redis 在存储Key-value 本身就可以设置过期时间
3.2.3 实现
- 编写生成 sign 方法的工具类
public static String genSign(String body,String secretKey) {
Digester md5=new Digester(DigestAlgorithm.SHA256);
String content = body + '*' + secretKey;
return md5.digestHex(content);
}
- 添加参数
private Map<String, String> getHeaders(String body) {
Map<String, String> headers = new HashMap<>();
headers.put("accessKey", accessKey);
// headers.put("secretKey", secretKey);
headers.put("nonce", RandomUtil.randomNumbers(4));
headers.put("body", body);
headers.put("timestamp", String.valueOf(System.currentTimeMillis()/1000));
headers.put("sign", SignUtil.genSign(body, secretKey));
return headers;
}
public String postNameJson(User user){
String json = JSONUtil.toJsonStr(user);
HttpResponse execute = HttpRequest.post("http://localhost:8123/api/name/user")
.addHeaders(getHeaders(json))
.body(json)
.execute();
System.out.println("execute :" + execute.getStatus());
String body = execute.body();
System.out.println("body :" + body);
return body;
}
- 参数校验
@PostMapping("/user")
public String postNameJson(@RequestBody User user, HttpServletRequest request) {
String accessKey = request.getHeader("accessKey");
String nonce = request.getHeader("nonce");
String timestamp = request.getHeader("timestamp");
String sign = request.getHeader("sign");
// 这段代码将从 ISO-8859-1 解码得到的字节流再用 UTF-8 编码,以适应中文字符。
String body = new String(request.getHeader("body").getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
//校验时间戳
if (System.currentTimeMillis() / 1000 - Long.parseLong(timestamp) > 60 * 5) {
throw new RuntimeException("无权限! 小黑子能不能不要再黑我家哥哥啦~");
}
// todo 实际上应该去数据库查询
if (!"aikun".equals(accessKey)) {
throw new RuntimeException("无权限! 小黑子能不能不要再黑我家哥哥啦~");
}
// todo 这里不校验随机数了
if (Long.parseLong(nonce) > 10000) {
throw new RuntimeException("无权限! 小黑子能不能不要再黑我家哥哥啦~");
}
String serverSign = SignUtil.genSign(body, "hd9ehxjue");
if (!sign.equals(serverSign)) {
throw new RuntimeException("无权限! 小黑子能不能不要再黑我家哥哥啦~");
}
return "post 方法 json 参数 用户的名字: " + user.getUsername();
}
总结
如何确定访问者的合法性
accessKey secretKey(极其重要,严格保密)
secretKey只有客户端和服务端所知道,但是不参与请求,服务端存在 accessKey -> secretKey 的关系
参数不被修改
sign根据参数,利用算法得出sign,如果你一旦修改一个参数,那么sign也会跟着改变
如何确保请求的唯一性
nonce 随机数
timestamp 时间戳