chore: 初始化项目基础结构和资源文件
- 添加项目图标文件(app-icon.png、各平台图标) - 配置开发环境文件(.env、.nvmrc、.npmrc) - 添加静态资源文件(背景图片、字体、音频) - 初始化Tauri后端结构(build.rs、main.rs、模块文件) - 配置前端项目结构(TypeScript、Vue组件、样式) - 添加Node.js API服务基础结构 - 配置构建和开发工具(vite、prettier、gitignore)
This commit is contained in:
157
node_api/src/modules/websocket/meeting-auth.guard.ts
Normal file
157
node_api/src/modules/websocket/meeting-auth.guard.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user