攻与防:Nest.js对全局API进行签名认证

⚠️ 警告:添加签名认证之后,安全性更高,这是毋庸置疑的。但并不意味着 API 就一定安全,一定不会被滥用了。

观察签名认证流程就可以发现,加签的步骤是在客户端本地进行的,这意味着攻击者只要不怕掉头发,契而不舍的进行逆向,还是可以把加签的逻辑和密钥逆向出来的。

归根究底,这是攻击者和防御者两方之间的对抗。为了更好的效果,在有必要的情况下完全可以使用一些欺诈的技俩。 比如:对签名动手脚,你以为我加签了,但是签名其实是假的,签名仅仅是为了配合请求中某些不起眼的地方,再统一进行校验。 比如:满足一定的条件,随机在请求体内加入一个不可见字符,或者是随机替换某一处地方。

总之就是:虚虚实实,真假难辨。

虽然最后都可以逆向出来,但是更大的可能是——攻击者还没有逆向出来,就被埋的大大小小的陷阱搞的心态崩掉,怀疑人生,自己放弃了。

  • 在 Web 开发过程中,一般都会对系统业务进行身份认证(Authentication​​​​​)和权限认证(Authorization​​​​​)。

  • 除此之外,如果对 API 安全性有更高的要求,还会添加一些额外的安全验证措施,签名认证就是典型的一类应用。

  • 签名认证一般用于保护 API,防止未经授权的访问或者是屏蔽被篡改的请求。签名认证一般不会和系统内的业务逻辑产生紧耦合,它主要是对 API 请求本身进行鉴权。

  • 签名认证的基本流程是:在 API 调用时使用密钥和一些参数(例如时间戳)生成一个签名,该签名在请求中被发送,并且会在服务器端进行比较。如果请求未被篡改且包含有效签名,则正常处理业务逻辑。否则,抛出错误,或者是直接限制该 IP 的访问。

  • 加密方式常用的有两种:

      1. 公钥加密、私钥解密 => 即非对称加密
      2. 使用 HMAC 生成消息认证码,验证认证码是否一致 => 即对称加密
    • 这两种方式都可以,这里使用对称加密进行演示。
  • 第一步,客户端生成签名,因为客户端可能的环境太多了,这里使用 TypeScript​ 简单演示一下,具体的还是需要根据自己的应用环境做调整,整个流程和逻辑都是通用的,核心思想就是在原有参数的基础上附加用于签名认证的参数。如:nonceKey​,timestamp​,sign

    • import hmacSHA256 from "crypto-js/hmac-sha256";
      const SIGN_SECRET = "这里是密钥,记得生成一个足够复杂的密钥";
      
      /**
       * 请求拦截器
       * @param options 请求选项
       */
      const reqInterceptor = async (options: Options) => {
        const timestamp = +new Date();
      
        // 在原参数的基础上,新增随机认证参数
        // options.data 在这里代表实际的请求参数
        // 对于GET请求,它是QueryString的对象表示,在Nest.js中,可使用query获取到
        // 对于POST请求,它是JSON的对象表示,在Nest.js中,可以使用body获取到
        // 针对自己的业务场景进行具体的处理就好,只需要附加上额外的参数就行
        options.data = Object.assign(options.data, {
          // 任何随机生成的算法都可以
          nonceKey: Math.round(Math.random() * (999999 - 100000) + 100000),
          // 打上时间戳,防止请求被恶意重复利用
          timestamp,
        });
      
        const { data } = options;
      
        // 将请求对象排序,并转换成 key1=value1&key2=value2 的形式
        // 就是为了生成签名,并且方便在服务端进行验签
        const sortedString = Object.keys(data)
          .sort()
          .map((key) => {
            return `${key}=${data[key]}`;
          })
          .join("&");
        // 获取到HMAC对象
        const hmac = hmacSHA256(sortedString, SIGN_SECRET);
        // 然后获取签名字符串
        const sign = hmac.toString();
        // 将签名附加到请求参数上
        options.data.sign = sign;
        return options;
      };
      
      // 应用请求拦截器之后,所有的请求都会附加上签名相关的参数
      // 如 GET /index 的请求,会变成:GET /index?nonceKey=xxx&timestamp=xxx&sign=xxx
      // 如 POST /index 的请求,请求体里会变成:
      // {
      //   "nonceKey": "xxx",
      //   "timestamp": "xxx",
      //   "sign": "xxx"
      // }
      
  • Nest.js 服务端进行签名验证:

    • 这里我选择使用 Nest.js 的守卫机制来实现,当然用中间件也可以实现
    • // common/guards/signature.guard.ts
      import { Injectable, CanActivate, ExecutionContext, HttpStatus } from "@nestjs/common";
      import { createHmac } from "crypto";
      import { FastifyRequest } from "fastify";
      import { omit, unset } from "lodash";
      import { ApiException } from "../exceptions/api.exception";
      
      @Injectable()
      export class SignatureGuard implements CanActivate {
        /** 签名密钥,和客户端保持一致 */
        private readonly secret = "";
      
        /** 返回true,才代表通过认证 */
        canActivate(context: ExecutionContext): boolean {
          const ctx = context.switchToHttp();
          // 注意,这里我的Nest.js底层使用的HTTP框架是Fastify
          // 如果你使用的Express,下面的一点逻辑需用Express的方法
          const request = ctx.getRequest<FastifyRequest>();
          const { method, query, body } = request;
          const reqData = method === "GET" ? query : body;
      
          if (this.checkSinatrue(reqData as any)) {
            return true;
          }
          throw new ApiException("鉴权失败", HttpStatus.FORBIDDEN);
        }
      
        /** 检查签名 */
        checkSinatrue(reqData: {
          [key: string]: any;
          timestamp: string;
          sign: string;
          nonceKey: string;
        }) {
          // 判断参数是否都传了
          if (!reqData.nonceKey || !reqData.timestamp || !reqData.sign) {
            return false;
          }
          const { timestamp, sign } = reqData;
      
          // 验证时间戳
          const now = +new Date();
          if (now - +timestamp > 30 * 1000) {
            // 30秒之前的请求禁止访问
            return false;
          }
      
          // 也转换成key1=value1&key2=value2的格式
          // 保证这里的获取到的字符串和客户端里的字符串完全一致
          // Lodash的omit方法是为了从对象中删掉sign属性
          // 因为在客户端中,就只有nonceKey和timestamp参与了生成签名的运算
          const sortedString = Object.keys(omit(reqData, ["sign"]))
            .sort()
            .map((key) => {
              return `${key}=${reqData[key]}`;
            })
            .join("&");
      
          // 服务端重新生成签名并进行验证
          const hmac = createHmac("sha256", this.secret);
          hmac.update(sortedString);
          if (sign !== hmac.digest("hex")) {
            return false;
          }
      
          // 认证成功后移除签名相关的数据
          unset(reqData, ["nonceKey", "sign", "timestamp"]);
          return true;
        }
      }
      
    • 最后在 main.ts 中,使用 app.useGlobalGuards(new SignatureGuard());​​ ​全局使用这个守卫即可。
    • 这样,所有过来的请求,都必须携带我们上面定义的签名参数,不然就直接返回 403,提示鉴权失败。
    • 业务中还很可能添加一些例外,比如支付回调 API,比如检查更新 API 等等……
    • 可以在上面的守卫中新增一个检测白名单的方法,如果检测到请求的路径在白名单中,就直接返回 true​​。再或者,就不直接全局应用签名守卫了,只在需要的控制器上使用 @UseGuards(SignatureGuard)​ ​装饰器来应用签名守卫。