feat(zulip): 添加全面的 Zulip 集成系统

* **新增 Zulip 模块**:包含完整的集成服务,涵盖客户端池(client pool)、会话管理及事件处理。
* **新增 WebSocket 网关**:用于处理 Zulip 的实时事件监听与双向通信。
* **新增安全服务**:支持 API 密钥加密存储及凭据的安全管理。
* **新增配置管理服务**:支持配置热加载(hot-reload),实现动态配置更新。
* **新增错误处理与监控服务**:提升系统的可靠性与可观测性。
* **新增消息过滤服务**:用于内容校验及速率限制(流控)。
* **新增流初始化与会话清理服务**:优化资源管理与回收。
* **完善测试覆盖**:包含单元测试及端到端(e2e)集成测试。
* **完善详细文档**:包括 API 参考手册、配置指南及集成概述。
* **新增地图配置系统**:实现游戏地点与 Zulip Stream(频道)及 Topic(话题)的逻辑映射。
* **新增环境变量配置**:涵盖 Zulip 服务器地址、身份验证及监控相关设置。
* **更新 App 模块**:注册并启用新的 Zulip 集成模块。
* **更新 Redis 接口**:以支持增强型的会话管理功能。
* **实现 WebSocket 协议支持**:确保与 Zulip 之间的实时双向通信。
This commit is contained in:
angjustinl
2025-12-25 22:22:30 +08:00
parent f6fa1ca1e3
commit 55cfda0532
46 changed files with 21488 additions and 2 deletions

View File

