Files
tauri-meeting/node_api/src/modules/websocket/meeting-auth.guard.ts
O昵称重要吗O d09d3645ea feat: 支持监控端进入并优化登录态验证
- 允许教师从加入页面选择进入监控端或课堂端
- 修改登录态验证逻辑,仅检查 Redis 中是否存在 token 而非严格相等,以支持同一账号多端登录
- 监控端默认不发布本地音视频轨道,但可通过配置动态启用
- 优化 WebSocket 鉴权失败处理,自动跳转登录页
2026-03-13 14:45:01 +08:00

159 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* WebSocket 认证守卫
* 用于验证 Socket.IO 连接的用户身份
*/
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Socket } from 'socket.io';
import { WsException } from '@nestjs/websockets';
import { RedisDatabase, RedisService } from '../../plugins/redis/redis.service';
import { LoggerService } from '../../plugins/logger/logger.service';
import { MeetingService } from '../meeting/meeting.service';
import type { MeetingWsUser } from './types';
@Injectable()
export class MeetingAuthGuard implements CanActivate {
public constructor(
private readonly redisService: RedisService,
private readonly logger: LoggerService,
private readonly meetingService: MeetingService
) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
// 获取 Socket 实例
const socket = context.switchToWs().getClient<Socket>();
// 检查是否已经通过连接认证
if (socket.data.user) {
return true;
}
try {
const user = await this.validateToken(socket);
socket.data.user = user;
return true;
} catch (e) {
if (e instanceof WsException) {
this.logger.error(e.message, 'WsAuthGuard');
throw e;
}
this.logger.error(e, 'WsAuthGuard');
throw new WsException('WebSocket 鉴权失败');
}
}
/**
* 验证 Token 并返回用户信息
* 可供 handleConnection 直接调用
*/
public async validateToken(socket: Socket): Promise<MeetingWsUser> {
// 尝试从握手信息中获取认证头(兼容大小写)
const handshake = socket.handshake;
const authHeader = String(
handshake.auth?.authorization || handshake.auth?.Authorization || handshake.headers?.authorization || handshake.headers?.Authorization || ''
);
const customMac = String(
handshake.auth?.['custom-mac'] || handshake.auth?.['Custom-Mac'] || handshake.headers?.['custom-mac'] || handshake.headers?.['Custom-Mac'] || ''
);
const customPlatform = String(
handshake.auth?.['custom-platform'] ||
handshake.auth?.['Custom-Platform'] ||
handshake.headers?.['custom-platform'] ||
handshake.headers?.['Custom-Platform'] ||
''
);
const customTimestamp = String(
handshake.auth?.['custom-timestamp'] ||
handshake.auth?.['Custom-Timestamp'] ||
handshake.headers?.['custom-timestamp'] ||
handshake.headers?.['Custom-Timestamp'] ||
''
);
// 验证必要的认证信息
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer')) {
throw new WsException('缺少或无效的 Authorization');
}
if (!customMac) {
throw new WsException('缺少 Custom-Mac');
}
if (!customPlatform) {
throw new WsException('缺少 Custom-Platform');
}
if (!customTimestamp) {
throw new WsException('缺少 Custom-Timestamp');
}
// 解析 Token
const token = authHeader.slice(7).trim();
const parts = token.split('.');
if (parts.length < 2) {
throw new WsException('无效的 Token 格式');
}
const payload: any = (() => {
try {
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
return JSON.parse(json);
} catch {
throw new WsException('无法解析 Token');
}
})();
const NAME_ID_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier';
const ROLE_CLAIM = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role';
const userId = String(payload?.[NAME_ID_CLAIM] || '');
const role = Number(payload?.[ROLE_CLAIM] || '-1');
if (!userId || isNaN(role) || role < 0) {
throw new WsException('Token 非法');
}
// 验证 Token 过期时间
const nowSec = Math.floor(Date.now() / 1000);
const exp = Number(payload?.exp || 0);
if (!Number.isFinite(exp) || nowSec >= exp) {
throw new WsException('Token 已过期');
}
const platform = customPlatform.toUpperCase();
// 验证 Redis 中的登录态
const redisKey = `Auth:${userId}:${platform}`;
const redis = this.redisService.getClient(RedisDatabase.GLOBAL);
const v = await redis.get(redisKey);
if (!v) {
throw new WsException('未找到登录态');
}
const redisObj: any = (() => {
try {
return JSON.parse(v);
} catch {
throw new WsException('登录态数据异常');
}
})();
const redisToken = redisObj?.Token || redisObj?.token || '';
// 因为会有同一个账号同平台登录多个监控端和教师端使用同一个账号也都是web所以不做过多验证
if (!redisToken) {
throw new WsException('登录态已失效');
}
// 查询数据库获取短 UID 和用户名
const shortUid = await this.meetingService.getOrAssignShortUid(Number(userId));
// 从 Redis 登录态中获取用户名
const userName = redisObj?.userName || redisObj?.UserName || `用户-${userId}`;
console.log(`[WsAuthGuard] 用户 ${userId} 获得短 UID: ${shortUid}, userName: ${userName}`);
return {
userId,
role,
shortUid,
platform,
userName,
};
}
}