youyichannel

志于道,据于德,依于仁,游于艺!

0%

什么是 API 签名认证?

背景

首先思考一个重要的问题:如果我们为开发者提供了一个接口,却对于调用者一无所知,这是一件很恐怖的事情。

假设我们的 Server 只能够允许 100 个人同时调用接口。如果有攻击者疯狂请求这个接口,这是很危险的。一方面可能会损害我们的安全性,另一方面也可能耗尽 Server 性能,影响正常用户的使用。

因此我们必须为接口设置保护措施,例如限制每个用户每秒只能调用十次接口,即实施请求频次的限额控制。

如果在后期,我们的业务扩大,可能还需要收费。因此,我们必须知道谁在调用接口,并且不能让无权限的人随意调用。

那么现在,我们需要设计一个方法,来确定谁在调用接口。在我们之前开发后端时,我们会进行一些权限检查。例如,当管理员执行删除操作时,后端需要检查这个用户是否为管理员。那么,我们如何获取用户信息呢,是否直接从后端的 session 中获取?但问题来了,当我们调用接口时,我们有 session 吗?比如说,前端直接发起请求,没有登录操作,没有输入用户名和密码,这个时候怎么去调用呢?因此,一般情况下,我们会采用一个叫API签名认证的机制。这是一个重要的概念。

什么是 API 签名认证?

简单来说,如果你想来我家做客,我不可能随便让任何陌生人进来。所以我会提前给你发一个类似于请帖的东西,作为授权或许可证。当你来访问我的时候,你需要带上这个许可证。我可能并不认识你,但我认识你的请帖,只要你有这个请帖,我就允许你进来。

所以,API签名认证主要包括两个过程,

  1. 签发签名
  2. 使用签名和校验签名。

为什么需要签名?

为了保证安全性,不能让任何人都可以调用接口。

如何实现呢?

我们需要两个 Key,即 accessKeysecretKey 。这跟用户名和密码类似,不过每次调用接口都需要带上,实现无状态的请求。这样,即使之前没有来过,这次的状态正确也可以调用接口,所以我们需要这两个东西来标识用户。

签名认证实现

通过 HTTP Request Header 传递参数:

  • 参数1:accessKey,调用的标识,复杂、无序、无规律
  • 参数2:secretKey,密钥,复杂、无序、无规律,该参数不直接放在请求头中,而是通过某种算法生成签名

类似于 用户名 / 密码,区别是 AK / SK 是无状态的。

  • 参数3:用户请求参数
  • 参数4:Sign,签名

加密方式

对称加密、非对称加密、MD5摘要等等。

过程:

graph LR
    left[用户参数 + 密钥] -- 签名生成算法 --> right["签名(不可解密的值)"]

如何校验签名呢?

服务端在拿到用户传递的参数时,只需要使用一模一样的参数和算法生成签名,只要和用户传递的一致,就表示正确。

如何防止请求重放?

  • 参数5:nonce,随机数,只能用一次,服务端要保存使用过的随机数
  • 参数6:timestamp,时间戳,校验时间戳是否过期

第一种方式是通过加入一个随机数(nonce)实现标准的签名认证。每次请求时,发送一个随机数给后端。后端只接受并认可该随机数一次,一旦随机数被使用过,后端将不再接受相同的随机数。这种方式解决了请求重放的问题,因为即使对方使用之前的时间和随机数进行请求,后端会认识到该请求已经被处理过,不会再次处理。然而,这种方法需要后端额外开发来保存已使用的随机数。并且,如果接口的并发量很大,每次请求都需要一个随机数,那么可能会面临处理百万、千万甚至亿级别请求的情况。因此,除了使用随机数之外,我们还需要其他机制来定期清理已使用的随机数。

第二种方式是加入一个时间戳(timestamp)。每个请求在发送时携带一个时间戳,并且后端会验证该时间戳是否在指定的时间范围内,例如不超过10分钟或5分钟。这可以防止对方使用昨天的请求在今天进行重放。通过这种方式,我们可以一定程度上控制随机数的过期时间。因为后端需要同时验证这两个参数,只要时间戳过期或随机数被使用过,后端会拒绝该请求。因此,时间戳可以在一定程度上减轻后端保存随机数的负担。通常情况下,这两种方法可以相互配合使用。

总结

在标准的签名认证算法中,建议至少添加以下五个参数:accessKeysecretKeysignnonce(随机数)、timestamp(时间戳)。此外,建议将用户请求的其他参数,例如接口中的 name 参数,也添加到签名中,以增加安全性。

需要注意的是,API 签名认证是一个很灵活的设计,具体要有哪些参数、参数名要根据实际的场景来(比如 userId、appId、version、固定值等)

一些问题

为什么需要两个 Key?

如果仅凭一个 Key 就可以调用接口,那么任何拿到这个 Key 的人都可以无限制的调用这个接口。这就好比,为什么你在登录网站的时候需要输入密码,而不是仅仅输入用户名就可以了?这二者的原理是一样的。

安全传递性

