158 lines
5.0 KiB
TypeScript
158 lines
5.0 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 || '';
|
||
|
|
if (redisToken !== token) {
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|