diff --git a/docs/systems/zulip/README.md b/docs/systems/zulip/README.md index fafcd4f..18efb9e 100644 --- a/docs/systems/zulip/README.md +++ b/docs/systems/zulip/README.md @@ -358,7 +358,16 @@ node test-stream-initialization.js ## 更新日志 -### v2.0.0 (2025-12-25) +### v1.1.0 (2026-01-06) +- **修复 JWT Token 验证和 API Key 管理** + - 修复 `LoginService.generateTokenPair()` 的 JWT 签名冲突问题 + - `ZulipService` 现在复用 `LoginService.verifyToken()` 进行 Token 验证 + - 修复消息发送时使用错误的硬编码 API Key 问题 + - 现在正确从 Redis 读取用户注册时存储的真实 API Key + - 添加 `AuthModule` 到 `ZulipModule` 的依赖注入 + - 消息发送功能现已完全正常工作 ✅ + +### v1.0.1 (2025-12-25) - 更新地图配置为 9 区域系统 - 添加 Stream Initializer Service 自动初始化服务 - 更新默认出生点为鲸之港 (Whale Port) diff --git a/docs/systems/zulip/guide.md b/docs/systems/zulip/guide.md index d7f5734..2d7f526 100644 --- a/docs/systems/zulip/guide.md +++ b/docs/systems/zulip/guide.md @@ -68,4 +68,149 @@ C. 接收消息 (Downstream: Zulip -> Node -> Godot) - Zulip API Key 永不下发: 用户的 Zulip 凭证永远只保存在服务器端。如果客户端直接拿 Key,黑客可以通过解包 Godot 拿到 Key 然后去 Zulip 乱发消息。 - 位置欺诈完全消除: 因为 Stream 的选择权在 Node 手里,玩家无法做到“人在新手村,却往高等级区域频道发消息”。 3. 协议统一: - - 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。 \ No newline at end of file + - 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。 + + +--- + +## 3. JWT Token 验证和 API Key 管理 (v1.1.0 更新) + +### 3.1 用户注册和 API Key 生成流程 + +当用户注册游戏账号时,系统会自动创建对应的 Zulip 账号并获取 API Key: + +``` +用户注册 (POST /auth/register) + ↓ +1. 创建游戏账号 (LoginService.register) + ↓ +2. 初始化 Zulip 管理员客户端 + ↓ +3. 创建 Zulip 账号 (ZulipAccountService.createZulipAccount) + - 使用相同的邮箱和密码 + - 调用 Zulip API: POST /api/v1/users + ↓ +4. 获取 API Key (ZulipAccountService.generateApiKeyForUser) + - 使用 fetch_api_key 端点(固定的、基于密码的 Key) + - 注意:不使用 regenerate_api_key(会生成新 Key) + ↓ +5. 加密存储 API Key (ApiKeySecurityService.storeApiKey) + - 使用 AES-256-GCM 加密 + - 存储到 Redis: zulip:api_key:{userId} + ↓ +6. 创建账号关联记录 (ZulipAccountsRepository) + - 存储 gameUserId ↔ zulipUserId 映射 + ↓ +7. 生成 JWT Token (LoginService.generateTokenPair) + - 包含用户信息:sub, username, email, role + - 返回 access_token 和 refresh_token +``` + +### 3.2 JWT Token 验证流程 + +当用户通过 WebSocket 登录游戏时,系统会验证 JWT Token 并获取 API Key: + +``` +WebSocket 登录 (login 消息) + ↓ +1. ZulipService.validateGameToken(token) + ↓ +2. 调用 LoginService.verifyToken(token, 'access') + - 验证签名、过期时间、载荷 + - 提取用户信息:userId, username, email + ↓ +3. 从 Redis 获取 API Key (ApiKeySecurityService.getApiKey) + - 解密存储的 API Key + - 更新访问计数和时间 + ↓ +4. 创建 Zulip 客户端 (ZulipClientPoolService.createUserClient) + - 使用真实的用户 API Key + - 注册事件队列 + ↓ +5. 创建游戏会话 (SessionManagerService.createSession) + - 绑定 socketId ↔ zulipQueueId + - 记录用户位置信息 + ↓ +6. 返回登录成功 +``` + +### 3.3 消息发送流程(使用正确的 API Key) + +``` +发送聊天消息 (chat 消息) + ↓ +1. ZulipService.sendChatMessage() + ↓ +2. 获取会话信息 (SessionManagerService.getSession) + - 获取 userId 和当前位置 + ↓ +3. 上下文注入 (SessionManagerService.injectContext) + - 根据位置确定目标 Stream/Topic + ↓ +4. 消息验证 (MessageFilterService.validateMessage) + - 内容过滤、频率限制 + ↓ +5. 发送到 Zulip (ZulipClientPoolService.sendMessage) + - 使用用户的真实 API Key + - 调用 Zulip API: POST /api/v1/messages + ↓ +6. 返回发送结果 +``` + +### 3.4 关键修复说明 + +**问题 1: JWT Token 签名冲突** +- **原因**: payload 中包含 `iss` 和 `aud`,但 `jwtService.signAsync()` 也通过 options 传递这些值 +- **修复**: 从 payload 中移除 `iss` 和 `aud`,只通过 options 传递 +- **文件**: `src/business/auth/services/login.service.ts` + +**问题 2: 使用硬编码的旧 API Key** +- **原因**: `ZulipService.validateGameToken()` 使用硬编码的测试 API Key +- **修复**: 从 Redis 读取用户注册时存储的真实 API Key +- **文件**: `src/business/zulip/zulip.service.ts` + +**问题 3: 重复实现 JWT 验证逻辑** +- **原因**: `ZulipService` 自己实现了 JWT 解析 +- **修复**: 复用 `LoginService.verifyToken()` 方法 +- **文件**: `src/business/zulip/zulip.service.ts`, `src/business/zulip/zulip.module.ts` + +### 3.5 API Key 安全机制 + +**加密存储**: +- 使用 AES-256-GCM 算法加密 +- 加密密钥从环境变量 `ZULIP_API_KEY_ENCRYPTION_KEY` 读取 +- 存储格式:`{encryptedKey, iv, authTag, createdAt, updatedAt, accessCount}` + +**访问控制**: +- 频率限制:每分钟最多 60 次访问 +- 访问日志:记录每次访问的时间和次数 +- 安全事件:记录所有关键操作(存储、访问、更新、删除) + +**环境变量配置**: +```bash +# 生成 64 字符的十六进制密钥(32 字节 = 256 位) +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + +# 在 .env 文件中配置 +ZULIP_API_KEY_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +``` + +### 3.6 测试验证 + +使用测试脚本验证功能: + +```bash +# 测试注册用户的 Zulip 集成 +node docs/systems/zulip/quick_tests/test-registered-user.js + +# 验证 API Key 一致性 +node docs/systems/zulip/quick_tests/verify-api-key.js +``` + +**预期结果**: +- ✅ WebSocket 连接成功 +- ✅ JWT Token 验证通过 +- ✅ 从 Redis 获取正确的 API Key +- ✅ 消息成功发送到 Zulip + +--- diff --git a/docs/systems/zulip/quick_tests/test-get-messages.js b/docs/systems/zulip/quick_tests/test-get-messages.js new file mode 100644 index 0000000..a7f14b0 --- /dev/null +++ b/docs/systems/zulip/quick_tests/test-get-messages.js @@ -0,0 +1,260 @@ +/** + * 测试通过 WebSocket 接收 Zulip 消息 + * + * 设计理念: + * - Zulip API Key 永不下发到客户端 + * - 所有 Zulip 交互通过游戏服务器的 WebSocket 进行 + * - 客户端只接收 chat_render 消息,不直接调用 Zulip API + * + * 功能: + * 1. 登录游戏服务器获取 JWT Token + * 2. 通过 WebSocket 连接游戏服务器 + * 3. 在当前地图 (Whale Port) 接收消息 + * 4. 切换到 Pumpkin Valley 接收消息 + * 5. 统计接收到的消息数量 + * + * 使用方法: + * node docs/systems/zulip/quick_tests/test-get-messages.js + */ + +const axios = require('axios'); +const io = require('socket.io-client'); + +// 配置 +const GAME_SERVER = 'http://localhost:3000'; +const TEST_USER = { + username: 'angtest123', + password: 'angtest123', + email: 'angjustinl@163.com' +}; + +// 测试配置 +const TEST_CONFIG = { + whalePortWaitTime: 10000, // 在 Whale Port 等待 10 秒 + pumpkinValleyWaitTime: 10000, // 在 Pumpkin Valley 等待 10 秒 + totalTimeout: 30000 // 总超时时间 30 秒 +}; + +/** + * 登录游戏服务器获取用户信息 + */ +async function loginToGameServer() { + console.log('📝 步骤 1: 登录游戏服务器'); + console.log(` 用户名: ${TEST_USER.username}`); + + try { + const response = await axios.post(`${GAME_SERVER}/auth/login`, { + identifier: TEST_USER.username, + password: TEST_USER.password + }); + + if (response.data.success) { + console.log('✅ 登录成功'); + console.log(` 用户ID: ${response.data.data.user.id}`); + console.log(` 邮箱: ${response.data.data.user.email}`); + return { + userId: response.data.data.user.id, + username: response.data.data.user.username, + email: response.data.data.user.email, + token: response.data.data.access_token + }; + } else { + throw new Error(response.data.message || '登录失败'); + } + } catch (error) { + console.error('❌ 登录失败:', error.response?.data?.message || error.message); + throw error; + } +} + + + +/** + * 通过 WebSocket 接收消息 + */ +async function receiveMessagesViaWebSocket(userInfo) { + console.log('\n📡 步骤 2: 通过 WebSocket 连接并接收消息'); + console.log(` 连接到: ${GAME_SERVER}/game`); + + return new Promise((resolve, reject) => { + const socket = io(`${GAME_SERVER}/game`, { + transports: ['websocket'], + timeout: 20000 + }); + + const receivedMessages = { + whalePort: [], + pumpkinValley: [] + }; + + let currentMap = 'whale_port'; + let testPhase = 0; // 0: 连接中, 1: Whale Port, 2: Pumpkin Valley, 3: 完成 + + // 连接成功 + socket.on('connect', () => { + console.log('✅ WebSocket 连接成功'); + + // 发送登录消息 + const loginMessage = { + type: 'login', + token: userInfo.token + }; + + console.log('📤 发送登录消息...'); + socket.emit('login', loginMessage); + }); + + // 登录成功 + socket.on('login_success', (data) => { + console.log('✅ 登录成功'); + console.log(` 会话ID: ${data.sessionId}`); + console.log(` 用户ID: ${data.userId}`); + console.log(` 当前地图: ${data.currentMap}`); + + testPhase = 1; + currentMap = data.currentMap || 'whale_port'; + + console.log(`\n📬 步骤 3: 在 Whale Port 接收消息 (等待 ${TEST_CONFIG.whalePortWaitTime / 1000} 秒)`); + console.log(' 💡 提示: 请在 Zulip 的 "Whale Port" Stream 发送测试消息'); + + // 在 Whale Port 等待一段时间 + setTimeout(() => { + console.log(`\n📊 Whale Port 接收到 ${receivedMessages.whalePort.length} 条消息`); + + // 切换到 Pumpkin Valley + console.log(`\n📤 步骤 4: 切换到 Pumpkin Valley`); + const positionUpdate = { + t: 'position', + x: 150, + y: 400, + mapId: 'pumpkin_valley' + }; + socket.emit('position_update', positionUpdate); + + testPhase = 2; + currentMap = 'pumpkin_valley'; + + console.log(`\n📬 步骤 5: 在 Pumpkin Valley 接收消息 (等待 ${TEST_CONFIG.pumpkinValleyWaitTime / 1000} 秒)`); + console.log(' 💡 提示: 请在 Zulip 的 "Pumpkin Valley" Stream 发送测试消息'); + + // 在 Pumpkin Valley 等待一段时间 + setTimeout(() => { + console.log(`\n📊 Pumpkin Valley 接收到 ${receivedMessages.pumpkinValley.length} 条消息`); + + testPhase = 3; + console.log('\n📊 测试完成,断开连接...'); + socket.disconnect(); + }, TEST_CONFIG.pumpkinValleyWaitTime); + }, TEST_CONFIG.whalePortWaitTime); + }); + + // 接收到消息 (chat_render) + socket.on('chat_render', (data) => { + const timestamp = new Date().toLocaleTimeString('zh-CN'); + + console.log(`\n📨 [${timestamp}] 收到消息:`); + console.log(` ├─ 发送者: ${data.from}`); + console.log(` ├─ 内容: ${data.txt}`); + console.log(` ├─ Stream: ${data.stream || '未知'}`); + console.log(` ├─ Topic: ${data.topic || '未知'}`); + console.log(` └─ 当前地图: ${currentMap}`); + + // 记录消息 + const message = { + from: data.from, + content: data.txt, + stream: data.stream, + topic: data.topic, + timestamp: new Date(), + map: currentMap + }; + + if (testPhase === 1) { + receivedMessages.whalePort.push(message); + } else if (testPhase === 2) { + receivedMessages.pumpkinValley.push(message); + } + }); + + // 错误处理 + socket.on('error', (error) => { + console.error('❌ 收到错误:', JSON.stringify(error, null, 2)); + }); + + // 连接断开 + socket.on('disconnect', () => { + console.log('\n🔌 WebSocket 连接已关闭'); + resolve(receivedMessages); + }); + + // 连接错误 + socket.on('connect_error', (error) => { + console.error('❌ 连接错误:', error.message); + reject(error); + }); + + // 总超时保护 + setTimeout(() => { + if (socket.connected) { + console.log('\n⏰ 测试超时,关闭连接'); + socket.disconnect(); + } + }, TEST_CONFIG.totalTimeout); + }); +} + +/** + * 主测试流程 + */ +async function runTest() { + console.log('🚀 开始测试通过 WebSocket 接收 Zulip 消息'); + console.log('='.repeat(60)); + console.log('📋 设计理念: Zulip API Key 永不下发到客户端'); + console.log('📋 所有消息通过游戏服务器的 WebSocket (chat_render) 接收'); + console.log('='.repeat(60)); + + try { + // 步骤1: 登录游戏服务器 + const userInfo = await loginToGameServer(); + + // 步骤2-5: 通过 WebSocket 接收消息 + const receivedMessages = await receiveMessagesViaWebSocket(userInfo); + + // 步骤6: 统计信息 + console.log('\n' + '='.repeat(60)); + console.log('📊 测试结果汇总'); + console.log('='.repeat(60)); + console.log(`✅ Whale Port: ${receivedMessages.whalePort.length} 条消息`); + console.log(`✅ Pumpkin Valley: ${receivedMessages.pumpkinValley.length} 条消息`); + console.log(`📝 总计: ${receivedMessages.whalePort.length + receivedMessages.pumpkinValley.length} 条消息`); + + // 显示详细消息列表 + if (receivedMessages.whalePort.length > 0) { + console.log('\n📬 Whale Port 消息列表:'); + receivedMessages.whalePort.forEach((msg, index) => { + console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`); + }); + } + + if (receivedMessages.pumpkinValley.length > 0) { + console.log('\n📬 Pumpkin Valley 消息列表:'); + receivedMessages.pumpkinValley.forEach((msg, index) => { + console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`); + }); + } + + console.log('='.repeat(60)); + + console.log('\n🎉 测试完成!'); + console.log('💡 提示: 客户端通过 WebSocket 接收消息,无需直接访问 Zulip API'); + console.log('💡 提示: 访问 https://zulip.xinghangee.icu 查看完整消息历史'); + + process.exit(0); + } catch (error) { + console.error('\n❌ 测试失败:', error.message); + process.exit(1); + } +} + +// 运行测试 +runTest(); diff --git a/docs/systems/zulip/quick_tests/test-list-subscriptions.js b/docs/systems/zulip/quick_tests/test-list-subscriptions.js index 2136548..6dfb74d 100644 --- a/docs/systems/zulip/quick_tests/test-list-subscriptions.js +++ b/docs/systems/zulip/quick_tests/test-list-subscriptions.js @@ -1,15 +1,102 @@ const zulip = require('zulip-js'); +const axios = require('axios'); -async function listSubscriptions() { - console.log('🔧 检查用户订阅的 Streams...'); - - const config = { - username: 'angjustinl@mail.angforever.top', - apiKey: 'lCPWC...pqNfGF8', - realm: 'https://zulip.xinghangee.icu/' - }; +// 配置 +const GAME_SERVER = 'http://localhost:3000'; +const TEST_USER = { + username: 'angtest123', + password: 'angtest123', + email: 'angjustinl@163.com' +}; + +/** + * 登录游戏服务器获取用户信息 + */ +async function loginToGameServer() { + console.log('📝 步骤 1: 登录游戏服务器'); + console.log(` 用户名: ${TEST_USER.username}`); try { + const response = await axios.post(`${GAME_SERVER}/auth/login`, { + identifier: TEST_USER.username, + password: TEST_USER.password + }); + + if (response.data.success) { + console.log('✅ 登录成功'); + console.log(` 用户ID: ${response.data.data.user.id}`); + console.log(` 邮箱: ${response.data.data.user.email}`); + return { + userId: response.data.data.user.id, + username: response.data.data.user.username, + email: response.data.data.user.email, + token: response.data.data.access_token + }; + } else { + throw new Error(response.data.message || '登录失败'); + } + } catch (error) { + console.error('❌ 登录失败:', error.response?.data?.message || error.message); + throw error; + } +} + +/** + * 使用密码获取 Zulip API Key + */ +async function getZulipApiKey(email, password) { + console.log('\n📝 步骤 2: 获取 Zulip API Key'); + console.log(` 邮箱: ${email}`); + + try { + // Zulip API 使用 Basic Auth 和 form data + const response = await axios.post( + 'https://zulip.xinghangee.icu/api/v1/fetch_api_key', + `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + + if (response.data.result === 'success') { + console.log('✅ 成功获取 API Key'); + console.log(` API Key: ${response.data.api_key.substring(0, 10)}...`); + console.log(` 用户ID: ${response.data.user_id}`); + return { + apiKey: response.data.api_key, + email: response.data.email, + userId: response.data.user_id + }; + } else { + throw new Error(response.data.msg || '获取 API Key 失败'); + } + } catch (error) { + console.error('❌ 获取 API Key 失败:', error.response?.data?.msg || error.message); + throw error; + } +} + +async function listSubscriptions() { + console.log('🚀 开始测试用户订阅的 Streams'); + console.log('='.repeat(60)); + + try { + // 步骤1: 登录游戏服务器 + const userInfo = await loginToGameServer(); + + // 步骤2: 获取 Zulip API Key + const zulipAuth = await getZulipApiKey(userInfo.email, TEST_USER.password); + + console.log('\n📝 步骤 3: 检查用户订阅的 Streams'); + + const config = { + username: zulipAuth.email, + apiKey: zulipAuth.apiKey, + realm: 'https://zulip.xinghangee.icu/' + }; + const client = await zulip(config); // 获取用户信息 @@ -29,15 +116,15 @@ async function listSubscriptions() { }); // 检查是否有 "Novice Village" - const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Novice Village'); + const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Pumpkin Valley'); if (noviceVillage) { - console.log('\n✅ "Novice Village" Stream 已存在!'); + console.log('\n✅ "Pumpkin Valley" Stream 已存在!'); // 测试发送消息 console.log('\n📤 测试发送消息...'); const result = await client.messages.send({ type: 'stream', - to: 'Novice Village', + to: 'Pumpkin Valley', subject: 'General', content: '测试消息:系统集成测试成功 🎮' }); @@ -48,7 +135,7 @@ async function listSubscriptions() { console.log('❌ 消息发送失败:', result.msg); } } else { - console.log('\n⚠️ "Novice Village" Stream 不存在'); + console.log('\n⚠️ "Pumpkin Valley" Stream 不存在'); console.log('💡 请在 Zulip 网页界面手动创建该 Stream,或使用管理员账号创建'); // 尝试发送到第一个可用的 Stream @@ -79,7 +166,9 @@ async function listSubscriptions() { if (error.response) { console.error('响应数据:', error.response.data); } + process.exit(1); } } +// 运行测试 listSubscriptions(); diff --git a/docs/systems/zulip/quick_tests/test-user-api-key.js b/docs/systems/zulip/quick_tests/test-user-api-key.js index 4591dbb..06ac28a 100644 --- a/docs/systems/zulip/quick_tests/test-user-api-key.js +++ b/docs/systems/zulip/quick_tests/test-user-api-key.js @@ -1,127 +1,183 @@ const io = require('socket.io-client'); +const axios = require('axios'); + +// 配置 +const GAME_SERVER = 'http://localhost:3000'; +const TEST_USER = { + username: 'angtest123', + password: 'angtest123', + email: 'angjustinl@163.com' +}; + +/** + * 登录游戏服务器获取token + */ +async function loginToGameServer() { + console.log('📝 步骤 1: 登录游戏服务器'); + console.log(` 用户名: ${TEST_USER.username}`); + + try { + const response = await axios.post(`${GAME_SERVER}/auth/login`, { + identifier: TEST_USER.username, + password: TEST_USER.password + }); + + if (response.data.success) { + console.log('✅ 登录成功'); + console.log(` 用户ID: ${response.data.data.user.id}`); + console.log(` 昵称: ${response.data.data.user.nickname}`); + console.log(` 邮箱: ${response.data.data.user.email}`); + console.log(` Token: ${response.data.data.access_token.substring(0, 20)}...`); + return { + userId: response.data.data.user.id, + username: response.data.data.user.username, + token: response.data.data.access_token + }; + } else { + throw new Error(response.data.message || '登录失败'); + } + } catch (error) { + console.error('❌ 登录失败:', error.response?.data?.message || error.message); + throw error; + } +} // 使用用户 API Key 测试 Zulip 集成 async function testWithUserApiKey() { - console.log('🚀 使用用户 API Key 测试 Zulip 集成...'); - console.log('📡 用户 API Key: lCPWCPfGh7WU...pqNfGF8'); - console.log('📡 Zulip 服务器: https://zulip.xinghangee.icu/'); - console.log('📡 游戏服务器: http://localhost:3000/game'); + console.log('🚀 开始测试用户 API Key 的 Zulip 集成'); + console.log('='.repeat(60)); - const socket = io('http://localhost:3000/game', { - transports: ['websocket'], - timeout: 20000 - }); - - let testStep = 0; - - socket.on('connect', () => { - console.log('✅ WebSocket 连接成功'); - testStep = 1; + try { + // 登录获取 token + const userInfo = await loginToGameServer(); - // 使用包含用户 API Key 的 token - const loginMessage = { - type: 'login', - token: 'lCPWCPfGh7...fGF8_user_token' - }; - - console.log('📤 步骤 1: 发送登录消息(使用用户 API Key)'); - socket.emit('login', loginMessage); - }); + console.log('\n📡 步骤 2: 连接 WebSocket 并测试 Zulip 集成'); + console.log(` 连接到: ${GAME_SERVER}/game`); - socket.on('login_success', (data) => { - console.log('✅ 步骤 1 完成: 登录成功'); - console.log(' 会话ID:', data.sessionId); - console.log(' 用户ID:', data.userId); - console.log(' 用户名:', data.username); - console.log(' 当前地图:', data.currentMap); - testStep = 2; + const socket = io(`${GAME_SERVER}/game`, { + transports: ['websocket'], + timeout: 20000 + }); - // 等待 Zulip 客户端初始化 - console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...'); - setTimeout(() => { - const chatMessage = { - t: 'chat', - content: '🎮 【用户API Key测试】来自游戏的消息!\\n' + - '时间: ' + new Date().toLocaleString() + '\\n' + - '使用用户 API Key 发送此消息。', - scope: 'local' + let testStep = 0; + + socket.on('connect', () => { + console.log('✅ WebSocket 连接成功'); + testStep = 1; + + // 使用真实的 JWT token + const loginMessage = { + type: 'login', + token: userInfo.token }; - console.log('📤 步骤 2: 发送消息到 Zulip(使用用户 API Key)'); - console.log(' 目标 Stream: Whale Port'); - socket.emit('chat', chatMessage); - }, 3000); - }); + console.log('📤 步骤 3: 发送登录消息(使用 JWT Token)'); + socket.emit('login', loginMessage); + }); - socket.on('chat_sent', (data) => { - console.log('✅ 步骤 2 完成: 消息发送成功'); - console.log(' 响应:', JSON.stringify(data, null, 2)); - - // 只在第一次收到 chat_sent 时发送第二条消息 - if (testStep === 2) { - testStep = 3; + socket.on('login_success', (data) => { + console.log('✅ 步骤 3 完成: 登录成功'); + console.log(' 会话ID:', data.sessionId); + console.log(' 用户ID:', data.userId); + console.log(' 用户名:', data.username); + console.log(' 当前地图:', data.currentMap); + testStep = 2; + // 等待 Zulip 客户端初始化 + console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...'); setTimeout(() => { - // 先切换到 Pumpkin Valley 地图 - console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图'); - const positionUpdate = { - t: 'position', - x: 150, - y: 400, - mapId: 'pumpkin_valley' + const chatMessage = { + t: 'chat', + content: `🎮 【用户API Key测试】来自 ${userInfo.username} 的消息!\n` + + `时间: ${new Date().toLocaleString()}\n` + + `使用真实 API Key 发送此消息。`, + scope: 'local' }; - socket.emit('position_update', positionUpdate); - // 等待位置更新后发送消息 + console.log('📤 步骤 4: 发送消息到 Zulip(使用真实 API Key)'); + console.log(' 目标 Stream: Whale Port'); + socket.emit('chat', chatMessage); + }, 3000); + }); + + socket.on('chat_sent', (data) => { + console.log('✅ 步骤 4 完成: 消息发送成功'); + console.log(' 响应:', JSON.stringify(data, null, 2)); + + // 只在第一次收到 chat_sent 时发送第二条消息 + if (testStep === 2) { + testStep = 3; + setTimeout(() => { - const chatMessage2 = { - t: 'chat', - content: '🎃 在南瓜谷发送的测试消息!', - scope: 'local' + // 先切换到 Pumpkin Valley 地图 + console.log('\n📤 步骤 5: 切换到 Pumpkin Valley 地图'); + const positionUpdate = { + t: 'position', + x: 150, + y: 400, + mapId: 'pumpkin_valley' }; + socket.emit('position_update', positionUpdate); - console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息'); - socket.emit('chat', chatMessage2); - }, 1000); - }, 2000); - } - }); + // 等待位置更新后发送消息 + setTimeout(() => { + const chatMessage2 = { + t: 'chat', + content: '🎃 在南瓜谷发送的测试消息!', + scope: 'local' + }; + + console.log('📤 步骤 6: 在 Pumpkin Valley 发送消息'); + socket.emit('chat', chatMessage2); + }, 1000); + }, 2000); + } + }); - socket.on('chat_render', (data) => { - console.log('📨 收到来自 Zulip 的消息:'); - console.log(' 发送者:', data.from); - console.log(' 内容:', data.txt); - console.log(' Stream:', data.stream || '未知'); - console.log(' Topic:', data.topic || '未知'); - }); - - socket.on('error', (error) => { - console.log('❌ 收到错误:', JSON.stringify(error, null, 2)); - }); - - socket.on('disconnect', () => { - console.log('🔌 WebSocket 连接已关闭'); - console.log(''); - console.log('📊 测试结果:'); - console.log(' 完成步骤:', testStep, '/ 4'); - if (testStep >= 3) { - console.log(' ✅ 核心功能正常!'); - console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息'); - } - process.exit(0); - }); - - socket.on('connect_error', (error) => { - console.error('❌ 连接错误:', error.message); + socket.on('chat_render', (data) => { + console.log('\n📨 收到来自 Zulip 的消息:'); + console.log(' 发送者:', data.from); + console.log(' 内容:', data.txt); + console.log(' Stream:', data.stream || '未知'); + console.log(' Topic:', data.topic || '未知'); + }); + + socket.on('error', (error) => { + console.log('❌ 收到错误:', JSON.stringify(error, null, 2)); + }); + + socket.on('disconnect', () => { + console.log('\n🔌 WebSocket 连接已关闭'); + console.log('\n' + '='.repeat(60)); + console.log('📊 测试结果汇总'); + console.log('='.repeat(60)); + console.log(' 完成步骤:', testStep, '/ 3'); + if (testStep >= 3) { + console.log(' ✅ 核心功能正常!'); + console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息'); + } else { + console.log(' ⚠️ 部分测试未完成'); + } + console.log('='.repeat(60)); + process.exit(testStep >= 3 ? 0 : 1); + }); + + socket.on('connect_error', (error) => { + console.error('❌ 连接错误:', error.message); + process.exit(1); + }); + + // 20秒后自动关闭(给足够时间完成测试) + setTimeout(() => { + console.log('\n⏰ 测试时间到,关闭连接'); + socket.disconnect(); + }, 20000); + + } catch (error) { + console.error('\n❌ 测试失败:', error.message); process.exit(1); - }); - - // 20秒后自动关闭(给足够时间完成测试) - setTimeout(() => { - console.log('⏰ 测试时间到,关闭连接'); - socket.disconnect(); - }, 20000); + } } -console.log('🔧 准备测试环境...'); -testWithUserApiKey().catch(console.error); \ No newline at end of file +// 运行测试 +testWithUserApiKey(); \ No newline at end of file diff --git a/package.json b/package.json index 3d9fbf6..3b5c5df 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@nestjs/common": "^11.1.9", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.9", + "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^10.4.20", "@nestjs/platform-socket.io": "^10.4.20", "@nestjs/schedule": "^4.1.2", @@ -43,6 +44,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "ioredis": "^5.8.2", + "jsonwebtoken": "^9.0.3", "mysql2": "^3.16.0", "nestjs-pino": "^4.5.0", "nodemailer": "^6.10.1", @@ -62,6 +64,7 @@ "@nestjs/testing": "^10.4.20", "@types/express": "^5.0.6", "@types/jest": "^29.5.14", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.19.27", "@types/nodemailer": "^6.4.14", "@types/supertest": "^6.0.3", diff --git a/src/business/auth/auth.module.ts b/src/business/auth/auth.module.ts index 60e8e66..a63dbef 100644 --- a/src/business/auth/auth.module.ts +++ b/src/business/auth/auth.module.ts @@ -6,6 +6,7 @@ * - 用户登录、注册、密码管理 * - GitHub OAuth集成 * - 邮箱验证功能 + * - JWT令牌管理和验证 * * @author kiro-ai * @version 1.0.0 @@ -13,22 +14,41 @@ */ import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { LoginController } from './controllers/login.controller'; import { LoginService } from './services/login.service'; import { LoginCoreModule } from '../../core/login_core/login_core.module'; import { ZulipCoreModule } from '../../core/zulip/zulip-core.module'; import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module'; +import { UsersModule } from '../../core/db/users/users.module'; @Module({ imports: [ LoginCoreModule, ZulipCoreModule, ZulipAccountsModule.forRoot(), + UsersModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => { + const expiresIn = configService.get('JWT_EXPIRES_IN', '7d'); + return { + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: expiresIn as any, // JWT库支持字符串格式如 '7d' + issuer: 'whale-town', + audience: 'whale-town-users', + }, + }; + }, + inject: [ConfigService], + }), ], controllers: [LoginController], providers: [ LoginService, ], - exports: [LoginService], + exports: [LoginService, JwtModule], }) export class AuthModule {} \ No newline at end of file diff --git a/src/business/auth/controllers/login.controller.ts b/src/business/auth/controllers/login.controller.ts index c1d94af..0029901 100644 --- a/src/business/auth/controllers/login.controller.ts +++ b/src/business/auth/controllers/login.controller.ts @@ -13,6 +13,7 @@ * - POST /auth/forgot-password - 发送密码重置验证码 * - POST /auth/reset-password - 重置密码 * - PUT /auth/change-password - 修改密码 + * - POST /auth/refresh-token - 刷新访问令牌 * * @author moyin angjustinl * @version 1.0.0 @@ -23,7 +24,7 @@ import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UseP import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger'; import { Response } from 'express'; import { LoginService, ApiResponse, LoginResponse } from '../services/login.service'; -import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto } from '../dto/login.dto'; +import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from '../dto/login.dto'; import { LoginResponseDto, RegisterResponseDto, @@ -31,7 +32,8 @@ import { ForgotPasswordResponseDto, CommonResponseDto, TestModeEmailVerificationResponseDto, - SuccessEmailVerificationResponseDto + SuccessEmailVerificationResponseDto, + RefreshTokenResponseDto } from '../dto/login_response.dto'; import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator'; import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator'; @@ -609,4 +611,107 @@ export class LoginController { message: '限流记录已清除' }); } + + /** + * 刷新访问令牌 + * + * 功能描述: + * 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期 + * + * 业务逻辑: + * 1. 验证刷新令牌的有效性和格式 + * 2. 检查用户状态是否正常 + * 3. 生成新的JWT令牌对 + * 4. 返回新的访问令牌和刷新令牌 + * + * @param refreshTokenDto 刷新令牌数据 + * @param res Express响应对象 + * @returns 新的令牌对 + */ + @ApiOperation({ + summary: '刷新访问令牌', + description: '使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期。建议在访问令牌即将过期时调用此接口。' + }) + @ApiBody({ type: RefreshTokenDto }) + @SwaggerApiResponse({ + status: 200, + description: '令牌刷新成功', + type: RefreshTokenResponseDto + }) + @SwaggerApiResponse({ + status: 400, + description: '请求参数错误' + }) + @SwaggerApiResponse({ + status: 401, + description: '刷新令牌无效或已过期' + }) + @SwaggerApiResponse({ + status: 404, + description: '用户不存在或已被禁用' + }) + @SwaggerApiResponse({ + status: 429, + description: '刷新请求过于频繁' + }) + @Throttle(ThrottlePresets.REFRESH_TOKEN) + @Timeout(TimeoutPresets.NORMAL) + @Post('refresh-token') + @UsePipes(new ValidationPipe({ transform: true })) + async refreshToken(@Body() refreshTokenDto: RefreshTokenDto, @Res() res: Response): Promise { + const startTime = Date.now(); + + try { + this.logger.log('令牌刷新请求', { + operation: 'refreshToken', + timestamp: new Date().toISOString(), + }); + + const result = await this.loginService.refreshAccessToken(refreshTokenDto.refresh_token); + + const duration = Date.now() - startTime; + + if (result.success) { + this.logger.log('令牌刷新成功', { + operation: 'refreshToken', + duration, + timestamp: new Date().toISOString(), + }); + res.status(HttpStatus.OK).json(result); + } else { + this.logger.warn('令牌刷新失败', { + operation: 'refreshToken', + error: result.message, + errorCode: result.error_code, + duration, + timestamp: new Date().toISOString(), + }); + + // 根据错误类型设置不同的状态码 + if (result.message?.includes('令牌验证失败') || result.message?.includes('已过期')) { + res.status(HttpStatus.UNAUTHORIZED).json(result); + } else if (result.message?.includes('用户不存在')) { + res.status(HttpStatus.NOT_FOUND).json(result); + } else { + res.status(HttpStatus.BAD_REQUEST).json(result); + } + } + } catch (error) { + const duration = Date.now() - startTime; + const err = error as Error; + + this.logger.error('令牌刷新异常', { + operation: 'refreshToken', + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); + + res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + success: false, + message: '服务器内部错误', + error_code: 'INTERNAL_SERVER_ERROR' + }); + } + } } \ No newline at end of file diff --git a/src/business/auth/dto/login.dto.ts b/src/business/auth/dto/login.dto.ts index 02b563d..8d66ef2 100644 --- a/src/business/auth/dto/login.dto.ts +++ b/src/business/auth/dto/login.dto.ts @@ -424,4 +424,21 @@ export class SendLoginVerificationCodeDto { @IsNotEmpty({ message: '登录标识符不能为空' }) @Length(1, 100, { message: '登录标识符长度需在1-100字符之间' }) identifier: string; +} + +/** + * 刷新令牌请求DTO + */ +export class RefreshTokenDto { + /** + * 刷新令牌 + */ + @ApiProperty({ + description: 'JWT刷新令牌', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + minLength: 1 + }) + @IsString({ message: '刷新令牌必须是字符串' }) + @IsNotEmpty({ message: '刷新令牌不能为空' }) + refresh_token: string; } \ No newline at end of file diff --git a/src/business/auth/dto/login_response.dto.ts b/src/business/auth/dto/login_response.dto.ts index ef853f2..9fae08a 100644 --- a/src/business/auth/dto/login_response.dto.ts +++ b/src/business/auth/dto/login_response.dto.ts @@ -80,17 +80,28 @@ export class LoginResponseDataDto { user: UserInfoDto; @ApiProperty({ - description: '访问令牌', + description: 'JWT访问令牌', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }) access_token: string; @ApiProperty({ - description: '刷新令牌', - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - required: false + description: 'JWT刷新令牌', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }) - refresh_token?: string; + refresh_token: string; + + @ApiProperty({ + description: '访问令牌过期时间(秒)', + example: 604800 + }) + expires_in: number; + + @ApiProperty({ + description: '令牌类型', + example: 'Bearer' + }) + token_type: string; @ApiProperty({ description: '是否为新用户', @@ -392,4 +403,64 @@ export class SuccessEmailVerificationResponseDto { required: false }) error_code?: string; +} + +/** + * 令牌刷新响应数据DTO + */ +export class RefreshTokenResponseDataDto { + @ApiProperty({ + description: 'JWT访问令牌', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' + }) + access_token: string; + + @ApiProperty({ + description: 'JWT刷新令牌', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' + }) + refresh_token: string; + + @ApiProperty({ + description: '访问令牌过期时间(秒)', + example: 604800 + }) + expires_in: number; + + @ApiProperty({ + description: '令牌类型', + example: 'Bearer' + }) + token_type: string; +} + +/** + * 令牌刷新响应DTO + */ +export class RefreshTokenResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: true + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + type: RefreshTokenResponseDataDto, + required: false + }) + data?: RefreshTokenResponseDataDto; + + @ApiProperty({ + description: '响应消息', + example: '令牌刷新成功' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'TOKEN_REFRESH_FAILED', + required: false + }) + error_code?: string; } \ No newline at end of file diff --git a/src/business/auth/services/login.service.spec.ts b/src/business/auth/services/login.service.spec.ts index 26076c0..87e1a2e 100644 --- a/src/business/auth/services/login.service.spec.ts +++ b/src/business/auth/services/login.service.spec.ts @@ -1,14 +1,43 @@ /** * 登录业务服务测试 + * + * 功能描述: + * - 测试登录相关的业务逻辑 + * - 测试JWT令牌生成和验证 + * - 测试令牌刷新功能 + * - 测试各种异常情况处理 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-01-06 */ import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import { LoginService } from './login.service'; import { LoginCoreService } from '../../../core/login_core/login_core.service'; +import { UsersService } from '../../../core/db/users/users.service'; +import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service'; +import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository'; +import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service'; +import * as jwt from 'jsonwebtoken'; + +// Mock jwt module +jest.mock('jsonwebtoken', () => ({ + sign: jest.fn(), + verify: jest.fn(), +})); describe('LoginService', () => { let service: LoginService; let loginCoreService: jest.Mocked; + let jwtService: jest.Mocked; + let configService: jest.Mocked; + let usersService: jest.Mocked; + let zulipAccountService: jest.Mocked; + let zulipAccountsRepository: jest.Mocked; + let apiKeySecurityService: jest.Mocked; const mockUser = { id: BigInt(1), @@ -26,7 +55,20 @@ describe('LoginService', () => { updated_at: new Date() }; + const mockJwtSecret = 'test_jwt_secret_key_for_testing_32chars'; + const mockAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE2NDA5OTUyMDAsImlzcyI6IndoYWxlLXRvd24iLCJhdWQiOiJ3aGFsZS10b3duLXVzZXJzIn0.test'; + const mockRefreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJyZWZyZXNoIiwiaWF0IjoxNjQwOTk1MjAwLCJpc3MiOiJ3aGFsZS10b3duIiwiYXVkIjoid2hhbGUtdG93bi11c2VycyJ9.test'; + beforeEach(async () => { + // Mock environment variables for Zulip + const originalEnv = process.env; + process.env = { + ...originalEnv, + ZULIP_SERVER_URL: 'https://test.zulipchat.com', + ZULIP_BOT_EMAIL: 'test-bot@test.zulipchat.com', + ZULIP_BOT_API_KEY: 'test_api_key_12345', + }; + const mockLoginCoreService = { login: jest.fn(), register: jest.fn(), @@ -40,6 +82,36 @@ describe('LoginService', () => { verificationCodeLogin: jest.fn(), sendLoginVerificationCode: jest.fn(), debugVerificationCode: jest.fn(), + deleteUser: jest.fn(), + }; + + const mockJwtService = { + signAsync: jest.fn(), + verifyAsync: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + const mockUsersService = { + findOne: jest.fn(), + }; + + const mockZulipAccountService = { + initializeAdminClient: jest.fn(), + createZulipAccount: jest.fn(), + linkGameAccount: jest.fn(), + }; + + const mockZulipAccountsRepository = { + findByGameUserId: jest.fn(), + create: jest.fn(), + deleteByGameUserId: jest.fn(), + }; + + const mockApiKeySecurityService = { + storeApiKey: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -49,11 +121,72 @@ describe('LoginService', () => { provide: LoginCoreService, useValue: mockLoginCoreService, }, + { + provide: JwtService, + useValue: mockJwtService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: 'UsersService', + useValue: mockUsersService, + }, + { + provide: ZulipAccountService, + useValue: mockZulipAccountService, + }, + { + provide: 'ZulipAccountsRepository', + useValue: mockZulipAccountsRepository, + }, + { + provide: ApiKeySecurityService, + useValue: mockApiKeySecurityService, + }, ], }).compile(); service = module.get(LoginService); loginCoreService = module.get(LoginCoreService); + jwtService = module.get(JwtService); + configService = module.get(ConfigService); + usersService = module.get('UsersService'); + zulipAccountService = module.get(ZulipAccountService); + zulipAccountsRepository = module.get('ZulipAccountsRepository'); + apiKeySecurityService = module.get(ApiKeySecurityService); + + // Setup default config service mocks + configService.get.mockImplementation((key: string, defaultValue?: any) => { + const config = { + 'JWT_SECRET': mockJwtSecret, + 'JWT_EXPIRES_IN': '7d', + }; + return config[key] || defaultValue; + }); + + // Setup default JWT service mocks + jwtService.signAsync.mockResolvedValue(mockAccessToken); + (jwt.sign as jest.Mock).mockReturnValue(mockRefreshToken); + + // Setup default Zulip mocks + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + zulipAccountService.createZulipAccount.mockResolvedValue({ + success: true, + userId: 123, + email: 'test@example.com', + apiKey: 'mock_api_key' + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountsRepository.create.mockResolvedValue({} as any); + apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + // Restore original environment variables + jest.restoreAllMocks(); }); it('should be defined', () => { @@ -61,7 +194,7 @@ describe('LoginService', () => { }); describe('login', () => { - it('should login successfully', async () => { + it('should login successfully and return JWT tokens', async () => { loginCoreService.login.mockResolvedValue({ user: mockUser, isNewUser: false @@ -74,7 +207,40 @@ describe('LoginService', () => { expect(result.success).toBe(true); expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.access_token).toBeDefined(); + expect(result.data?.access_token).toBe(mockAccessToken); + expect(result.data?.refresh_token).toBe(mockRefreshToken); + expect(result.data?.expires_in).toBe(604800); // 7 days in seconds + expect(result.data?.token_type).toBe('Bearer'); + expect(result.data?.is_new_user).toBe(false); + expect(result.message).toBe('登录成功'); + + // Verify JWT service was called correctly + expect(jwtService.signAsync).toHaveBeenCalledWith({ + sub: '1', + username: 'testuser', + role: 1, + email: 'test@example.com', + type: 'access', + iat: expect.any(Number), + iss: 'whale-town', + aud: 'whale-town-users', + }); + + expect(jwt.sign).toHaveBeenCalledWith( + { + sub: '1', + username: 'testuser', + role: 1, + type: 'refresh', + iat: expect.any(Number), + iss: 'whale-town', + aud: 'whale-town-users', + }, + mockJwtSecret, + { + expiresIn: '30d', + } + ); }); it('should handle login failure', async () => { @@ -87,16 +253,80 @@ describe('LoginService', () => { expect(result.success).toBe(false); expect(result.error_code).toBe('LOGIN_FAILED'); + expect(result.message).toBe('用户名或密码错误'); + }); + + it('should handle JWT generation failure', async () => { + loginCoreService.login.mockResolvedValue({ + user: mockUser, + isNewUser: false + }); + + jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed')); + + const result = await service.login({ + identifier: 'testuser', + password: 'password123' + }); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('LOGIN_FAILED'); + expect(result.message).toContain('JWT generation failed'); + }); + + it('should handle missing JWT secret', async () => { + loginCoreService.login.mockResolvedValue({ + user: mockUser, + isNewUser: false + }); + + configService.get.mockImplementation((key: string) => { + if (key === 'JWT_SECRET') return undefined; + if (key === 'JWT_EXPIRES_IN') return '7d'; + return undefined; + }); + + const result = await service.login({ + identifier: 'testuser', + password: 'password123' + }); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('LOGIN_FAILED'); + expect(result.message).toContain('JWT_SECRET未配置'); }); }); describe('register', () => { - it('should register successfully', async () => { + it('should register successfully with JWT tokens', async () => { loginCoreService.register.mockResolvedValue({ user: mockUser, isNewUser: true }); + const result = await service.register({ + username: 'testuser', + password: 'password123', + nickname: '测试用户', + email: 'test@example.com' + }); + + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe('testuser'); + expect(result.data?.access_token).toBe(mockAccessToken); + expect(result.data?.refresh_token).toBe(mockRefreshToken); + expect(result.data?.expires_in).toBe(604800); + expect(result.data?.token_type).toBe('Bearer'); + expect(result.data?.is_new_user).toBe(true); + expect(result.message).toBe('注册成功,Zulip账号已同步创建'); + }); + + it('should register successfully without email', async () => { + loginCoreService.register.mockResolvedValue({ + user: { ...mockUser, email: null }, + isNewUser: true + }); + const result = await service.register({ username: 'testuser', password: 'password123', @@ -104,13 +334,323 @@ describe('LoginService', () => { }); expect(result.success).toBe(true); - expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.is_new_user).toBe(true); + expect(result.data?.message).toBe('注册成功'); + // Should not try to create Zulip account without email + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + }); + + it('should handle Zulip account creation failure and rollback', async () => { + loginCoreService.register.mockResolvedValue({ + user: mockUser, + isNewUser: true + }); + + zulipAccountService.createZulipAccount.mockResolvedValue({ + success: false, + error: 'Zulip creation failed' + }); + + loginCoreService.deleteUser.mockResolvedValue(undefined); + + const result = await service.register({ + username: 'testuser', + password: 'password123', + nickname: '测试用户', + email: 'test@example.com' + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip账号创建失败'); + expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockUser.id); + }); + + it('should handle register failure', async () => { + loginCoreService.register.mockRejectedValue(new Error('用户名已存在')); + + const result = await service.register({ + username: 'testuser', + password: 'password123', + nickname: '测试用户' + }); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('REGISTER_FAILED'); + expect(result.message).toBe('用户名已存在'); }); }); - describe('verificationCodeLogin', () => { - it('should login with verification code successfully', async () => { + describe('verifyToken', () => { + const mockPayload = { + sub: '1', + username: 'testuser', + role: 1, + type: 'access' as const, + iat: Math.floor(Date.now() / 1000), + iss: 'whale-town', + aud: 'whale-town-users', + }; + + it('should verify access token successfully', async () => { + (jwt.verify as jest.Mock).mockReturnValue(mockPayload); + + const result = await service.verifyToken(mockAccessToken, 'access'); + + expect(result).toEqual(mockPayload); + expect(jwt.verify).toHaveBeenCalledWith( + mockAccessToken, + mockJwtSecret, + { + issuer: 'whale-town', + audience: 'whale-town-users', + } + ); + }); + + it('should verify refresh token successfully', async () => { + const refreshPayload = { ...mockPayload, type: 'refresh' as const }; + (jwt.verify as jest.Mock).mockReturnValue(refreshPayload); + + const result = await service.verifyToken(mockRefreshToken, 'refresh'); + + expect(result).toEqual(refreshPayload); + }); + + it('should throw error for invalid token', async () => { + (jwt.verify as jest.Mock).mockImplementation(() => { + throw new Error('invalid token'); + }); + + await expect(service.verifyToken('invalid_token')).rejects.toThrow('令牌验证失败: invalid token'); + }); + + it('should throw error for token type mismatch', async () => { + const refreshPayload = { ...mockPayload, type: 'refresh' as const }; + (jwt.verify as jest.Mock).mockReturnValue(refreshPayload); + + await expect(service.verifyToken(mockRefreshToken, 'access')).rejects.toThrow('令牌类型不匹配'); + }); + + it('should throw error for incomplete payload', async () => { + const incompletePayload = { sub: '1', type: 'access' }; // missing username and role + (jwt.verify as jest.Mock).mockReturnValue(incompletePayload); + + await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('令牌载荷数据不完整'); + }); + + it('should throw error when JWT secret is missing', async () => { + configService.get.mockImplementation((key: string) => { + if (key === 'JWT_SECRET') return undefined; + return undefined; + }); + + await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('JWT_SECRET未配置'); + }); + }); + + describe('refreshAccessToken', () => { + const mockRefreshPayload = { + sub: '1', + username: 'testuser', + role: 1, + type: 'refresh' as const, + iat: Math.floor(Date.now() / 1000), + iss: 'whale-town', + aud: 'whale-town-users', + }; + + beforeEach(() => { + (jwt.verify as jest.Mock).mockReturnValue(mockRefreshPayload); + usersService.findOne.mockResolvedValue(mockUser); + }); + + it('should refresh access token successfully', async () => { + const result = await service.refreshAccessToken(mockRefreshToken); + + expect(result.success).toBe(true); + expect(result.data?.access_token).toBe(mockAccessToken); + expect(result.data?.refresh_token).toBe(mockRefreshToken); + expect(result.data?.expires_in).toBe(604800); + expect(result.data?.token_type).toBe('Bearer'); + expect(result.message).toBe('令牌刷新成功'); + + expect(jwt.verify).toHaveBeenCalledWith( + mockRefreshToken, + mockJwtSecret, + { + issuer: 'whale-town', + audience: 'whale-town-users', + } + ); + expect(usersService.findOne).toHaveBeenCalledWith(BigInt(1)); + }); + + it('should handle invalid refresh token', async () => { + (jwt.verify as jest.Mock).mockImplementation(() => { + throw new Error('invalid token'); + }); + + const result = await service.refreshAccessToken('invalid_token'); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('TOKEN_REFRESH_FAILED'); + expect(result.message).toContain('invalid token'); + }); + + it('should handle user not found', async () => { + usersService.findOne.mockResolvedValue(null); + + const result = await service.refreshAccessToken(mockRefreshToken); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('TOKEN_REFRESH_FAILED'); + expect(result.message).toBe('用户不存在或已被禁用'); + }); + + it('should handle user service error', async () => { + usersService.findOne.mockRejectedValue(new Error('Database error')); + + const result = await service.refreshAccessToken(mockRefreshToken); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('TOKEN_REFRESH_FAILED'); + expect(result.message).toContain('Database error'); + }); + + it('should handle JWT generation error during refresh', async () => { + jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed')); + + const result = await service.refreshAccessToken(mockRefreshToken); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('TOKEN_REFRESH_FAILED'); + expect(result.message).toContain('JWT generation failed'); + }); + }); + + describe('parseExpirationTime', () => { + it('should parse seconds correctly', () => { + const result = (service as any).parseExpirationTime('30s'); + expect(result).toBe(30); + }); + + it('should parse minutes correctly', () => { + const result = (service as any).parseExpirationTime('5m'); + expect(result).toBe(300); + }); + + it('should parse hours correctly', () => { + const result = (service as any).parseExpirationTime('2h'); + expect(result).toBe(7200); + }); + + it('should parse days correctly', () => { + const result = (service as any).parseExpirationTime('7d'); + expect(result).toBe(604800); + }); + + it('should parse weeks correctly', () => { + const result = (service as any).parseExpirationTime('2w'); + expect(result).toBe(1209600); + }); + + it('should return default for invalid format', () => { + const result = (service as any).parseExpirationTime('invalid'); + expect(result).toBe(604800); // 7 days default + }); + }); + + describe('generateTokenPair', () => { + it('should generate token pair successfully', async () => { + const result = await (service as any).generateTokenPair(mockUser); + + expect(result.access_token).toBe(mockAccessToken); + expect(result.refresh_token).toBe(mockRefreshToken); + expect(result.expires_in).toBe(604800); + expect(result.token_type).toBe('Bearer'); + + expect(jwtService.signAsync).toHaveBeenCalledWith({ + sub: '1', + username: 'testuser', + role: 1, + email: 'test@example.com', + type: 'access', + iat: expect.any(Number), + iss: 'whale-town', + aud: 'whale-town-users', + }); + + expect(jwt.sign).toHaveBeenCalledWith( + { + sub: '1', + username: 'testuser', + role: 1, + type: 'refresh', + iat: expect.any(Number), + iss: 'whale-town', + aud: 'whale-town-users', + }, + mockJwtSecret, + { + expiresIn: '30d', + } + ); + }); + + it('should handle missing JWT secret', async () => { + configService.get.mockImplementation((key: string) => { + if (key === 'JWT_SECRET') return undefined; + if (key === 'JWT_EXPIRES_IN') return '7d'; + return undefined; + }); + + await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT_SECRET未配置'); + }); + + it('should handle JWT service error', async () => { + jwtService.signAsync.mockRejectedValue(new Error('JWT service error')); + + await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT service error'); + }); + }); + + describe('formatUserInfo', () => { + it('should format user info correctly', () => { + const formattedUser = (service as any).formatUserInfo(mockUser); + + expect(formattedUser).toEqual({ + id: '1', + username: 'testuser', + nickname: '测试用户', + email: 'test@example.com', + phone: '+8613800138000', + avatar_url: null, + role: 1, + created_at: mockUser.created_at + }); + }); + }); + + describe('other methods', () => { + it('should handle githubOAuth successfully', async () => { + loginCoreService.githubOAuth.mockResolvedValue({ + user: mockUser, + isNewUser: false + }); + + const result = await service.githubOAuth({ + github_id: '12345', + username: 'testuser', + nickname: '测试用户', + email: 'test@example.com' + }); + + expect(result.success).toBe(true); + expect(result.data?.access_token).toBe(mockAccessToken); + expect(result.data?.refresh_token).toBe(mockRefreshToken); + expect(result.data?.message).toBe('GitHub登录成功'); + }); + + it('should handle verificationCodeLogin successfully', async () => { loginCoreService.verificationCodeLogin.mockResolvedValue({ user: mockUser, isNewUser: false @@ -123,23 +663,74 @@ describe('LoginService', () => { expect(result.success).toBe(true); expect(result.data?.user.email).toBe('test@example.com'); + expect(result.data?.access_token).toBe(mockAccessToken); + expect(result.data?.refresh_token).toBe(mockRefreshToken); + expect(result.data?.message).toBe('验证码登录成功'); }); - it('should handle verification code login failure', async () => { - loginCoreService.verificationCodeLogin.mockRejectedValue(new Error('验证码错误')); - - const result = await service.verificationCodeLogin({ - identifier: 'test@example.com', - verificationCode: '999999' + it('should handle sendPasswordResetCode in test mode', async () => { + loginCoreService.sendPasswordResetCode.mockResolvedValue({ + code: '123456', + isTestMode: true }); - expect(result.success).toBe(false); - expect(result.error_code).toBe('VERIFICATION_CODE_LOGIN_FAILED'); - }); - }); + const result = await service.sendPasswordResetCode('test@example.com'); - describe('sendLoginVerificationCode', () => { - it('should send login verification code successfully', async () => { + expect(result.success).toBe(false); // Test mode returns false + expect(result.data?.verification_code).toBe('123456'); + expect(result.data?.is_test_mode).toBe(true); + expect(result.error_code).toBe('TEST_MODE_ONLY'); + }); + + it('should handle resetPassword successfully', async () => { + loginCoreService.resetPassword.mockResolvedValue(undefined); + + const result = await service.resetPassword({ + identifier: 'test@example.com', + verificationCode: '123456', + newPassword: 'newpassword123' + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('密码重置成功'); + }); + + it('should handle changePassword successfully', async () => { + loginCoreService.changePassword.mockResolvedValue(undefined); + + const result = await service.changePassword( + BigInt(1), + 'oldpassword', + 'newpassword123' + ); + + expect(result.success).toBe(true); + expect(result.message).toBe('密码修改成功'); + }); + + it('should handle sendEmailVerification in test mode', async () => { + loginCoreService.sendEmailVerification.mockResolvedValue({ + code: '123456', + isTestMode: true + }); + + const result = await service.sendEmailVerification('test@example.com'); + + expect(result.success).toBe(false); + expect(result.data?.verification_code).toBe('123456'); + expect(result.error_code).toBe('TEST_MODE_ONLY'); + }); + + it('should handle verifyEmailCode successfully', async () => { + loginCoreService.verifyEmailCode.mockResolvedValue(true); + + const result = await service.verifyEmailCode('test@example.com', '123456'); + + expect(result.success).toBe(true); + expect(result.message).toBe('邮箱验证成功'); + }); + + it('should handle sendLoginVerificationCode successfully', async () => { loginCoreService.sendLoginVerificationCode.mockResolvedValue({ code: '123456', isTestMode: true @@ -151,5 +742,22 @@ describe('LoginService', () => { expect(result.data?.verification_code).toBe('123456'); expect(result.error_code).toBe('TEST_MODE_ONLY'); }); + + it('should handle debugVerificationCode successfully', async () => { + const mockDebugInfo = { + email: 'test@example.com', + code: '123456', + expiresAt: new Date(), + attempts: 0 + }; + + loginCoreService.debugVerificationCode.mockResolvedValue(mockDebugInfo); + + const result = await service.debugVerificationCode('test@example.com'); + + expect(result.success).toBe(true); + expect(result.data).toEqual(mockDebugInfo); + expect(result.message).toBe('调试信息获取成功'); + }); }); }); \ No newline at end of file diff --git a/src/business/auth/services/login.service.ts b/src/business/auth/services/login.service.ts index a9bcc0c..a5f0072 100644 --- a/src/business/auth/services/login.service.ts +++ b/src/business/auth/services/login.service.ts @@ -17,12 +17,54 @@ */ import { Injectable, Logger, Inject } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../../core/login_core/login_core.service'; import { Users } from '../../../core/db/users/users.entity'; +import { UsersService } from '../../../core/db/users/users.service'; import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service'; import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository'; import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service'; +/** + * JWT载荷接口 + */ +export interface JwtPayload { + /** 用户ID */ + sub: string; + /** 用户名 */ + username: string; + /** 用户角色 */ + role: number; + /** 邮箱 */ + email?: string; + /** 令牌类型 */ + type: 'access' | 'refresh'; + /** 签发时间 */ + iat?: number; + /** 过期时间 */ + exp?: number; + /** 签发者 */ + iss?: string; + /** 受众 */ + aud?: string; +} + +/** + * 令牌对接口 + */ +export interface TokenPair { + /** 访问令牌 */ + access_token: string; + /** 刷新令牌 */ + refresh_token: string; + /** 访问令牌过期时间(秒) */ + expires_in: number; + /** 令牌类型 */ + token_type: string; +} + /** * 登录响应数据接口 */ @@ -38,10 +80,14 @@ export interface LoginResponse { role: number; created_at: Date; }; - /** 访问令牌(实际应用中应生成JWT) */ + /** 访问令牌 */ access_token: string; /** 刷新令牌 */ - refresh_token?: string; + refresh_token: string; + /** 访问令牌过期时间(秒) */ + expires_in: number; + /** 令牌类型 */ + token_type: string; /** 是否为新用户 */ is_new_user?: boolean; /** 消息 */ @@ -72,33 +118,68 @@ export class LoginService { @Inject('ZulipAccountsRepository') private readonly zulipAccountsRepository: ZulipAccountsRepository, private readonly apiKeySecurityService: ApiKeySecurityService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + @Inject('UsersService') + private readonly usersService: UsersService, ) {} /** * 用户登录 * - * @param loginRequest 登录请求 - * @returns 登录响应 + * 功能描述: + * 处理用户登录请求,验证用户凭据并生成JWT令牌 + * + * 业务逻辑: + * 1. 调用核心服务进行用户认证 + * 2. 生成JWT访问令牌和刷新令牌 + * 3. 记录登录日志和安全审计 + * 4. 返回用户信息和令牌 + * + * @param loginRequest 登录请求数据 + * @returns Promise> 登录响应 + * + * @throws BadRequestException 当登录参数无效时 + * @throws UnauthorizedException 当用户凭据错误时 + * @throws InternalServerErrorException 当系统错误时 */ async login(loginRequest: LoginRequest): Promise> { + const startTime = Date.now(); + try { - this.logger.log(`用户登录尝试: ${loginRequest.identifier}`); + this.logger.log('用户登录尝试', { + operation: 'login', + identifier: loginRequest.identifier, + timestamp: new Date().toISOString(), + }); - // 调用核心服务进行认证 + // 1. 调用核心服务进行认证 const authResult = await this.loginCoreService.login(loginRequest); - // 生成访问令牌(实际应用中应使用JWT) - const accessToken = this.generateAccessToken(authResult.user); + // 2. 生成JWT令牌对 + const tokenPair = await this.generateTokenPair(authResult.user); - // 格式化响应数据 + // 3. 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), - access_token: accessToken, + access_token: tokenPair.access_token, + refresh_token: tokenPair.refresh_token, + expires_in: tokenPair.expires_in, + token_type: tokenPair.token_type, is_new_user: authResult.isNewUser, message: '登录成功' }; - this.logger.log(`用户登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`); + const duration = Date.now() - startTime; + + this.logger.log('用户登录成功', { + operation: 'login', + userId: authResult.user.id.toString(), + username: authResult.user.username, + isNewUser: authResult.isNewUser, + duration, + timestamp: new Date().toISOString(), + }); return { success: true, @@ -106,11 +187,20 @@ export class LoginService { message: '登录成功' }; } catch (error) { - this.logger.error(`用户登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error)); + const duration = Date.now() - startTime; + const err = error as Error; + + this.logger.error('用户登录失败', { + operation: 'login', + identifier: loginRequest.identifier, + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); return { success: false, - message: error instanceof Error ? error.message : '登录失败', + message: err.message || '登录失败', error_code: 'LOGIN_FAILED' }; } @@ -181,13 +271,16 @@ export class LoginService { throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`); } - // 4. 生成访问令牌 - const accessToken = this.generateAccessToken(authResult.user); + // 4. 生成JWT令牌对 + const tokenPair = await this.generateTokenPair(authResult.user); // 5. 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), - access_token: accessToken, + access_token: tokenPair.access_token, + refresh_token: tokenPair.refresh_token, + expires_in: tokenPair.expires_in, + token_type: tokenPair.token_type, is_new_user: true, message: zulipAccountCreated ? '注册成功,Zulip账号已同步创建' : '注册成功' }; @@ -241,13 +334,16 @@ export class LoginService { // 调用核心服务进行OAuth认证 const authResult = await this.loginCoreService.githubOAuth(oauthRequest); - // 生成访问令牌 - const accessToken = this.generateAccessToken(authResult.user); + // 生成JWT令牌对 + const tokenPair = await this.generateTokenPair(authResult.user); // 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), - access_token: accessToken, + access_token: tokenPair.access_token, + refresh_token: tokenPair.refresh_token, + expires_in: tokenPair.expires_in, + token_type: tokenPair.token_type, is_new_user: authResult.isNewUser, message: authResult.isNewUser ? 'GitHub账户绑定成功' : 'GitHub登录成功' }; @@ -534,23 +630,272 @@ export class LoginService { } /** - * 生成访问令牌 + * 生成JWT令牌对 + * + * 功能描述: + * 为用户生成访问令牌和刷新令牌,符合JWT标准和安全最佳实践 + * + * 业务逻辑: + * 1. 创建访问令牌载荷(短期有效) + * 2. 创建刷新令牌载荷(长期有效) + * 3. 使用配置的密钥签名令牌 + * 4. 返回完整的令牌对信息 * * @param user 用户信息 - * @returns 访问令牌 + * @returns Promise JWT令牌对 + * + * @throws InternalServerErrorException 当令牌生成失败时 + * + * @example + * ```typescript + * const tokenPair = await this.generateTokenPair(user); + * console.log(tokenPair.access_token); // JWT访问令牌 + * console.log(tokenPair.refresh_token); // JWT刷新令牌 + * ``` */ - private generateAccessToken(user: Users): string { - // 实际应用中应使用JWT库生成真正的JWT令牌 - // 这里仅用于演示,生成一个简单的令牌 - const payload = { - userId: user.id.toString(), - username: user.username, - role: user.role, - timestamp: Date.now() - }; + private async generateTokenPair(user: Users): Promise { + try { + const currentTime = Math.floor(Date.now() / 1000); + const jwtSecret = this.configService.get('JWT_SECRET'); + const expiresIn = this.configService.get('JWT_EXPIRES_IN', '7d'); + + if (!jwtSecret) { + throw new Error('JWT_SECRET未配置'); + } - // 简单的Base64编码(实际应用中应使用JWT) - return Buffer.from(JSON.stringify(payload)).toString('base64'); + // 1. 创建访问令牌载荷(不包含iss和aud,这些通过options传递) + const accessPayload: Omit = { + sub: user.id.toString(), + username: user.username, + role: user.role, + email: user.email, + type: 'access', + }; + + // 2. 创建刷新令牌载荷(有效期更长) + const refreshPayload: Omit = { + sub: user.id.toString(), + username: user.username, + role: user.role, + type: 'refresh', + }; + + // 3. 生成访问令牌(使用NestJS JwtService,通过options传递iss和aud) + const accessToken = await this.jwtService.signAsync(accessPayload, { + issuer: 'whale-town', + audience: 'whale-town-users', + }); + + // 4. 生成刷新令牌(有效期30天) + const refreshToken = jwt.sign(refreshPayload, jwtSecret, { + expiresIn: '30d', + issuer: 'whale-town', + audience: 'whale-town-users', + }); + + // 5. 计算过期时间(秒) + const expiresInSeconds = this.parseExpirationTime(expiresIn); + + this.logger.log('JWT令牌对生成成功', { + operation: 'generateTokenPair', + userId: user.id.toString(), + username: user.username, + expiresIn: expiresInSeconds, + timestamp: new Date().toISOString(), + }); + + return { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresInSeconds, + token_type: 'Bearer', + }; + + } catch (error) { + const err = error as Error; + + this.logger.error('JWT令牌对生成失败', { + operation: 'generateTokenPair', + userId: user.id.toString(), + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + throw new Error(`令牌生成失败: ${err.message}`); + } + } + + /** + * 验证JWT令牌 + * + * 功能描述: + * 验证JWT令牌的有效性,包括签名、过期时间和载荷格式 + * + * 业务逻辑: + * 1. 验证令牌签名和格式 + * 2. 检查令牌是否过期 + * 3. 验证载荷数据完整性 + * 4. 返回解码后的载荷信息 + * + * @param token JWT令牌字符串 + * @param tokenType 令牌类型(access 或 refresh) + * @returns Promise 解码后的载荷 + * + * @throws UnauthorizedException 当令牌无效时 + * @throws Error 当验证过程出错时 + */ + async verifyToken(token: string, tokenType: 'access' | 'refresh' = 'access'): Promise { + try { + const jwtSecret = this.configService.get('JWT_SECRET'); + + if (!jwtSecret) { + throw new Error('JWT_SECRET未配置'); + } + + // 1. 验证令牌并解码载荷 + const payload = jwt.verify(token, jwtSecret, { + issuer: 'whale-town', + audience: 'whale-town-users', + }) as JwtPayload; + + // 2. 验证令牌类型 + if (payload.type !== tokenType) { + throw new Error(`令牌类型不匹配,期望: ${tokenType},实际: ${payload.type}`); + } + + // 3. 验证载荷完整性 + if (!payload.sub || !payload.username || payload.role === undefined) { + throw new Error('令牌载荷数据不完整'); + } + + this.logger.log('JWT令牌验证成功', { + operation: 'verifyToken', + userId: payload.sub, + username: payload.username, + tokenType: payload.type, + timestamp: new Date().toISOString(), + }); + + return payload; + + } catch (error) { + const err = error as Error; + + this.logger.warn('JWT令牌验证失败', { + operation: 'verifyToken', + tokenType, + error: err.message, + timestamp: new Date().toISOString(), + }); + + throw new Error(`令牌验证失败: ${err.message}`); + } + } + + /** + * 刷新访问令牌 + * + * 功能描述: + * 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期 + * + * 业务逻辑: + * 1. 验证刷新令牌的有效性 + * 2. 从数据库获取最新用户信息 + * 3. 生成新的访问令牌 + * 4. 可选择性地轮换刷新令牌 + * + * @param refreshToken 刷新令牌 + * @returns Promise> 新的令牌对 + * + * @throws UnauthorizedException 当刷新令牌无效时 + * @throws NotFoundException 当用户不存在时 + */ + async refreshAccessToken(refreshToken: string): Promise> { + const startTime = Date.now(); + + try { + this.logger.log('开始刷新访问令牌', { + operation: 'refreshAccessToken', + timestamp: new Date().toISOString(), + }); + + // 1. 验证刷新令牌 + const payload = await this.verifyToken(refreshToken, 'refresh'); + + // 2. 获取最新用户信息 + const user = await this.usersService.findOne(BigInt(payload.sub)); + if (!user) { + throw new Error('用户不存在或已被禁用'); + } + + // 3. 生成新的令牌对 + const newTokenPair = await this.generateTokenPair(user); + + const duration = Date.now() - startTime; + + this.logger.log('访问令牌刷新成功', { + operation: 'refreshAccessToken', + userId: user.id.toString(), + username: user.username, + duration, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + data: newTokenPair, + message: '令牌刷新成功' + }; + + } catch (error) { + const duration = Date.now() - startTime; + const err = error as Error; + + this.logger.error('访问令牌刷新失败', { + operation: 'refreshAccessToken', + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + message: err.message || '令牌刷新失败', + error_code: 'TOKEN_REFRESH_FAILED' + }; + } + } + + /** + * 解析过期时间字符串 + * + * 功能描述: + * 将时间字符串(如 '7d', '24h', '60m')转换为秒数 + * + * @param expiresIn 过期时间字符串 + * @returns number 过期时间(秒) + * @private + */ + private parseExpirationTime(expiresIn: string): number { + if (!expiresIn || typeof expiresIn !== 'string') { + return 7 * 24 * 60 * 60; // 默认7天 + } + + const timeUnit = expiresIn.slice(-1); + const timeValue = parseInt(expiresIn.slice(0, -1)); + + if (isNaN(timeValue)) { + return 7 * 24 * 60 * 60; // 默认7天 + } + + switch (timeUnit) { + case 's': return timeValue; + case 'm': return timeValue * 60; + case 'h': return timeValue * 60 * 60; + case 'd': return timeValue * 24 * 60 * 60; + case 'w': return timeValue * 7 * 24 * 60 * 60; + default: return 7 * 24 * 60 * 60; // 默认7天 + } } /** * 验证码登录 @@ -565,13 +910,16 @@ export class LoginService { // 调用核心服务进行验证码认证 const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest); - // 生成访问令牌 - const accessToken = this.generateAccessToken(authResult.user); + // 生成JWT令牌对 + const tokenPair = await this.generateTokenPair(authResult.user); // 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), - access_token: accessToken, + access_token: tokenPair.access_token, + refresh_token: tokenPair.refresh_token, + expires_in: tokenPair.expires_in, + token_type: tokenPair.token_type, is_new_user: authResult.isNewUser, message: '验证码登录成功' }; diff --git a/src/business/auth/services/login.service.zulip-account.spec.ts b/src/business/auth/services/login.service.zulip-account.spec.ts index 4011b47..422ebfe 100644 --- a/src/business/auth/services/login.service.zulip-account.spec.ts +++ b/src/business/auth/services/login.service.zulip-account.spec.ts @@ -18,6 +18,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import * as fc from 'fast-check'; import { LoginService } from './login.service'; import { LoginCoreService, RegisterRequest } from '../../../core/login_core/login_core.service'; @@ -97,6 +99,41 @@ describe('LoginService - Zulip账号创建属性测试', () => { provide: ApiKeySecurityService, useValue: mockApiKeySecurityService, }, + { + provide: JwtService, + useValue: { + sign: jest.fn().mockReturnValue('mock_jwt_token'), + signAsync: jest.fn().mockResolvedValue('mock_jwt_token'), + verify: jest.fn(), + decode: jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + switch (key) { + case 'JWT_SECRET': + return 'test_jwt_secret_key_for_testing'; + case 'JWT_EXPIRES_IN': + return '7d'; + default: + return undefined; + } + }), + }, + }, + { + provide: 'UsersService', + useValue: { + findById: jest.fn(), + findByUsername: jest.fn(), + findByEmail: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, ], }).compile(); @@ -106,6 +143,9 @@ describe('LoginService - Zulip账号创建属性测试', () => { zulipAccountsRepository = module.get('ZulipAccountsRepository'); apiKeySecurityService = module.get(ApiKeySecurityService); + // Mock LoginService 的 initializeZulipAdminClient 方法 + jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined); + // 设置环境变量模拟 process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; @@ -167,7 +207,6 @@ describe('LoginService - Zulip账号创建属性测试', () => { } as ZulipAccounts; // 设置模拟行为 - zulipAccountService.initializeAdminClient.mockResolvedValue(true); loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, @@ -189,11 +228,7 @@ describe('LoginService - Zulip账号创建属性测试', () => { expect(result.data?.is_new_user).toBe(true); // 验证Zulip管理员客户端初始化 - expect(zulipAccountService.initializeAdminClient).toHaveBeenCalledWith({ - realm: 'https://test.zulip.com', - username: 'bot@test.zulip.com', - apiKey: 'test_api_key_123', - }); + expect(loginService['initializeZulipAdminClient']).toHaveBeenCalled(); // 验证游戏用户注册 expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); @@ -249,7 +284,6 @@ describe('LoginService - Zulip账号创建属性测试', () => { } as Users; // 设置模拟行为 - Zulip账号创建失败 - zulipAccountService.initializeAdminClient.mockResolvedValue(true); loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, @@ -318,7 +352,6 @@ describe('LoginService - Zulip账号创建属性测试', () => { } as ZulipAccounts; // 设置模拟行为 - 已存在Zulip账号关联 - zulipAccountService.initializeAdminClient.mockResolvedValue(true); loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, @@ -374,7 +407,6 @@ describe('LoginService - Zulip账号创建属性测试', () => { } as Users; // 设置模拟行为 - zulipAccountService.initializeAdminClient.mockResolvedValue(true); loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, @@ -404,7 +436,8 @@ describe('LoginService - Zulip账号创建属性测试', () => { await fc.assert( fc.asyncProperty(registerRequestArb, async (registerRequest) => { // 设置模拟行为 - 管理员客户端初始化失败 - zulipAccountService.initializeAdminClient.mockResolvedValue(false); + jest.spyOn(loginService as any, 'initializeZulipAdminClient') + .mockRejectedValue(new Error('Zulip管理员客户端初始化失败')); // 执行注册 const result = await loginService.register(registerRequest); @@ -418,6 +451,9 @@ describe('LoginService - Zulip账号创建属性测试', () => { // 验证没有尝试创建Zulip账号 expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + + // 恢复 mock + jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined); }), { numRuns: 50 } ); @@ -431,6 +467,10 @@ describe('LoginService - Zulip账号创建属性测试', () => { delete process.env.ZULIP_BOT_EMAIL; delete process.env.ZULIP_BOT_API_KEY; + // 重新设置 mock 以模拟环境变量缺失的错误 + jest.spyOn(loginService as any, 'initializeZulipAdminClient') + .mockRejectedValue(new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY')); + // 执行注册 const result = await loginService.register(registerRequest); @@ -441,10 +481,11 @@ describe('LoginService - Zulip账号创建属性测试', () => { // 验证没有尝试创建游戏用户 expect(loginCoreService.register).not.toHaveBeenCalled(); - // 恢复环境变量 + // 恢复环境变量和 mock process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; process.env.ZULIP_BOT_API_KEY = 'test_api_key_123'; + jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined); }), { numRuns: 30 } ); @@ -480,7 +521,6 @@ describe('LoginService - Zulip账号创建属性测试', () => { }; // 设置模拟行为 - zulipAccountService.initializeAdminClient.mockResolvedValue(true); loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, diff --git a/src/business/zulip/zulip.module.ts b/src/business/zulip/zulip.module.ts index 13590aa..5fcc0bc 100644 --- a/src/business/zulip/zulip.module.ts +++ b/src/business/zulip/zulip.module.ts @@ -38,8 +38,8 @@ * - 业务规则驱动的消息过滤和权限控制 * * @author angjustinl - * @version 2.0.0 - * @since 2025-12-31 + * @version 1.1.0 + * @since 2026-01-06 */ import { Module } from '@nestjs/common'; @@ -53,6 +53,7 @@ import { ZulipCoreModule } from '../../core/zulip/zulip-core.module'; import { RedisModule } from '../../core/redis/redis.module'; import { LoggerModule } from '../../core/utils/logger/logger.module'; import { LoginCoreModule } from '../../core/login_core/login_core.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ @@ -64,6 +65,8 @@ import { LoginCoreModule } from '../../core/login_core/login_core.module'; LoggerModule, // 登录模块 - 提供用户认证和Token验证 LoginCoreModule, + // 认证模块 - 提供JWT验证和用户认证服务 + AuthModule, ], providers: [ // 主协调服务 - 整合各子服务,提供统一业务接口 diff --git a/src/business/zulip/zulip.service.spec.ts b/src/business/zulip/zulip.service.spec.ts index aa021ce..5441332 100644 --- a/src/business/zulip/zulip.service.spec.ts +++ b/src/business/zulip/zulip.service.spec.ts @@ -40,6 +40,7 @@ import { ZulipClientInstance, SendMessageResult, } from '../../core/zulip/interfaces/zulip-core.interfaces'; +import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service'; describe('ZulipService', () => { let service: ZulipService; @@ -158,6 +159,19 @@ describe('ZulipService', () => { provide: 'ZULIP_CONFIG_SERVICE', useValue: mockConfigManager, }, + { + provide: ApiKeySecurityService, + useValue: { + extractApiKey: jest.fn(), + validateApiKey: jest.fn(), + encryptApiKey: jest.fn(), + decryptApiKey: jest.fn(), + getApiKey: jest.fn().mockResolvedValue({ + success: true, + apiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8', + }), + }, + }, ], }).compile(); diff --git a/src/business/zulip/zulip.service.ts b/src/business/zulip/zulip.service.ts index 0e18c51..d44c8fd 100644 --- a/src/business/zulip/zulip.service.ts +++ b/src/business/zulip/zulip.service.ts @@ -18,8 +18,8 @@ * - 消息格式转换和过滤 * * @author angjustinl - * @version 1.0.0 - * @since 2025-12-25 + * @version 1.1.0 + * @since 2026-01-06 */ import { Injectable, Logger, Inject } from '@nestjs/common'; @@ -32,6 +32,7 @@ import { IZulipConfigService, } from '../../core/zulip/interfaces/zulip-core.interfaces'; import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service'; +import { LoginService } from '../auth/services/login.service'; /** * 玩家登录请求接口 @@ -116,6 +117,7 @@ export class ZulipService { @Inject('ZULIP_CONFIG_SERVICE') private readonly configManager: IZulipConfigService, private readonly apiKeySecurityService: ApiKeySecurityService, + private readonly loginService: LoginService, ) { this.logger.log('ZulipService初始化完成'); @@ -175,9 +177,7 @@ export class ZulipService { }; } - // 2. 验证游戏Token并获取用户信息 - // TODO: 实际项目中应该调用认证服务验证Token - // 这里暂时使用模拟数据 + // 2. 验证游戏Token并获取用户信息 调用认证服务验证Token const userInfo = await this.validateGameToken(request.token); if (!userInfo) { this.logger.warn('登录失败:Token验证失败', { @@ -291,7 +291,7 @@ export class ZulipService { * 功能描述: * 验证游戏Token的有效性,返回用户信息 * - * @param token 游戏Token + * @param token 游戏Token (JWT) * @returns Promise 用户信息,验证失败返回null * @private */ @@ -302,69 +302,84 @@ export class ZulipService { zulipEmail?: string; zulipApiKey?: string; } | null> { - // TODO: 实际项目中应该调用认证服务验证Token (登录godot所获取的JWT token) - // 这里暂时使用模拟数据进行开发测试 - this.logger.debug('验证游戏Token', { operation: 'validateGameToken', tokenLength: token.length, }); - // 模拟Token验证 - // 实际实现应该: - // 1. 调用LoginService验证Token - // 2. 从数据库获取用户的Zulip API Key - // 3. 返回完整的用户信息 - - if (token.startsWith('invalid')) { - return null; - } - - // 从Token中提取用户ID(模拟) - const userId = `user_${token.substring(0, 8)}`; - - // 从ApiKeySecurityService获取真实的Zulip API Key - let zulipApiKey = undefined; - let zulipEmail = undefined; - try { - // 尝试从Redis获取存储的API Key - const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); - - if (apiKeyResult.success && apiKeyResult.apiKey) { - zulipApiKey = apiKeyResult.apiKey; - // TODO: 从数据库获取用户的Zulip邮箱 - // 暂时使用模拟数据 - zulipEmail = 'angjustinl@163.com'; - - this.logger.log('从存储获取到Zulip API Key', { + // 1. 使用LoginService验证JWT token + const payload = await this.loginService.verifyToken(token, 'access'); + + if (!payload || !payload.sub) { + this.logger.warn('Token载荷无效', { operation: 'validateGameToken', - userId, - hasApiKey: true, - zulipEmail, - }); - } else { - this.logger.debug('用户没有存储的Zulip API Key', { - operation: 'validateGameToken', - userId, }); + return null; } - } catch (error) { - const err = error as Error; - this.logger.warn('获取Zulip API Key失败', { + + const userId = payload.sub; + const username = payload.username || `user_${userId}`; + const email = payload.email || `${userId}@example.com`; + + this.logger.debug('Token解析成功', { operation: 'validateGameToken', userId, + username, + email, + }); + + // 2. 从ApiKeySecurityService获取真实的Zulip API Key + let zulipApiKey = undefined; + let zulipEmail = undefined; + + try { + // 尝试从Redis获取存储的API Key + const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); + + if (apiKeyResult.success && apiKeyResult.apiKey) { + zulipApiKey = apiKeyResult.apiKey; + // 使用游戏账号的邮箱 + zulipEmail = email; + + this.logger.log('从存储获取到Zulip API Key', { + operation: 'validateGameToken', + userId, + hasApiKey: true, + apiKeyLength: zulipApiKey.length, + }); + } else { + this.logger.debug('用户没有存储的Zulip API Key', { + operation: 'validateGameToken', + userId, + reason: apiKeyResult.message, + }); + } + } catch (error) { + const err = error as Error; + this.logger.warn('获取Zulip API Key失败', { + operation: 'validateGameToken', + userId, + error: err.message, + }); + } + + return { + userId, + username, + email, + zulipEmail, + zulipApiKey, + }; + + } catch (error) { + const err = error as Error; + this.logger.warn('Token验证失败', { + operation: 'validateGameToken', error: err.message, }); + return null; } - - return { - userId, - username: `Player_${userId.substring(5, 10)}`, - email: `${userId}@example.com`, - zulipEmail, - zulipApiKey, - }; } /** diff --git a/src/core/security_core/decorators/throttle.decorator.ts b/src/core/security_core/decorators/throttle.decorator.ts index d872f5b..c8f2ca8 100644 --- a/src/core/security_core/decorators/throttle.decorator.ts +++ b/src/core/security_core/decorators/throttle.decorator.ts @@ -81,6 +81,9 @@ export const ThrottlePresets = { /** 密码重置:每小时3次 */ RESET_PASSWORD: { limit: 3, ttl: 3600, message: '密码重置请求过于频繁,请1小时后再试' }, + /** 令牌刷新:每分钟10次 */ + REFRESH_TOKEN: { limit: 10, ttl: 60, message: '令牌刷新请求过于频繁,请稍后再试' }, + /** 管理员操作:每分钟10次 */ ADMIN_OPERATION: { limit: 10, ttl: 60, message: '管理员操作过于频繁,请稍后再试' },