forked from datawhale/whale-town-end
feat: 添加JWT令牌刷新功能
- 新增 @nestjs/jwt 和 jsonwebtoken 依赖包 - 实现 refreshAccessToken 方法支持令牌续期 - 添加 RefreshTokenDto 和 RefreshTokenResponseDto - 新增 /auth/refresh-token 接口 - 完善令牌刷新的限流和超时控制 - 增加相关单元测试覆盖 - 优化错误处理和日志记录
This commit is contained in:
@@ -25,6 +25,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",
|
||||
@@ -40,6 +41,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",
|
||||
@@ -59,6 +61,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",
|
||||
|
||||
@@ -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<string>('JWT_EXPIRES_IN', '7d');
|
||||
return {
|
||||
secret: configService.get<string>('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 {}
|
||||
@@ -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<void> {
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,3 +425,20 @@ export class SendLoginVerificationCodeDto {
|
||||
@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;
|
||||
}
|
||||
@@ -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: '是否为新用户',
|
||||
@@ -393,3 +404,63 @@ export class SuccessEmailVerificationResponseDto {
|
||||
})
|
||||
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;
|
||||
}
|
||||
@@ -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<LoginCoreService>;
|
||||
let jwtService: jest.Mocked<JwtService>;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
let usersService: jest.Mocked<UsersService>;
|
||||
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
||||
let zulipAccountsRepository: jest.Mocked<ZulipAccountsRepository>;
|
||||
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
|
||||
|
||||
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>(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('调试信息获取成功');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ApiResponse<LoginResponse>> 登录响应
|
||||
*
|
||||
* @throws BadRequestException 当登录参数无效时
|
||||
* @throws UnauthorizedException 当用户凭据错误时
|
||||
* @throws InternalServerErrorException 当系统错误时
|
||||
*/
|
||||
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
this.logger.log(`用户登录尝试: ${loginRequest.identifier}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
// 调用核心服务进行认证
|
||||
try {
|
||||
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,273 @@ export class LoginService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成访问令牌
|
||||
* 生成JWT令牌对
|
||||
*
|
||||
* 功能描述:
|
||||
* 为用户生成访问令牌和刷新令牌,符合JWT标准和安全最佳实践
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 创建访问令牌载荷(短期有效)
|
||||
* 2. 创建刷新令牌载荷(长期有效)
|
||||
* 3. 使用配置的密钥签名令牌
|
||||
* 4. 返回完整的令牌对信息
|
||||
*
|
||||
* @param user 用户信息
|
||||
* @returns 访问令牌
|
||||
* @returns Promise<TokenPair> 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<TokenPair> {
|
||||
try {
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const jwtSecret = this.configService.get<string>('JWT_SECRET');
|
||||
const expiresIn = this.configService.get<string>('JWT_EXPIRES_IN', '7d');
|
||||
|
||||
// 简单的Base64编码(实际应用中应使用JWT)
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_SECRET未配置');
|
||||
}
|
||||
|
||||
// 1. 创建访问令牌载荷
|
||||
const accessPayload: JwtPayload = {
|
||||
sub: user.id.toString(),
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
email: user.email,
|
||||
type: 'access',
|
||||
iat: currentTime,
|
||||
iss: 'whale-town',
|
||||
aud: 'whale-town-users',
|
||||
};
|
||||
|
||||
// 2. 创建刷新令牌载荷(有效期更长)
|
||||
const refreshPayload: JwtPayload = {
|
||||
sub: user.id.toString(),
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
type: 'refresh',
|
||||
iat: currentTime,
|
||||
iss: 'whale-town',
|
||||
aud: 'whale-town-users',
|
||||
};
|
||||
|
||||
// 3. 生成访问令牌(使用NestJS JwtService)
|
||||
const accessToken = await this.jwtService.signAsync(accessPayload);
|
||||
|
||||
// 4. 生成刷新令牌(有效期30天)
|
||||
const refreshToken = jwt.sign(refreshPayload, jwtSecret, {
|
||||
expiresIn: '30d',
|
||||
});
|
||||
|
||||
// 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<JwtPayload> 解码后的载荷
|
||||
*
|
||||
* @throws UnauthorizedException 当令牌无效时
|
||||
* @throws Error 当验证过程出错时
|
||||
*/
|
||||
async verifyToken(token: string, tokenType: 'access' | 'refresh' = 'access'): Promise<JwtPayload> {
|
||||
try {
|
||||
const jwtSecret = this.configService.get<string>('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<ApiResponse<TokenPair>> 新的令牌对
|
||||
*
|
||||
* @throws UnauthorizedException 当刷新令牌无效时
|
||||
* @throws NotFoundException 当用户不存在时
|
||||
*/
|
||||
async refreshAccessToken(refreshToken: string): Promise<ApiResponse<TokenPair>> {
|
||||
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 +911,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: '验证码登录成功'
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: '管理员操作过于频繁,请稍后再试' },
|
||||
|
||||
|
||||
Reference in New Issue
Block a user