一. 接口调用
对于开发者来说,接口的调用应当是方便快捷的,而且出于安全考虑,通常会选择在后端调用第三方 API,避免在前端暴露诸如密码的敏感信息。
若采用 HTTP 调用方式:
- HttpClient
- RestTemplate
- 第三方库(Hutool等)
项目中使用了 Hutool 工具库中的 Http 客户端工具类,快速调用其它的 http 请求。
Hutool 工具库官方依赖:
<dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.16</version>
</dependency>
三个接口:
/**** 查询用户名称的 API**/
@RestController
@RequestMapping("name")
public class NameController {@GetMapping("/") public String getNameByGet(String name) {return "GET 你的名字是" + name;}@PostMapping("/") public String getNameByPost(@RequestParam String name) {return "POST 你的名字是" + name;}@PostMapping("/") public String getUserNameByPost(@RequestParam User user) {return "POST 用户名字是" + user.getUsername();}
}
调用第三方接口,直接使用Http 客户端工具类官方代码,略微修改即可:
/****调用第三方接口的客户端**/
public class kApiClient {// 使用 GET 方法从服务器获取名称信息public String getNameByGet(String name){// 可以单独传入 http 参数,这样参数会自动做 URL 编码,拼接在 URL 中。hashMap<String, Object> paramMap = new HashMap<>();// 将 "name" 参数添加到映射中paramMap.put("name", name);// 使用 HttpUtil 工具发起 GET 请求,并获取服务器返回结果String result = HttpUtil.get("http://localhost:8123/api/name",paramMap);// 打印服务器返回结果System.out.println(result);// 返回服务器返回结果return result;}// 使用 POST 方法从服务器获取名称信息public String getNameByPost(@RequestParam String name) {// 可以单独传入 http 参数,这样参数会自动做 URL 编码,拼接在 URL 中。hashMap<String, Object> paramMap = new HashMap<>();// 将 "name" 参数添加到映射中paramMap.put("name", name);// 使用 HttpUtil 工具发起 GET 请求,并获取服务器返回结果String result = HttpUtil.post("http://localhost:8123/api/name",paramMap);// 打印服务器返回结果System.out.println(result);// 返回服务器返回结果return result; }// 使用 POST 方法向服务器发送 User 对象,并获取服务器返回结果public String getUserNameByPost(@RequestParam User user) { // 将 User 对象转换为 JSON 字符串(轻量,方便在客户端服务端之间传输数据)String json = JSONUtil.toJsonStr(user);// 使用 HttpRequest 工具发起 POST 请求,并获取服务器的响应HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name").body(json) // 将 JSON 字符串设置为请求体.execute(); // 执行请求 // 打印服务器返回的状态码System.out.println(httpResponse.getStatus());// 打印服务器返回结果System.out.println(result);// 返回结果return result;}
}
二. API 签名认证
这部分在 API Hunter — 客制化API开放平台 一文中进行过阐述,此处重新整理并再次回顾。
1. 为什么需要认证
思考一个问题:如果我们为外界提供了一些可用接口,却对请求调用者一无所知。
这可能会让我们面临严重的安全问题。假设服务器最多只允许100人同时调用,有攻击者疯狂地请求接口,刷量刷请求,会严重消耗服务器性能,影响正常用户的使用,并且也会使损害系统安全。
因此,我们需要为接口设置保护措施,例如限流,限制每个用户每秒只能调用接口十次等。同时我们需要知道调用者信息,即谁在请求调用接口。类似于管理系统中的权限检查,执行删除操作时,后端会先去检查用户是否具有管理员权限等。
但用户调用接口时,可能是从前端直接发起请求,用户没有登录操作,也就不涉及用户名和密码,所以后端无法从 session 中获取用户信息,因为根本就没有。因此在这种情况下,就采用 API 签名认证机制。
2. 什么是 API 签名认证
通俗地讲,就是基于授权(许可证)的身份校验。
举例:客人想要参加我的宴会,需要有我事前签发的请帖,作为授权或许可证。当客人赴约时,需要带上请帖,只要有请帖,就能参加。
API 签名认证的过程:签发签名 -> 校验签名。
API 签名认证不仅保证了安全性,不让随便一个人调用接口,而且实现了用户的无状态请求,即只认签名,不关注用户登录态。
3. 涉及的参数和组件
通过 http request header 头传递参数。
- 参数1:accessKey(aK),调用的标识 userA/userB … (复杂、无序、无规律)
- 参数2:secretKey(sK),密钥 (复杂、无序、无规律),该参数不能放到请求头中
认证过程中主要依靠签发 aK 和 sK 来完成身份的校验。类似于用户名和密码,只是是无状态的。
- 参数3:用户请求参数
- 参数4:sign
加密组件:
利用用户参数和密钥,然后通过签名生成算法(MD5、SHA256等)加密,变成不可解密的值,防止泄露和被破译。
用户参数 + 密钥 不可解密的值
防重放:
- 参数5:nonce 随机数,只能用一次。服务端保存用过的随机数。
- 参数6:timestapm 时间戳,校验时间戳是否过期。
API 签名认证过程相对灵活,具体的参数应当根据实际业务场景合理选择。
4. 基本流程实现
首先给数据库中的用户表增加两个字段 accessKey 和 secretKey。
aK 和 sK 的生成通常要求无规律且复杂,为了模拟效果,此处先自行设置。
aK:khr123 sK:1q2w3e4r
Tips:之所以需要两个 key,还是为了保证安全性。就像在登陆网站时不仅需要用户名还需要密码。如果只有一个,那么任何一个拿到这个 key 的人都可以调用接口,不安全。
然后在调用接口客户端中增加这两个字段及其构造方法:
/****调用第三方接口的客户端**/
public class kApiClient {private String accessKey;private String secretKey;public KApiClient(String accessKey, String secretKey) {this.accessKey = accessKey;this.secretKey = secretKey;}……}
之后在调用的 KApiClient 的地方,将 aK、sK 拿到即可,客户端改造完成:
public class Main {public static void main(String[] args){ String accessKey ="khr123";String secretKey ="1q2w3e4r";KApiClient kApiClient = new KApiClient(accessKey, secretKey);……}
}
接下来服务端要校验 aK、sK,以 getUserNameByPost 接口为例说明:
首先要获取到用户传递的 aK、sK。这种数据建议不要直接在 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 不等于 "khr123" 或者 secretKey 不等于 "1q2w3e4r"if(!accessKey.equals("khr123")||!secretKey.equals("1q2w3e4r")){// 抛出运行异常,提示权限不足throw new RuntimeException("无权限");}// 如果权限校验通过,返回"POST 用户名是" + 用户名return "POST 用户名是" + user.getUsername();
}
其实在实际应用中,后端应该根据提供的 key 去数据库中查询,检查对应的用户是否合法或者该 key 是否被分配过等,此处仅作模拟。
/****调用第三方接口的客户端**/
public class kApiClient {private String accessKey;private String secretKey;public KApiClient(String accessKey, String secretKey) {this.accessKey = accessKey;this.secretKey = secretKey;}……// 创建一个私有方法,用于构造请求头private Map<String, String> getHeaderMap() {// 创建一个新的 HashMap 对象Map<String, String> hashMap = new HashMap<>();// 将 "accessKey" 和其对应的值放入 map 中hashMap.put("accessKey",accessKey);// 将 "secretKey" 和其对应的值放入 map 中hashMap.put("secretKey",secretKey);// 返回构造的请求头 mapreturn hashMap;}// 使用 POST 方法向服务器发送 User 对象,并获取服务器返回结果public String getUserNameByPost(@RequestParam User user) { // 将 User 对象转换为 JSON 字符串(轻量,方便在客户端服务端之间传输数据)String json = JSONUtil.toJsonStr(user);// 使用 HttpRequest 工具发起 POST 请求,并获取服务器的响应HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name")// 添加构造的请求头.addHeaders(getHeaderMap()).body(json) // 将 JSON 字符串设置为请求体.execute(); // 执行请求 // 打印服务器返回的状态码System.out.println(httpResponse.getStatus());// 打印服务器返回结果System.out.println(result);// 返回结果return result;}
}
测试后发现能够获取到 aK、sK,并且通过了验证。如果将 secretKey 随意修改为其它值,则会提示无权限:
hashMap.put("secretKey", "qweasdax");
5. 安全传递
虽然目前实现了通过签发 aK、sK 进行身份校验,但依然存在安全隐患。因为发送的请求可能被拦截,而我们传递的参数信息均放在请求头中,所以如果请求被拦截,那么攻击者可以直接从请求头中获取到密钥,然后使用密钥发送请求。
此外,secretKey 绝对不能传递,或者说不能以明文的形式直接传递,需要经过加密处理。在标准的 API 签名认证中,通常需要传递一个签名。签名是由用户传递的参数和 secretKey 拼接,并经过签名算法加密生成的。
加密算法通常又对称加密、非对称加密、单向加密等,详细内容可参考:一文读懂密码学
在项目中使用了 MD5 签名算法,即单向加密。这种加密方式不可逆,无法解密,通常用来生成签名。将生成的签名发送给服务器,服务器只需验证签名是否正确即可,这样根本不会暴露密码。
对于服务器而言,它会使用相同的参数与加密算法再次生成签名,以检验签名是否正确。
但这样做可能仍然存在被重放攻击的风险,所谓重放攻击,就是攻击者复制并重复之前发布的请求。也就是说即使攻击者不知道签名内容,将其拦截后,再次以请求者的身份发送给后端,依然能够完成调用。所以,为了避免重放攻击,再增加两个参数,分别是 nonce 随机数和 timestamp 时间戳。
nonce 随机数:每次请求时,发送一个随机数给后端。后端只接受并认可该随机数一次,如果请求中带有重复的随机数,则不会处理请求。但这样会带来额外的存储开销,因为后端需要记录所有随机数。
timestamp 时间戳:每个请求在发送时携带一个时间戳,并且后端会验证该时间戳是否在指定的时间范围内,例如不超过 10 分钟,这样就可以防止攻击者使用先前的请求进行重放。可以和随机数配合使用,能够在一定程度上控制随机数的过期时间,后端也就不需要保存所有的随机数,减轻了存储开销。例如,只需要保存 10 分钟以内的随机数,因为后端需要校验随机数和时间戳两个参数,只要其中一个不符合要求,直接拒绝请求。
因此,在项目的签名认证算法中,至少需要添加五个参数:accessKey、secretKey、sign、nonce、timestamp。其它参数,比如接口的 name 参数等也可以添加到签名中,根据具体业务情况选择,以增加安全性。
无论如何,要确保密码绝不能在服务器直接传输,任何在服务器之间传输的内容都有可能被拦截。
6. 安全传递实现
首先在客户端中增加新的参数:
private Map<String, String> getHeaderMap() {Map<String, String> hashMap = new HashMap<>();hashMap.put("accessKey", accessKey);// 记住!不能直接发送密码// hashMap.put("secretKey", secretKey);// nonce 随机数hashMap.put("nonce", RandomUtil.randomNumber(4));// 请求体内容hashMap.put("body", body);// timestamp 当前时间戳hashMap.put("timestamp", String.valueof(System.currentTimeMillis() / 1000));return hashMap;
}
然后把用户参数进行拼接,经过签名算法生成唯一的字符串:
使用了 Hutool 工具库中的加密算法工具类(摘要加密),直接将生成签名当作一个工具类使用,
/*** 签名工具*/
public class SignUtils {/*** 生成签名* @param body 请求体内容(用户参数)* @param secretKey 密钥* @return 生成的签名字符串*/public static String genSign(String body, String secretKey) {// 使用 MD5 算法的 DigesterDigester md5 = new Digester(DigesterAlgorithm.MD5);// 构建签名内容,将哈希映射转换为字符串并拼接密钥String content = body + "." + secretKey;// 计算签名的摘要并返回摘要的十六进制表示形式return md5.digestHex(content); }
}
后续会持续更新整理。