- 允许教师从加入页面选择进入监控端或课堂端 - 修改登录态验证逻辑,仅检查 Redis 中是否存在 token 而非严格相等,以支持同一账号多端登录 - 监控端默认不发布本地音视频轨道,但可通过配置动态启用 - 优化 WebSocket 鉴权失败处理,自动跳转登录页
159 lines
5.1 KiB
TypeScript
159 lines
5.1 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
}
|
||
}
|