@@ -0,0 +1,799 @@
/**
* API Key安全存储服务
*
* 功能描述:
* - 实现Zulip API Key的加密存储
* - 提供安全日志记录功能
* - 检测异常操作并记录安全事件
* - 支持API Key的安全获取和更新
*
* 主要方法:
* - storeApiKey(): 加密存储API Key
* - getApiKey(): 安全获取API Key
* - updateApiKey(): 更新API Key
* - deleteApiKey(): 删除API Key
* - logSecurityEvent(): 记录安全事件
*
* 使用场景:
* - 用户首次绑定Zulip账户
* - Zulip客户端创建时获取API Key
* - 检测到异常操作时记录安全日志
*
* 依赖模块:
* - AppLoggerService: 日志记录服务
* - IRedisService: Redis缓存服务
*
* @author 开发团队
* @version 1.0.0
* @since 2025-12-25
*/
import { Injectable, Inject, Logger } from '@nestjs/common';
import * as crypto from 'crypto';
import { IRedisService } from '../../../core/redis/redis.interface';
/**
* 安全事件类型枚举
*/
export enum SecurityEventType {
API_KEY_STORED = 'api_key_stored',
API_KEY_ACCESSED = 'api_key_accessed',
API_KEY_UPDATED = 'api_key_updated',
API_KEY_DELETED = 'api_key_deleted',
API_KEY_DECRYPTION_FAILED = 'api_key_decryption_failed',
SUSPICIOUS_ACCESS = 'suspicious_access',
RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded',
INVALID_KEY_FORMAT = 'invalid_key_format',
UNAUTHORIZED_ACCESS = 'unauthorized_access',
}
/**
* 安全事件严重级别
*/
export enum SecuritySeverity {
INFO = 'info',
WARNING = 'warning',
CRITICAL = 'critical',
}
/**
* 安全事件记录接口
*/
export interface SecurityEvent {
eventType: SecurityEventType;
severity: SecuritySeverity;
userId: string;
details: Record<string, any>;
timestamp: Date;
ipAddress?: string;
userAgent?: string;
}
/**
* 加密后的API Key存储结构
*/
export interface EncryptedApiKey {
encryptedKey: string;
iv: string;
authTag: string;
createdAt: Date;
updatedAt: Date;
accessCount: number;
lastAccessedAt?: Date;
}
/**
* API Key存储结果
*/
export interface StoreApiKeyResult {
success: boolean;
message: string;
userId?: string;
}
/**
* API Key获取结果
*/
export interface GetApiKeyResult {
success: boolean;
apiKey?: string;
message?: string;
}
@Injectable()
export class ApiKeySecurityService {
private readonly logger = new Logger(ApiKeySecurityService.name);
private readonly API_KEY_PREFIX = 'zulip:api_key:';
private readonly SECURITY_LOG_PREFIX = 'zulip:security_log:';
private readonly ACCESS_COUNT_PREFIX = 'zulip:api_key_access:';
private readonly ENCRYPTION_ALGORITHM = 'aes-256-gcm';
private readonly KEY_LENGTH = 32; // 256 bits
private readonly IV_LENGTH = 16; // 128 bits
private readonly AUTH_TAG_LENGTH = 16; // 128 bits
private readonly MAX_ACCESS_PER_MINUTE = 60; // 每分钟最大访问次数
private readonly SECURITY_LOG_RETENTION = 30 * 24 * 3600; // 30天
// 加密密钥(生产环境应从环境变量或密钥管理服务获取)
private readonly encryptionKey: Buffer;
constructor(
@Inject('REDIS_SERVICE')
private readonly redisService: IRedisService,
) {
// 从环境变量获取加密密钥,如果没有则生成一个默认密钥(仅用于开发)
const keyFromEnv = process.env.ZULIP_API_KEY_ENCRYPTION_KEY;
if (keyFromEnv) {
this.encryptionKey = Buffer.from(keyFromEnv, 'hex');
} else {
// 开发环境使用固定密钥(生产环境必须配置环境变量)
this.encryptionKey = crypto.scryptSync('default-dev-key', 'salt', this.KEY_LENGTH);
this.logger.warn('使用默认加密密钥生产环境请配置ZULIP_API_KEY_ENCRYPTION_KEY环境变量');
}
this.logger.log('ApiKeySecurityService初始化完成');
}
/**
* 加密存储API Key
*
* 功能描述:
* 使用AES-256-GCM算法加密API Key并存储到Redis
*
* 业务逻辑:
* 1. 验证API Key格式
* 2. 生成随机IV
* 3. 使用AES-256-GCM加密
* 4. 存储加密后的数据到Redis
* 5. 记录安全日志
*
* @param userId 用户ID
* @param apiKey Zulip API Key
* @param metadata 可选的元数据如IP地址
* @returns Promise<StoreApiKeyResult> 存储结果
*/
async storeApiKey(
userId: string,
apiKey: string,
metadata?: { ipAddress?: string; userAgent?: string }
): Promise<StoreApiKeyResult> {
const startTime = Date.now();
this.logger.log(`开始存储API Key: ${userId}`);
try {
// 1. 参数验证
if (!userId || !userId.trim()) {
await this.logSecurityEvent({
eventType: SecurityEventType.INVALID_KEY_FORMAT,
severity: SecuritySeverity.WARNING,
userId: userId || 'unknown',
details: { reason: 'empty_user_id' },
timestamp: new Date(),
...metadata,
});
return { success: false, message: '用户ID不能为空' };
}
if (!apiKey || !apiKey.trim()) {
await this.logSecurityEvent({
eventType: SecurityEventType.INVALID_KEY_FORMAT,
severity: SecuritySeverity.WARNING,
userId,
details: { reason: 'empty_api_key' },
timestamp: new Date(),
...metadata,
});
return { success: false, message: 'API Key不能为空' };
}
// 2. 验证API Key格式Zulip API Key通常是32字符的字母数字字符串
if (!this.isValidApiKeyFormat(apiKey)) {
await this.logSecurityEvent({
eventType: SecurityEventType.INVALID_KEY_FORMAT,
severity: SecuritySeverity.WARNING,
userId,
details: { reason: 'invalid_format', keyLength: apiKey.length },
timestamp: new Date(),
...metadata,
});
return { success: false, message: 'API Key格式无效' };
}
// 3. 加密API Key
const encrypted = this.encrypt(apiKey);
// 4. 构建存储数据
const storageData: EncryptedApiKey = {
encryptedKey: encrypted.encryptedData,
iv: encrypted.iv,
authTag: encrypted.authTag,
createdAt: new Date(),
updatedAt: new Date(),
accessCount: 0,
};
// 5. 存储到Redis
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
await this.redisService.set(storageKey, JSON.stringify(storageData));
// 6. 记录安全日志
await this.logSecurityEvent({
eventType: SecurityEventType.API_KEY_STORED,
severity: SecuritySeverity.INFO,
userId,
details: {
action: 'store',
keyLength: apiKey.length,
},
timestamp: new Date(),
...metadata,
});
const duration = Date.now() - startTime;
this.logger.log(`API Key存储成功: ${userId}`);
return { success: true, message: 'API Key存储成功', userId };
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('API Key存储失败', {
operation: 'storeApiKey',
userId,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return { success: false, message: '存储失败,请稍后重试' };
}
}
/**
* 安全获取API Key
*
* 功能描述:
* 从Redis获取加密的API Key并解密返回
*
* 业务逻辑:
* 1. 检查访问频率限制
* 2. 从Redis获取加密数据
* 3. 解密API Key
* 4. 更新访问计数
* 5. 记录访问日志
*
* @param userId 用户ID
* @param metadata 可选的元数据
* @returns Promise<GetApiKeyResult> 获取结果
*/
async getApiKey(
userId: string,
metadata?: { ipAddress?: string; userAgent?: string }
): Promise<GetApiKeyResult> {
const startTime = Date.now();
this.logger.debug('开始获取API Key', {
operation: 'getApiKey',
userId,
timestamp: new Date().toISOString(),
});
try {
// 1. 参数验证
if (!userId || !userId.trim()) {
return { success: false, message: '用户ID不能为空' };
}
// 2. 检查访问频率限制
const rateLimitCheck = await this.checkAccessRateLimit(userId);
if (!rateLimitCheck.allowed) {
await this.logSecurityEvent({
eventType: SecurityEventType.RATE_LIMIT_EXCEEDED,
severity: SecuritySeverity.WARNING,
userId,
details: {
currentCount: rateLimitCheck.currentCount,
limit: this.MAX_ACCESS_PER_MINUTE,
},
timestamp: new Date(),
...metadata,
});
return { success: false, message: '访问频率过高,请稍后重试' };
}
// 3. 从Redis获取加密数据
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
const encryptedData = await this.redisService.get(storageKey);
if (!encryptedData) {
this.logger.debug('API Key不存在', {
operation: 'getApiKey',
userId,
});
return { success: false, message: 'API Key不存在' };
}
// 4. 解析存储数据
const storageData: EncryptedApiKey = JSON.parse(encryptedData);
// 5. 解密API Key
let apiKey: string;
try {
apiKey = this.decrypt(
storageData.encryptedKey,
storageData.iv,
storageData.authTag
);
} catch (decryptError) {
await this.logSecurityEvent({
eventType: SecurityEventType.API_KEY_DECRYPTION_FAILED,
severity: SecuritySeverity.CRITICAL,
userId,
details: {
error: (decryptError as Error).message,
},
timestamp: new Date(),
...metadata,
});
return { success: false, message: 'API Key解密失败' };
}
// 6. 更新访问计数和时间
storageData.accessCount += 1;
storageData.lastAccessedAt = new Date();
await this.redisService.set(storageKey, JSON.stringify(storageData));
// 7. 记录访问日志
await this.logSecurityEvent({
eventType: SecurityEventType.API_KEY_ACCESSED,
severity: SecuritySeverity.INFO,
userId,
details: {
accessCount: storageData.accessCount,
},
timestamp: new Date(),
...metadata,
});
const duration = Date.now() - startTime;
this.logger.debug('API Key获取成功', {
operation: 'getApiKey',
userId,
accessCount: storageData.accessCount,
duration,
});
return { success: true, apiKey };
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('API Key获取失败', {
operation: 'getApiKey',
userId,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return { success: false, message: '获取失败,请稍后重试' };
}
}
/**
* 更新API Key
*
* 功能描述:
* 更新用户的Zulip API Key
*
* @param userId 用户ID
* @param newApiKey 新的API Key
* @param metadata 可选的元数据
* @returns Promise<StoreApiKeyResult> 更新结果
*/
async updateApiKey(
userId: string,
newApiKey: string,
metadata?: { ipAddress?: string; userAgent?: string }
): Promise<StoreApiKeyResult> {
this.logger.log(`开始更新API Key: ${userId}`);
try {
// 1. 检查原API Key是否存在
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
const existingData = await this.redisService.get(storageKey);
if (!existingData) {
// 如果不存在,则创建新的
return this.storeApiKey(userId, newApiKey, metadata);
}
// 2. 验证新API Key格式
if (!this.isValidApiKeyFormat(newApiKey)) {
await this.logSecurityEvent({
eventType: SecurityEventType.INVALID_KEY_FORMAT,
severity: SecuritySeverity.WARNING,
userId,
details: { reason: 'invalid_format', action: 'update' },
timestamp: new Date(),
...metadata,
});
return { success: false, message: 'API Key格式无效' };
}
// 3. 解析现有数据
const oldStorageData: EncryptedApiKey = JSON.parse(existingData);
// 4. 加密新API Key
const encrypted = this.encrypt(newApiKey);
// 5. 更新存储数据
const newStorageData: EncryptedApiKey = {
encryptedKey: encrypted.encryptedData,
iv: encrypted.iv,
authTag: encrypted.authTag,
createdAt: oldStorageData.createdAt,
updatedAt: new Date(),
accessCount: oldStorageData.accessCount,
lastAccessedAt: oldStorageData.lastAccessedAt,
};
await this.redisService.set(storageKey, JSON.stringify(newStorageData));
// 6. 记录安全日志
await this.logSecurityEvent({
eventType: SecurityEventType.API_KEY_UPDATED,
severity: SecuritySeverity.INFO,
userId,
details: {
action: 'update',
previousAccessCount: oldStorageData.accessCount,
},
timestamp: new Date(),
...metadata,
});
this.logger.log(`API Key更新成功: ${userId}`);
return { success: true, message: 'API Key更新成功', userId };
} catch (error) {
const err = error as Error;
this.logger.error('API Key更新失败', {
operation: 'updateApiKey',
userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return { success: false, message: '更新失败,请稍后重试' };
}
}
/**
* 删除API Key
*
* 功能描述:
* 安全删除用户的API Key
*
* @param userId 用户ID
* @param metadata 可选的元数据
* @returns Promise<boolean> 是否删除成功
*/
async deleteApiKey(
userId: string,
metadata?: { ipAddress?: string; userAgent?: string }
): Promise<boolean> {
this.logger.log(`开始删除API Key: ${userId}`);
try {
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
await this.redisService.del(storageKey);
// 记录安全日志
await this.logSecurityEvent({
eventType: SecurityEventType.API_KEY_DELETED,
severity: SecuritySeverity.INFO,
userId,
details: { action: 'delete' },
timestamp: new Date(),
...metadata,
});
this.logger.log(`API Key删除成功: ${userId}`);
return true;
} catch (error) {
const err = error as Error;
this.logger.error('API Key删除失败', {
operation: 'deleteApiKey',
userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return false;
}
}
/**
* 检查API Key是否存在
*
* @param userId 用户ID
* @returns Promise<boolean> 是否存在
*/
async hasApiKey(userId: string): Promise<boolean> {
try {
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
return await this.redisService.exists(storageKey);
} catch (error) {
this.logger.error('检查API Key存在性失败', {
operation: 'hasApiKey',
userId,
error: (error as Error).message,
});
return false;
}
}
/**
* 记录安全事件
*
* 功能描述:
* 记录安全相关的事件到Redis用于审计和监控
*
* @param event 安全事件
* @returns Promise<void>
*/
async logSecurityEvent(event: SecurityEvent): Promise<void> {
try {
const logKey = `${this.SECURITY_LOG_PREFIX}${event.userId}:${Date.now()}`;
await this.redisService.setex(
logKey,
this.SECURITY_LOG_RETENTION,
JSON.stringify(event)
);
// 根据严重级别记录到应用日志
const logContext = {
operation: 'logSecurityEvent',
eventType: event.eventType,
severity: event.severity,
userId: event.userId,
details: event.details,
ipAddress: event.ipAddress,
timestamp: event.timestamp.toISOString(),
};
switch (event.severity) {
case SecuritySeverity.CRITICAL:
this.logger.error('安全事件 - 严重', logContext);
break;
case SecuritySeverity.WARNING:
this.logger.warn('安全事件 - 警告', logContext);
break;
case SecuritySeverity.INFO:
default:
this.logger.log('安全事件 - 信息', logContext);
break;
}
} catch (error) {
this.logger.error('记录安全事件失败', {
operation: 'logSecurityEvent',
event,
error: (error as Error).message,
});
}
}
/**
* 记录可疑访问
*
* 功能描述:
* 当检测到异常操作时记录可疑访问事件
*
* @param userId 用户ID
* @param reason 可疑原因
* @param details 详细信息
* @param metadata 元数据
* @returns Promise<void>
*/
async logSuspiciousAccess(
userId: string,
reason: string,
details: Record<string, any>,
metadata?: { ipAddress?: string; userAgent?: string }
): Promise<void> {
await this.logSecurityEvent({
eventType: SecurityEventType.SUSPICIOUS_ACCESS,
severity: SecuritySeverity.WARNING,
userId,
details: {
reason,
...details,
},
timestamp: new Date(),
...metadata,
});
}
/**
* 获取用户安全事件历史
*
* @param userId 用户ID
* @param limit 返回数量限制
* @returns Promise<SecurityEvent[]> 安全事件列表
*/
async getSecurityEventHistory(userId: string, limit: number = 100): Promise<SecurityEvent[]> {
// 注意这是一个简化实现实际应该使用Redis的有序集合或扫描功能
// 当前实现仅作为示例
this.logger.debug('获取安全事件历史', {
operation: 'getSecurityEventHistory',
userId,
limit,
});
return [];
}
/**
* 获取API Key统计信息
*
* @param userId 用户ID
* @returns Promise<{exists: boolean, accessCount?: number, lastAccessedAt?: Date, createdAt?: Date}>
*/
async getApiKeyStats(userId: string): Promise<{
exists: boolean;
accessCount?: number;
lastAccessedAt?: Date;
createdAt?: Date;
updatedAt?: Date;
}> {
try {
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
const data = await this.redisService.get(storageKey);
if (!data) {
return { exists: false };
}
const storageData: EncryptedApiKey = JSON.parse(data);
return {
exists: true,
accessCount: storageData.accessCount,
lastAccessedAt: storageData.lastAccessedAt ? new Date(storageData.lastAccessedAt) : undefined,
createdAt: new Date(storageData.createdAt),
updatedAt: new Date(storageData.updatedAt),
};
} catch (error) {
this.logger.error('获取API Key统计信息失败', {
operation: 'getApiKeyStats',
userId,
error: (error as Error).message,
});
return { exists: false };
}
}
// ==================== 私有方法 ====================
/**
* 加密数据
*
* @param plaintext 明文
* @returns 加密结果
* @private
*/
private encrypt(plaintext: string): {
encryptedData: string;
iv: string;
authTag: string;
} {
const iv = crypto.randomBytes(this.IV_LENGTH);
const cipher = crypto.createCipheriv(
this.ENCRYPTION_ALGORITHM,
this.encryptionKey,
iv
);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
encryptedData: encrypted,
iv: iv.toString('hex'),
authTag: authTag.toString('hex'),
};
}
/**
* 解密数据
*
* @param encryptedData 加密数据
* @param ivHex IV十六进制
* @param authTagHex 认证标签(十六进制)
* @returns 解密后的明文
* @private
*/
private decrypt(encryptedData: string, ivHex: string, authTagHex: string): string {
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(
this.ENCRYPTION_ALGORITHM,
this.encryptionKey,
iv
);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* 验证API Key格式
*
* @param apiKey API Key
* @returns boolean 是否有效
* @private
*/
private isValidApiKeyFormat(apiKey: string): boolean {
// Zulip API Key通常是32字符的字母数字字符串
// 这里放宽限制以支持不同格式
if (!apiKey || apiKey.length < 16 || apiKey.length > 128) {
return false;
}
// 只允许字母、数字和一些特殊字符
return /^[a-zA-Z0-9_-]+$/.test(apiKey);
}
/**
* 检查访问频率限制
*
* @param userId 用户ID
* @returns Promise<{allowed: boolean, currentCount: number}>
* @private
*/
private async checkAccessRateLimit(userId: string): Promise<{
allowed: boolean;
currentCount: number;
}> {
try {
const rateLimitKey = `${this.ACCESS_COUNT_PREFIX}${userId}`;
const currentCount = await this.redisService.get(rateLimitKey);
const count = currentCount ? parseInt(currentCount, 10) : 0;
if (count >= this.MAX_ACCESS_PER_MINUTE) {
return { allowed: false, currentCount: count };
}
// 增加计数
if (count === 0) {
await this.redisService.setex(rateLimitKey, 60, '1');
} else {
await this.redisService.incr(rateLimitKey);
}
return { allowed: true, currentCount: count + 1 };
} catch (error) {
// 频率检查失败时默认允许
this.logger.warn('访问频率检查失败', {
operation: 'checkAccessRateLimit',
userId,
error: (error as Error).message,
});
return { allowed: true, currentCount: 0 };
}
}
}