概述

image.png

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 不泄漏,就可以保证服务的安全。

流程

  1. 首先在平台生成 AK/SK,客户端将 AK/SK 保存起来。后续每次调用平台服务之前,通过 SK 将当前的时间戳 timestamp 和 请求唯一标识 nonce 进行加密计算得到签名 signature,并将三者一起发送到服务端。
  2. 服务端接收到调用请求后,从请求中拿出这些参数进行校验。
  3. 首先是时效性校验,判断时间戳是否在有效时间内,以及 nonce 是否已经生效过。
  4. 然后从数据库中查询 AK/SK 是否存在。
  5. 最后用查询到的 SK 在对时间戳和请求标识进行加密计算,把结果与客户端的签名进行比对。
    image.png

    为什么要将时间戳和唯一标识进行加密?
    它们各自解决了不同的安全问题,同时使用两者可以提供更全面的安全保障。

    1. Timestamp: 主要用于防止请求过期。通过在请求中包含一个时间戳,服务端可以验证请求是否在允许的时间窗口内发出。这有助于避免重放攻击(replay attack)。
    2. Nonce: Nonce 是“数字仅一次使用”的缩写,它确保了每个请求的唯一性。即使两个请求发生在同一秒内并且其他参数完全相同,由于 nonce 的不同,它们仍然会被视为独立的请求。Nonce 通常是一个随机生成的字符串或者递增的计数器值,在一段时间内必须是唯一的。

整个 AK/SK 鉴权的交互过程如下
image.png

实战

实战阶段我们直接通过 MidwayJS 来完成,首先来🧠脑暴一下我们的AKSK实现思路,首先我们需要在数据库层面存储AKSK相关的数据记录;然后我们要有对这部数据增删改查的能力;其次需要实现我们上面两小节提到的签名校验能力,这部分可以通过中间件的形式实现。

最终,我们需要实现的整体代码架构如下

image-20250121224611572

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分别为正常请求、重放请求以及过期请求,可以看到测试结果如下,不合法的请求均被拦截。

testResult


前端小白