Files
whale-town-end/src/business/zulip/services/api-key-security.service.ts
angjustinl 3dd5f23d79 fix(zulip): Fix e2e test errors and pdate author attribution across all Zulip integration files
- Standardize author attribution across 27 files in the Zulip integration module
- Maintain consistent code documentation and authorship tracking
2025-12-25 23:37:26 +08:00

800 lines
22 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.
/**
* 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 angjustinl
* @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 };
}
}
}