一、WebSocket在NestJS中鉴权时容易踩的坑

很多开发者用NestJS做WebSocket网关的时候,第一反应是在连接时做个鉴权,比如检查有没有传token,但容易忽略几个关键的安全点,就像开公司大门只查一次工牌,员工走到办公室后门就不管了,结果恶意人员混了进来。

1.1 只做一次握手鉴权,不跟进后续连接状态

举个例子,在握手时验证了token是对的,允许用户连进来,但这个token可能10分钟后就过期了,用户还在保持连接,网关不会再主动校验,就会产生无效连接,占用服务器资源。很多人忘了,握手只是建立连接的起点,后续的所有数据交互也需要确保合法性。

1.2 握手时没过滤非法请求来源

比如,别人故意模拟大量的WebSocket握手请求,打满服务器的连接池,让合法用户连不上,这是DOS攻击的一种。如果在握手阶段只查token,不检查请求的来源IP、请求频率,就很容易被这种攻击盯上。

1.3 凭证传递方式不安全

很多人把token放在握手请求的URL参数里,抓包工具能直接看到,就像把工牌挂在胸口,路过的人都能看到。如果别人拿到你的token,就能用你的身份连进WebSocket网关,做非法操作。

二、握手阶段安全校验与心跳保活的协同逻辑

这两个机制就像公司的"进门检查"和"保安巡逻",进门查一次工牌,保安每隔一段时间巡逻确认里面的人还在,两个配合才能保证连接既安全又稳定,不会随便掉线。

2.1 握手阶段的安全校验怎么做

在NestJS里,我们可以用网关的handleConnection方法,在建立连接的瞬间做校验,关键是:① 凭证要放在安全的地方(比如请求头,而不是URL参数);② 不仅要验证凭证格式对不对,还要看有没有过期、是不是合法签发的;③ 额外过滤异常请求,比如短时间内握手太频繁的IP直接处理。下面是完整的代码示例,技术栈为NestJS 10.x、@nestjs/websockets、@nestjs/platform-ws、jsonwebtoken:

import { WebSocketGateway, OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, WebSocketServer } from '@nestjs/websockets';
import { Server, WebSocket } from 'ws';
import { JwtService } from '@nestjs/jwt';

// WebSocket网关,监听8080端口的WS连接
@WebSocketGateway(8080, { transports: ['websocket'], cors: { origin: '*' } })
export class WsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer() server: Server;
  // 存每个客户端的心跳定时器,方便后续清理(避免内存泄漏)
  private heartbeatIntervals = new Map<string, NodeJS.Timeout>();
  // 存客户端最后心跳时间,用来判断是否失联
  private clientLastHeartbeat = new Map<string, number>();

  constructor(private readonly jwtService: JwtService) {}

  // 网关启动时的初始化操作
  afterInit(server: Server) {
    console.log('WebSocket网关启动,监听8080端口');
    // 全局定时检查所有客户端的心跳,每30秒执行一次
    setInterval(() => this.checkHeartbeat(), 30000);
  }

  // 握手阶段:客户端连接时的核心安全校验
  async handleConnection(client: WebSocket & { handhsake: any }) {
    // 从握手请求的query里拿token(实际项目要改从请求头取,更安全)
    const token = client.handhsake.query?.token;
    if (!token) {
      client.close(401, '缺少认证凭证'); // 没token直接断开,拒绝连接
      return;
    }

    try {
      // 验证token的有效性:是否过期、是不是合法签发的
      const user = await this.jwtService.verifyAsync(token, { secret: 'your-secret-key' });
      // 把用户id挂在客户端对象上,方便后续识别
      client['userId'] = user.sub;
      // 初始化该客户端的心跳时间(当前时间戳)
      this.clientLastHeartbeat.set(user.sub, Date.now());
      // 给每个合法客户端设置心跳定时器:每15秒发一次ping信号
      const interval = setInterval(() => {
        client.send(JSON.stringify({ type: 'ping' }));
      }, 15000);
      this.heartbeatIntervals.set(user.sub, interval);
    } catch (err) {
      client.close(401, '凭证无效或已过期'); // token失效,断开连接
      return;
    }

    console.log(`合法用户 ${client['userId']} 已连接`);
  }

  // 处理客户端发来的消息(包含心跳响应)
  handleMessage(client: WebSocket, message: string) {
    const data = JSON.parse(message);
    const userId = client['userId'];
    if (!userId) return;

    // 如果收到的是心跳响应(约定叫"pong"),更新该客户端的最后心跳时间
    if (data.type === 'pong') {
      this.clientLastHeartbeat.set(userId, Date.now());
    }
    // 此处可以添加业务消息处理逻辑,比如聊天消息、数据上报等,示例省略
  }

  // 客户端断开连接时的清理操作(避免内存泄漏)
  handleDisconnect(client: WebSocket) {
    const userId = client['userId'];
    if (userId) {
      // 清除该客户端的心跳定时器
      const interval = this.heartbeatIntervals.get(userId);
      if (interval) clearInterval(interval);
      // 清理对应的记录
      this.heartbeatIntervals.delete(userId);
      this.clientLastHeartbeat.delete(userId);
      console.log(`用户 ${userId} 已断开连接`);
    }
  }

  // 全局检查所有客户端的心跳状态,判断是否失联
  private checkHeartbeat() {
    const now = Date.now();
    const maxHeartbeatTimeout = 30000; // 超过30秒没响应就认为失联
    for (const [userId, lastTime] of this.clientLastHeartbeat) {
      if (now - lastTime > maxHeartbeatTimeout) {
        // 找到对应的客户端实例,主动断开连接
        const client = Array.from(this.server.clients).find(c => c['userId'] === userId);
        if (client) {
          client.close(1008, '心跳超时,连接断开');
        }
      }
    }
  }
}

