概述
Q:什么是 AK/SK?
A:AK/SK(Access Key/Secert Key) 是一组用于鉴权的密钥对,类比常见的账号密码体系,AK 相当于账号,SK 相当于密码,只不过 AK/SK 一般是给开放平台使用的鉴权手段,一个用户可以设置多个 AK/SK,并且可以精细控制每一组 Key 的权限范围。
Q:为什么需要 AK/SK?
A:当我们的平台需要对外提供 API 服务时,就需要对调用者进行鉴权处理。要如何识别是哪个用户进行的操作,一种是token,用户授权后生成一个唯一标识,后续调用服务时根据这个token识别用户,但是这种方案风险性较高,其他人通过请求抓包可以拦截到token进行调用;另一种安全性较高的手段就是通过 AK/SK 的形式,AK 标识用户或者应用,SK 作为密钥将 timestamp、nonce 等信息计算出签名,客户端和服务端通过相同的 SK 计算得到的签名是一致的,如果请求被拦截,nonce 已经被使用过可以避免请求重放,timestamp 可以保证签名的有效期。因为签名由 SK 生成,所以只要 SK 不泄漏,就可以保证服务的安全。
流程
- 首先在平台生成 AK/SK,客户端将 AK/SK 保存起来。后续每次调用平台服务之前,通过 SK 将当前的时间戳 timestamp 和 请求唯一标识 nonce 进行加密计算得到签名 signature,并将三者一起发送到服务端。
- 服务端接收到调用请求后,从请求中拿出这些参数进行校验。
- 首先是时效性校验,判断时间戳是否在有效时间内,以及 nonce 是否已经生效过。
- 然后从数据库中查询 AK/SK 是否存在。
- 最后用查询到的 SK 在对时间戳和请求标识进行加密计算,把结果与客户端的签名进行比对。
为什么要将时间戳和唯一标识进行加密?
它们各自解决了不同的安全问题,同时使用两者可以提供更全面的安全保障。- Timestamp: 主要用于防止请求过期。通过在请求中包含一个时间戳,服务端可以验证请求是否在允许的时间窗口内发出。这有助于避免重放攻击(replay attack)。
- Nonce: Nonce 是“数字仅一次使用”的缩写,它确保了每个请求的唯一性。即使两个请求发生在同一秒内并且其他参数完全相同,由于 nonce 的不同,它们仍然会被视为独立的请求。Nonce 通常是一个随机生成的字符串或者递增的计数器值,在一段时间内必须是唯一的。
整个 AK/SK 鉴权的交互过程如下
实战
实战阶段我们直接通过 MidwayJS 来完成,首先来🧠脑暴一下我们的AKSK实现思路,首先我们需要在数据库层面存储AKSK相关的数据记录;然后我们要有对这部数据增删改查的能力;其次需要实现我们上面两小节提到的签名校验能力,这部分可以通过中间件的形式实现。
最终,我们需要实现的整体代码架构如下
AKSK 怎删改查相关的接口通过常规的鉴权手段控制,OpenAPI 开放平台接口通过AKSK进行鉴权,对需要AKSK保护的借口通过中间件进行拦截,对请求中的签名进行校验,这里我们只贴核心代码了,剩下的增删改查的代码可以自己去仓库查看。
最核心的其实就是AKSK鉴权的中间件
import { Middleware, IMiddleware, Logger, ILogger } from '@midwayjs/core';
import { NextFunction, Context } from '@midwayjs/koa';
import { AKSKService } from '../service/aksk.service';
import * as crypto from 'crypto';
const nonceList: Array<string> = [];
@Middleware()
export class AKSKMiddleware implements IMiddleware<Context, NextFunction> {
@Logger()
logger: ILogger;
resolve() {
return async (ctx: Context, next: NextFunction) => {
const akskService = await ctx.requestContext.getAsync<AKSKService>(
AKSKService
);
const ak = ctx.get('x-ak');
const nonce = ctx.get('x-nonce');
const timestamp = ctx.get('x-timestamp');
const signature = ctx.get('x-signature');
if (!ak || !nonce || !timestamp || !signature) {
this.logger.warn('缺少认证信息');
ctx.status = 401;
return { success: false, message: '无效请求' };
}
// 验证时间戳是否在有效期内(例如5分钟)
const timestampNum = parseInt(timestamp);
const now = Date.now();
if (isNaN(timestampNum) || Math.abs(now - timestampNum) > 5 * 60 * 1000) {
this.logger.warn('%s 请求过期, timestamp -> %s', ak, timestamp);
ctx.status = 401;
return { success: false, message: '无效请求' };
}
// TODO: 在实际生产环境中,这里应该使用Redis等存储nonce,防止重放攻击
if (nonceList.includes(nonce)) {
this.logger.warn('%s nonce已使用, nonce -> %s', ak, nonce);
ctx.status = 401;
return { success: false, message: '无效请求' };
}
// 保存nonce
nonceList.push(nonce);
const aksk = await akskService.getAKSKByAK(ak);
// 使用sk对nonce和timestamp进行签名
const hmac = crypto.createHmac('sha256', aksk.sk);
hmac.update(nonce + timestamp);
const calculatedSignature = hmac.digest('hex');
// 验证签名
const isValid = calculatedSignature === signature;
if (!isValid) {
ctx.status = 401;
this.logger.warn('%s 签名验证失败', ak);
return { success: false, message: '无效请求' };
}
return await next();
};
}
static getName(): string {
return 'aksk';
}
}
这里我们按照上面的介绍的AKSK鉴权流程对timestamp、nonce和signature进行校验,其中nonce应该使用Redis等手段来存储,这里我们为了简化环境直接使用了数据保存。
为了验证我们的逻辑,我们编写一个测试文件来进行单元测试。
import { createApp, close, createHttpRequest } from '@midwayjs/mock';
import { Application, Framework } from '@midwayjs/koa';
import * as crypto from 'crypto';
describe('test/controller/api.test.ts', () => {
let app: Application;
const nonce = '1111123',
timestamp = Date.now(),
ak = '8527d2683fab2da4ac2a02d8be6c07d4',
sk = 'd4a38df5a6e1b19d5c2853818cd7ac597fa5b87a8da8a1a0d6be54f2dc016724'
const hmac = crypto.createHmac('sha256', sk);
hmac.update(nonce + timestamp);
const calculatedSignature = hmac.digest('hex');
beforeAll(async () => {
// 只创建一次 app,可以复用
app = await createApp<Framework>();
});
afterAll(async () => {
// close app
await close(app);
});
it('normal get /api/get_user', async () => {
// make request
const result = await createHttpRequest(app)
.get('/api/get_user')
.set('x-ak', ak)
.set('x-nonce', nonce)
.set('x-timestamp', timestamp)
.set('x-signature', calculatedSignature)
.query({ uid: 1 });
// use expect by jest
expect(result.status).toBe(200);
expect(result.body.message).toBe('OK');
});
it('replace get /api/get_user', async () => {
// make request
const result = await createHttpRequest(app)
.get('/api/get_user')
.set('x-ak', ak)
.set('x-nonce', nonce)
.set('x-timestamp', timestamp)
.set('x-signature', calculatedSignature)
.query({ uid: 1 });
// use expect by jest
expect(result.status).toBe(401);
expect(result.body.message).toBe('无效请求');
});
it('time over get /api/get_user', async () => {
// make request
const result = await createHttpRequest(app)
.get('/api/get_user')
.set('x-ak', ak)
.set('x-nonce', nonce)
.set('x-timestamp', String(1737452047255))
.set('x-signature', calculatedSignature)
.query({ uid: 1 });
// use expect by jest
expect(result.status).toBe(401);
expect(result.body.message).toBe('无效请求');
});
});
三个case分别为正常请求、重放请求以及过期请求,可以看到测试结果如下,不合法的请求均被拦截。