一. 接口调用
对于开发者来说,接口的调用应当是方便快捷的,而且出于安全考虑,通常会选择在后端调用第三方 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("/user")
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);
// 返回构造的请求头 map
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())
.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));
// 生成签名
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 工具库中的加密算法工具类(摘要加密),直接将生成签名当作一个工具类使用,
/**
* 签名工具
*/
public class SignUtils {
/**
* 生成签名
* @param body 请求体内容(用户参数)
* @param secretKey 密钥
* @return 生成的签名字符串
*/
public static String genSign(String body, String secretKey) {
// 使用 MD5 算法的 Digester
Digester md5 = new Digester(DigesterAlgorithm.MD5);
// 构建签名内容,将哈希映射转换为字符串并拼接密钥
String content = body + "." + secretKey;
// 计算签名的摘要并返回摘要的十六进制表示形式
return md5.digestHex(content);
}
}
服务端的校验:
@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: 6379
host: localhost
database: 0
2. 开发流程
首先创建一个 spring boot 项目,并选择引入 Lombok、Spring Configuration Processor 依赖。Spring Configuration Processor 能够帮助开发者自动生成配置的代码提示。
然后进入 pom.xml 配置文件,删除一些不需要的依赖配置,比如测试类和 maven 构建项目的依赖(build 的内容)。
我们的目标是为用户生成一个可用的客户端对象,并且用户引入 starter 后能够直接使用,因此创建一个配置类 KApiClientConfig:
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
@Bean
public KApiClient kApiClient() {
// 使用 aK 和 sK 创建一个 KApiClient 实例
return new KApiClient(accessKey, secretKey);
}
}
将接口项目中的 client 包、model 包和 utils 包复制粘贴过来,注意添加 hutool 工具类的依赖,
给 KApiClient 类重新引入包,删除多余注解,完整代码如下:
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:khr123
secret-key:1q2w3e4r