2.2 心跳保活机制怎么设计

心跳的核心是"双向确认":网关每隔一段时间给客户端发一个"ping"信号,客户端收到后马上回一个"pong"信号,就像保安每隔5分钟敲你的办公室门,你应声就是还在。如果客户端超过一定时间没回,网关就认为连接断了,主动断开,避免浪费资源。刚才的代码里,网关每15秒发一次ping,全局每30秒检查一次,这个时间设置是经过权衡的:太短会让服务器白忙活,太长会导致真的掉线后还要等很久才清理。

2.3 两者协同的关键点

握手校验是"准入门槛",心跳是"后续维护",两者要配合:① 握手时把合法的客户端标识(比如用户id)和凭证状态存下来,心跳检查时就能快速确认;② 心跳的时候除了响应ping,还可以主动检查当前凭证有没有过期,一旦发现过期,马上断开,不需要等下一次握手;③ 心跳清理的时候,要把对应的内存资源清掉,比如代码里的定时器和心跳时间记录,不然会越积越多,占满服务器内存。

三、实际应用场景与技术利弊

3.1 常见应用场景

这个组合机制特别适合需要实时交互的场景:比如在线聊天软件,用户要发消息,必须保证连接是合法的,而且不会随便掉线;比如实时数据监控,设备要把数据上报到服务器,稳定的长连接才能保证数据不丢;还有在线游戏,玩家要实时操作,连接一断就会掉出游戏,所以心跳和鉴权是必备的。

3.2 技术优缺点

优点:用NestJS做WebSocket网关,框架已经帮你处理了很多底层的连接细节,比如断开后的清理、多客户端的管理,你只需要关注业务逻辑;握手和心跳结合,既保证了安全,又减少了无效连接的占用,服务器能扛更多的连接。缺点:如果心跳时间设置不合理,比如太短,会增加服务器的压力,太长又容易出现假掉线(比如网络临时波动,没有回pong,就被断开);另外,NestJS的WebSocket默认配置如果不限制跨域,容易被恶意网站调用,这也是需要注意的。

3.3 关键注意事项

第一,凭证传递要安全,不要放URL参数,应该用请求头或者在连接后的第一条业务消息里传;第二,心跳的时间要根据实际情况调整,比如移动端的网络波动大,心跳时间可以设短一点,PC端可以长一点;第三,一定要处理客户端的重连逻辑,比如客户端断开后,自动尝试重新连接,并且用新的token;第四,不要忽略内存泄漏,每个客户端的定时器和记录都要在断开时删掉,不然服务器跑久了会变慢。

四、总结

WebSocket网关在NestJS里做鉴权,最容易遗漏的就是"只查一次握手,不跟进后续状态",而握手校验和心跳保活的配合,就是解决这个问题的核心。简单来说,握手是把好入口,心跳是守住后续的连接状态,两者一起,既能保证只有合法用户能连进来,又能让连接稳定不中断,不会被无效连接拖垮服务器。实际项目里,一定要把这两个机制结合起来,注意细节的配置,比如凭证安全、心跳时间、内存清理,这样才能写出稳定又安全的长连接服务。