攻与防:Nest.js对全局API进行签名认证
⚠️ 警告:添加签名认证之后,安全性更高,这是毋庸置疑的。但并不意味着 API 就一定安全,一定不会被滥用了。
观察签名认证流程就可以发现,加签的步骤是在客户端本地进行的,这意味着攻击者只要不怕掉头发,契而不舍的进行逆向,还是可以把加签的逻辑和密钥逆向出来的。
归根究底,这是攻击者和防御者两方之间的对抗。为了更好的效果,在有必要的情况下完全可以使用一些欺诈的技俩。 比如:对签名动手脚,你以为我加签了,但是签名其实是假的,签名仅仅是为了配合请求中某些不起眼的地方,再统一进行校验。 比如:满足一定的条件,随机在请求体内加入一个不可见字符,或者是随机替换某一处地方。
总之就是:虚虚实实,真假难辨。
虽然最后都可以逆向出来,但是更大的可能是——攻击者还没有逆向出来,就被埋的大大小小的陷阱搞的心态崩掉,怀疑人生,自己放弃了。
在 Web 开发过程中,一般都会对系统业务进行身份认证(
Authentication
)和权限认证(Authorization
)。除此之外,如果对 API 安全性有更高的要求,还会添加一些额外的安全验证措施,签名认证就是典型的一类应用。
签名认证一般用于保护 API,防止未经授权的访问或者是屏蔽被篡改的请求。签名认证一般不会和系统内的业务逻辑产生紧耦合,它主要是对 API 请求本身进行鉴权。
签名认证的基本流程是:在 API 调用时使用密钥和一些参数(例如时间戳)生成一个签名,该签名在请求中被发送,并且会在服务器端进行比较。如果请求未被篡改且包含有效签名,则正常处理业务逻辑。否则,抛出错误,或者是直接限制该 IP 的访问。
加密方式常用的有两种:
- 公钥加密、私钥解密 => 即非对称加密
- 使用 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×tamp=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)
装饰器来应用签名守卫。