一. 接口调用
对于开发者来说,接口的调用应当是方便快捷的,而且出于安全考虑,通常会选择在后端调用第三方 API,避免在前端暴露诸如密码的敏感信息。
若采用 HTTP 调用方式:
- HttpClient
- RestTemplate
- 第三方库(Hutool等)
项目中使用了 Hutool 工具库中的 Http 客户端工具类,快速调用其它的 http 请求。
Hutool 工具库官方依赖:
<dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.16</version>
</dependency>
三个接口:
java">/**** 查询用户名称的 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("/user") public String getUserNameByPost(@RequestParam User user) {return "POST 用户名字是" + user.getUsername();}
}
调用第三方接口,直接使用Http 客户端工具类官方代码,略微修改即可:
java">/****调用第三方接口的客户端**/
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 的人都可以调用接口,不安全。
然后在调用接口客户端中增加这两个字段及其构造方法:
java">/****调用第三方接口的客户端**/
public class kApiClient {private String accessKey;private String secretKey;public KApiClient(String accessKey, String secretKey) {this.accessKey = accessKey;this.secretKey = secretKey;}……}
之后在调用的 KApiClient 的地方,将 aK、sK 拿到即可,客户端改造完成:
java">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 存在最大长度限制,如果传递的其它参数过多,会导致关键数据被挤出,所以建议从请求头中获取这些数据。
java">@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 是否被分配过等,此处仅作模拟。
java">/****调用第三方接口的客户端**/
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 随意修改为其它值,则会提示无权限:
java">hashMap.put("secretKey", "qweasdax");
5. 安全传递
虽然目前实现了通过签发 aK、sK 进行身份校验,但依然存在安全隐患。因为发送的请求可能被拦截,而我们传递的参数信息均放在请求头中,所以如果请求被拦截,那么攻击者可以直接从请求头中获取到密钥,然后使用密钥发送请求。
此外,secretKey 绝对不能传递,或者说不能以明文的形式直接传递,需要经过加密处理。在标准的 API 签名认证中,通常需要传递一个签名。签名是由用户传递的参数和 secretKey 拼接,并经过签名算法加密生成的。
加密算法通常又对称加密、非对称加密、单向加密等,详细内容可参考:一文读懂密码学
在项目中使用了 MD5 签名算法,即单向加密。这种加密方式不可逆,无法解密,通常用来生成签名。将生成的签名发送给服务器,服务器只需验证签名是否正确即可,这样根本不会暴露密码。
对于服务器而言,它会使用相同的参数与加密算法再次生成签名,以检验签名是否正确。
但这样做可能仍然存在被重放攻击的风险,所谓重放攻击,就是攻击者复制并重复之前发布的请求。也就是说即使攻击者不知道签名内容,将其拦截后,再次以请求者的身份发送给后端,依然能够完成调用。所以,为了避免重放攻击,再增加两个参数,分别是 nonce 随机数和 timestamp 时间戳。
nonce 随机数:每次请求时,发送一个随机数给后端。后端只接受并认可该随机数一次,如果请求中带有重复的随机数,则不会处理请求。但这样会带来额外的存储开销,因为后端需要记录所有随机数。
timestamp 时间戳:每个请求在发送时携带一个时间戳,并且后端会验证该时间戳是否在指定的时间范围内,例如不超过 10 分钟,这样就可以防止攻击者使用先前的请求进行重放。可以和随机数配合使用,能够在一定程度上控制随机数的过期时间,后端也就不需要保存所有的随机数,减轻了存储开销。例如,只需要保存 10 分钟以内的随机数,因为后端需要校验随机数和时间戳两个参数,只要其中一个不符合要求,直接拒绝请求。
因此,在项目的签名认证算法中,至少需要添加五个参数:accessKey、secretKey、sign、nonce、timestamp。其它参数,比如接口的 name 参数等也可以添加到签名中,根据具体业务情况选择,以增加安全性。
无论如何,要确保密码绝不能在服务器直接传输,任何在服务器之间传输的内容都有可能被拦截。
6. 安全传递实现
首先在客户端中增加新的参数:
java">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));// 生成签名hashMap.put("sign", genSign(body, secretKey));return 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(json)).body(json) // 将 JSON 字符串设置为请求体.execute(); // 执行请求 // 打印服务器返回的状态码System.out.println(httpResponse.getStatus());// 打印服务器返回结果System.out.println(result);// 返回结果return result;}
然后把用户参数进行拼接,经过签名算法生成唯一的字符串:
使用了 Hutool 工具库中的加密算法工具类(摘要加密),直接将生成签名当作一个工具类使用,
java">/*** 签名工具*/
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); }
}
服务端的校验:
java">@PostMapping("/user")
public String getUserNameByPost(@RequestParam User user) {// 1.拿到五个参数后一步步校验// 从请求头中获取参数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");// 2.权限校验(实际应为到数据库中去查)if(!accessKey.equals("khr123")) {throw new RuntimeException("无权限"); }// 3.校验随机数if(Long.parseLong(nonce) > 10000) {throw new RuntimeException("无权限");}// 拼接 sign,实际 secretKey 应该从数据库中去查String serverSign = SignUtils.genSign(body, "1q2w3e4r"); // 如果生成的签名不一致,抛出异常,提示无权限if(!sign.equals(serverSign)) {throw new RuntimeException("无权限");} return "POST 用户名字是" + user.getUsername();}
签名认证中具体的参数应当根据业务场景灵活选择,可能会包含 userId 字段用来区分用户,也可能会增加 version 字段来表示应用程序版本号等。
三. 开发 SDK
1. 为什么需要 SDK
思考一个问题,上述 API 签名验证过程的确能够保证安全性,但如果要求开发者每次调用都要自己写一套签名算法未免太过繁琐。理想的情况是,开发者只需要关注调用哪些接口、传递哪些参数即可,就跟调用自己写的代码一样简单。
因此,需要开发一个简单易用的 SDK,给调用接口的开发者提供一个 starter,引入后,直接在 application.yml 中编写配置,即可自动创建客户端,更加方便开发者使用。类似于 spring boot 中的各种 starter,如引入 mybatis、redis、swagger 接口文档等。
<denpendency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.1</version>
</denpendency>
引入 starter 后,可以直接在 application.yml 配置文件中编写相关配置,如 redis。
# redis 配置
redis:port: 6379host: localhostdatabase: 0
2. 开发流程
首先创建一个 spring boot 项目,并选择引入 Lombok、Spring Configuration Processor 依赖。Spring Configuration Processor 能够帮助开发者自动生成配置的代码提示。
然后进入 pom.xml 配置文件,删除一些不需要的依赖配置,比如测试类和 maven 构建项目的依赖(build 的内容)。
我们的目标是为用户生成一个可用的客户端对象,并且用户引入 starter 后能够直接使用,因此创建一个配置类 KApiClientConfig:
java">import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;// 通过 @Configuration 注解,将该类标记为一个配置类,告诉 Spring 这是一个用于配置的类
@Configuration
// 能够读取application.yml的配置,读取到配置之后,把这个读到的配置设置到我们这里的属性中,
// 这里给所有的配置加上前缀为"kapi.client"
@ConfigurationProperties("kapi.client")
// @Data 注解是一个 Lombok 注解,自动生成了类的getter、setter方法
@Data
// @ComponentScan 注解用于自动扫描组件,使得 Spring 能够自动注册相应的 Bean
@ComponentScan
public class KApiClientConfig {private String accessKey;private String secretKey;// 创建一个名为 kApiClient 的 Bean@Beanpublic KApiClient kApiClient() {// 使用 aK 和 sK 创建一个 KApiClient 实例return new KApiClient(accessKey, secretKey);}
}
将接口项目中的 client 包、model 包和 utils 包复制粘贴过来,注意添加 hutool 工具类的依赖,
给 KApiClient 类重新引入包,删除多余注解,完整代码如下:
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.khr.kapiclientsdk.model.User;import java.util.HashMap;
import java.util.Map;import static com.khr.kapiclientsdk.utils.SignUtils.genSign;/*** 调用第三方接口的客户端**/
public class KApiClient {private String accessKey;private String secretKey;public KApiClient(String accessKey, String secretKey) {this.accessKey = accessKey;this.secretKey = secretKey;}public String getNameByGet(String name) {// 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中HashMap<String, Object> paramMap = new HashMap<>();paramMap.put("name", name);String result = HttpUtil.get("http://localhost:8123/api/name/", paramMap);System.out.println(result);return result;}public String getNameByPost(String name) {// 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中HashMap<String, Object> paramMap = new HashMap<>();paramMap.put("name", name);String result= HttpUtil.post("http://localhost:8123/api/name/", paramMap);System.out.println(result);return result;}private Map<String, String> getHeaderMap(String body) {Map<String, String> hashMap = new HashMap<>();hashMap.put("accessKey", accessKey);// 一定不能直接发送// hashMap.put("secretKey", secretKey);hashMap.put("nonce", RandomUtil.randomNumbers(4));hashMap.put("body", body);hashMap.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));hashMap.put("sign", genSign(body, secretKey));return hashMap;}public String getUserNameByPost(User user) {String json = JSONUtil.toJsonStr(user);HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name/user").addHeaders(getHeaderMap(json)).body(json).execute();System.out.println(httpResponse.getStatus());String result = httpResponse.body();System.out.println(result);return result;}
}
最后,还需要创建一个 properties 文件用于指定要自动配置的类。在 resources 目录下创建 META-INF 目录,并在 META-INF 下新建一个 spring.factories 文件。
该文件定义了 Spring Boot 的自动配置,文件中每一行都是一个配置项,包含两个部分,= 前面是配置项的全限定类名,后面是对应的自动配置类。Spring Boot 应用启动时,会加载这个文件,并根据其中的配置项自动进行相应配置。
# spring boot starter
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.khr.kapiclientsdk.KApiClientConfig
上述配置指定的要自动配置的类就是刚才编写的配置类 KApiClientConfig,当 Spring Boot 启动时会自动加载并实例化 KApiClientConfig,将其应用在程序中。这样就可以自动生成一个客户端对象,而无需手动创建,大大简化了开发者的调用过程。
将该 SDK 项目打包后,会保存在 maven 仓库中。
这样回到之前的接口项目,删除 client、model、utils 包和测试类,然后引入刚才写好的 SDK 依赖,在 application.yml 配置文件中编写配置,指定自己的 aK、sK,即可创建客户端:
kapi:client:access-key:khr123secret-key:1q2w3e4r