密钥一定不能传递。也就是说,在向对方发送请求时,密钥绝对不能以明文的形式传递,必须通过特殊的方式进行传递。在标准的API签名认证中,我们需要传递一个签名,这个签名是根据密钥生成的。

简单示例

在用户表中添加字段:

create table if not exists user
(
id bigint auto_increment comment 'id' primary key,
# ...
accessKey varchar(64) null comment 'accessKey',
secretKey varchar(64) null comment 'secretKey',
# ...
) comment '用户表';

用户在申请 ak / sk 时,使用随机生成算法生成:

public static String[] genUserKey(String salt) {
String accessKey = RandomStringUtils.randomAlphabetic(UserConstants.USER_KEY_LEN);
String secretKey = DigestUtils.sha256Hex(salt + accessKey).substring(0, UserConstants.USER_KEY_LEN);
return new String[]{accessKey, secretKey};
}

使用拦截器或者网关层做用户信息的校验:

private User doUserAuth(ServerHttpRequest request) {
HttpHeaders headers = request.getHeaders();
String accessKey = headers.getFirst("accessKey");
String nonce = headers.getFirst("nonce");
String timestamp = headers.getFirst("timestamp");
String body;
try {
body = URLDecoder.decode(Objects.requireNonNull(headers.getFirst("body")), "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
String sign = headers.getFirst("sign");

User invokeUser = rpcUserService.getUserByAccessKey(accessKey);
if (Objects.isNull(invokeUser)) {
throw new RuntimeException("无权限");
}

// 实际上是根据ak去数据库查询是否分配给用户
if (!invokeUser.getAccessKey().equals(accessKey)) {
throw new RuntimeException("无权限");
}

// todo 校验随机数,实际上还要查看服务器端是否有这个随机数,可以使用Redis存储
assert nonce != null;
if (nonce.length() != 20) {
throw new RuntimeException("无权限");
}

// 校验时间戳,和当前时间相差5分钟以内
if (!validateTimestamp(timestamp)) {
throw new RuntimeException("无权限");
}

// 校验sign, 实际上的sk是从数据库中查出来的
String serverSign = SignUtils.genSign(body, invokeUser.getSecretKey());
if (!serverSign.equals(sign)) {
throw new RuntimeException("无权限");
}
return invokeUser;
}

SDK

为什么需要 SDK?

理想情况:开发者只需要关心调用那些接口、传递哪些参数,就跟调用自己写的代码一样简单。

开发Starter的好处:开发者引入Starter之后,可以直接在application.yml中写配置,自动创建客户端。

如果按照我们上述的过程,用户在请求接口时,可能需要自己组装签名、随机数、时间戳等等,这是很不友好的,比如:

private Map<String, String> getHeaders(String body) {
Map<String, String> headers = new HashMap<>();
headers.put("accessKey", accessKey);
headers.put("nonce", RandomUtil.randomNumbers(20));
try {
headers.put("body", URLEncoder.encode(body, "UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
headers.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
headers.put("sign", SignUtils.genSign(body, secretKey));
return headers;
}

这也不利于统一。

进一步说明

为了方便开发者的调用,我们不能让他们每次都自己编写签名算法,这显然很繁琐。因此,我们需要开发一个简单易用的 SDK,使开发者只需关注调用哪些接口、传递哪些参数,就像调用自己编写的代码一样简单。实际上,RPC(远程过程调用)就是为了实现这一目的而设计的。RPC它就是追求简单化调用的理想情况。类似的例子是小程序开发或者调用第三方 API,如腾讯云的 API,它们都提供了相应的 SDK。

如何开发Starter?

  • 确认所需要的依赖,需要引入spring-boot-configuration-processor(配置文件编写提示)
  • 过程代码编写
  • 配置类编写
  • resources目录下创建META-INF/spring.factories,其中配置自动配置类org.springframework.boot.autoconfigure.EnableAutoConfiguration=xxx
@Data
@Configuration
@ConfigurationProperties("xxx.api.client")
@ComponentScan
public class MockApiConfig {
private String accessKey;
private String secretKey;

@Bean
public MockApiClient mockApiClient() {
return new MockApiClient(accessKey, secretKey);
}
}
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
xxx.sdk.config.MockApiConfig

一些问题

明确一个核心点:一旦接口投入使用后,肯定要针对这个接口进行 SDK 的开发。你需要不断地完善 SDK,使其适应不断变化的需求。当然,也可以让 SDK 从接口信息表中读取信息,然后动态生成方法等等,这种做法也是可行的,然而,并不建议这样做。实际上,接口的发布可能不太像我们在应用商店发布应用那样灵活,在这种接口的发布过程中,建议还是介入一点人工。

每次发布一个新的接口,都需要对 SDK 进行更新。建议的做法是这样的:由于用户并不关心具体的接口地址,因此你可以让他们直接调用方法名,然后根据这些方法名去动态生成对应的方法。每次发布新接口时,更新 SDK 的操作可以做得非常简单,可以采用脚本的方式,从数据库中读取接口地址,与之前已有的地址进行对比,然后补充相应的方法,这个过程是可行的,事实上,很多公司都在这样做。

推荐参考:[美团开放平台SDK自动生成技术与实践](