forked from datawhale/whale-town-end
feat(login): Add verification code login functionality
- Add verification code login endpoint to support passwordless authentication via email or phone - Add send login verification code endpoint to initiate verification code delivery - Implement verificationCodeLogin method in LoginService to handle verification code authentication - Implement sendLoginVerificationCode method in LoginService to send verification codes to users - Add VerificationCodeLoginRequest and related DTOs to support new login flow - Add VerificationCodeLoginDto and SendLoginVerificationCodeDto for API request validation - Implement verificationCodeLogin and sendLoginVerificationCode in LoginCoreService - Add comprehensive Swagger documentation for new endpoints with proper status codes and responses - Support test mode for verification code delivery with 206 Partial Content status - Fix UsersService dependency injection in test specifications to use string token - Enhance authentication options by providing passwordless login alternative to traditional password-based authentication
This commit is contained in:
@@ -23,7 +23,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 './login.service';
|
||||
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto } from '../../dto/login.dto';
|
||||
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto } from '../../dto/login.dto';
|
||||
import {
|
||||
LoginResponseDto,
|
||||
RegisterResponseDto,
|
||||
@@ -398,6 +398,96 @@ export class LoginController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录
|
||||
*
|
||||
* @param verificationCodeLoginDto 验证码登录数据
|
||||
* @returns 登录结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '验证码登录',
|
||||
description: '使用邮箱或手机号和验证码进行登录,无需密码'
|
||||
})
|
||||
@ApiBody({ type: VerificationCodeLoginDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '验证码登录成功',
|
||||
type: LoginResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 401,
|
||||
description: '验证码错误或已过期'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 404,
|
||||
description: '用户不存在'
|
||||
})
|
||||
@Post('verification-code-login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async verificationCodeLogin(@Body() verificationCodeLoginDto: VerificationCodeLoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||
return await this.loginService.verificationCodeLogin({
|
||||
identifier: verificationCodeLoginDto.identifier,
|
||||
verificationCode: verificationCodeLoginDto.verification_code
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送登录验证码
|
||||
*
|
||||
* @param sendLoginVerificationCodeDto 发送验证码数据
|
||||
* @param res Express响应对象
|
||||
* @returns 发送结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '发送登录验证码',
|
||||
description: '向用户邮箱或手机发送登录验证码'
|
||||
})
|
||||
@ApiBody({ type: SendLoginVerificationCodeDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '验证码发送成功',
|
||||
type: ForgotPasswordResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 206,
|
||||
description: '测试模式:验证码已生成但未真实发送',
|
||||
type: ForgotPasswordResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 404,
|
||||
description: '用户不存在'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '发送频率过高'
|
||||
})
|
||||
@Post('send-login-verification-code')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async sendLoginVerificationCode(
|
||||
@Body() sendLoginVerificationCodeDto: SendLoginVerificationCodeDto,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const result = await this.loginService.sendLoginVerificationCode(sendLoginVerificationCodeDto.identifier);
|
||||
|
||||
// 根据结果设置不同的状态码
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else if (result.error_code === 'TEST_MODE_ONLY') {
|
||||
res.status(HttpStatus.PARTIAL_CONTENT).json(result); // 206 Partial Content
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试验证码信息
|
||||
* 仅用于开发和调试
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult } from '../../core/login_core/login_core.service';
|
||||
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../core/login_core/login_core.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
|
||||
/**
|
||||
@@ -475,6 +475,96 @@ export class LoginService {
|
||||
// 简单的Base64编码(实际应用中应使用JWT)
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
||||
}
|
||||
/**
|
||||
* 验证码登录
|
||||
*
|
||||
* @param loginRequest 验证码登录请求
|
||||
* @returns 登录响应
|
||||
*/
|
||||
async verificationCodeLogin(loginRequest: VerificationCodeLoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
this.logger.log(`验证码登录尝试: ${loginRequest.identifier}`);
|
||||
|
||||
// 调用核心服务进行验证码认证
|
||||
const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest);
|
||||
|
||||
// 生成访问令牌
|
||||
const accessToken = this.generateAccessToken(authResult.user);
|
||||
|
||||
// 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: accessToken,
|
||||
is_new_user: authResult.isNewUser,
|
||||
message: '验证码登录成功'
|
||||
};
|
||||
|
||||
this.logger.log(`验证码登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: '验证码登录成功'
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`验证码登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '验证码登录失败',
|
||||
error_code: 'VERIFICATION_CODE_LOGIN_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送登录验证码
|
||||
*
|
||||
* @param identifier 邮箱或手机号
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async sendLoginVerificationCode(identifier: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`发送登录验证码: ${identifier}`);
|
||||
|
||||
// 调用核心服务发送验证码
|
||||
const result = await this.loginCoreService.sendLoginVerificationCode(identifier);
|
||||
|
||||
this.logger.log(`登录验证码已发送: ${identifier}`);
|
||||
|
||||
// 根据是否为测试模式返回不同的状态和消息
|
||||
if (result.isTestMode) {
|
||||
// 测试模式:验证码生成但未真实发送
|
||||
return {
|
||||
success: false, // 测试模式下不算真正成功
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
|
||||
error_code: 'TEST_MODE_ONLY'
|
||||
};
|
||||
} else {
|
||||
// 真实发送模式
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: '验证码已发送,请查收'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`发送登录验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '发送验证码失败',
|
||||
error_code: 'SEND_LOGIN_CODE_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试验证码信息
|
||||
*
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('LoginCoreService', () => {
|
||||
providers: [
|
||||
LoginCoreService,
|
||||
{
|
||||
provide: UsersService,
|
||||
provide: 'UsersService',
|
||||
useValue: mockUsersService,
|
||||
},
|
||||
{
|
||||
@@ -70,7 +70,7 @@ describe('LoginCoreService', () => {
|
||||
}).compile();
|
||||
|
||||
service = module.get<LoginCoreService>(LoginCoreService);
|
||||
usersService = module.get(UsersService);
|
||||
usersService = module.get('UsersService');
|
||||
emailService = module.get(EmailService);
|
||||
verificationService = module.get(VerificationService);
|
||||
});
|
||||
|
||||
@@ -100,6 +100,16 @@ export interface VerificationCodeResult {
|
||||
isTestMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录请求数据接口
|
||||
*/
|
||||
export interface VerificationCodeLoginRequest {
|
||||
/** 登录标识符:邮箱或手机号 */
|
||||
identifier: string;
|
||||
/** 验证码 */
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LoginCoreService {
|
||||
constructor(
|
||||
@@ -582,6 +592,157 @@ export class LoginCoreService {
|
||||
const phoneRegex = /^(\+\d{1,3}[- ]?)?\d{10,11}$/;
|
||||
return phoneRegex.test(str.replace(/\s/g, ''));
|
||||
}
|
||||
/**
|
||||
* 验证码登录 ANG 12.19
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用邮箱或手机号和验证码进行登录,无需密码
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证标识符格式(邮箱或手机号)
|
||||
* 2. 查找对应的用户
|
||||
* 3. 验证验证码的有效性
|
||||
* 4. 返回用户信息
|
||||
*
|
||||
* @param loginRequest 验证码登录请求数据
|
||||
* @returns 认证结果
|
||||
* @throws BadRequestException 参数验证失败时
|
||||
* @throws UnauthorizedException 验证码验证失败时
|
||||
* @throws NotFoundException 用户不存在时
|
||||
*/
|
||||
async verificationCodeLogin(loginRequest: VerificationCodeLoginRequest): Promise<AuthResult> {
|
||||
const { identifier, verificationCode } = loginRequest;
|
||||
|
||||
// 1. 验证参数
|
||||
if (!identifier || !verificationCode) {
|
||||
throw new BadRequestException('邮箱/手机号和验证码不能为空');
|
||||
}
|
||||
|
||||
// 2. 查找用户
|
||||
let user: Users | null = null;
|
||||
let verificationType: VerificationCodeType;
|
||||
|
||||
if (this.isEmail(identifier)) {
|
||||
// 邮箱登录
|
||||
user = await this.usersService.findByEmail(identifier);
|
||||
verificationType = VerificationCodeType.EMAIL_VERIFICATION;
|
||||
|
||||
// 检查邮箱是否已验证
|
||||
if (user && !user.email_verified) {
|
||||
throw new BadRequestException('邮箱未验证,请先验证邮箱后再使用验证码登录');
|
||||
}
|
||||
} else if (this.isPhoneNumber(identifier)) {
|
||||
// 手机号登录
|
||||
const users = await this.usersService.findAll();
|
||||
user = users.find((u: Users) => u.phone === identifier) || null;
|
||||
verificationType = VerificationCodeType.SMS_VERIFICATION;
|
||||
} else {
|
||||
throw new BadRequestException('请提供有效的邮箱或手机号');
|
||||
}
|
||||
|
||||
// 3. 检查用户是否存在
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在,请先注册账户');
|
||||
}
|
||||
|
||||
// 4. 验证验证码
|
||||
try {
|
||||
const isValidCode = await this.verificationService.verifyCode(
|
||||
identifier,
|
||||
verificationType,
|
||||
verificationCode
|
||||
);
|
||||
|
||||
if (!isValidCode) {
|
||||
throw new UnauthorizedException('验证码验证失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) {
|
||||
// 验证码相关的业务异常(过期、错误等)
|
||||
throw new UnauthorizedException(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 5. 验证成功,返回用户信息
|
||||
return {
|
||||
user,
|
||||
isNewUser: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送登录验证码
|
||||
*
|
||||
* 功能描述:
|
||||
* 为验证码登录发送验证码到用户的邮箱或手机号
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证标识符格式
|
||||
* 2. 检查用户是否存在
|
||||
* 3. 生成并发送验证码
|
||||
*
|
||||
* @param identifier 邮箱或手机号
|
||||
* @returns 验证码结果
|
||||
* @throws NotFoundException 用户不存在时
|
||||
* @throws BadRequestException 邮箱未验证时
|
||||
*/
|
||||
async sendLoginVerificationCode(identifier: string): Promise<VerificationCodeResult> {
|
||||
// 1. 查找用户
|
||||
let user: Users | null = null;
|
||||
let verificationType: VerificationCodeType;
|
||||
|
||||
if (this.isEmail(identifier)) {
|
||||
user = await this.usersService.findByEmail(identifier);
|
||||
verificationType = VerificationCodeType.EMAIL_VERIFICATION;
|
||||
|
||||
// 检查邮箱是否已验证
|
||||
if (user && !user.email_verified) {
|
||||
throw new BadRequestException('邮箱未验证,无法使用验证码登录');
|
||||
}
|
||||
} else if (this.isPhoneNumber(identifier)) {
|
||||
const users = await this.usersService.findAll();
|
||||
user = users.find((u: Users) => u.phone === identifier) || null;
|
||||
verificationType = VerificationCodeType.SMS_VERIFICATION;
|
||||
} else {
|
||||
throw new BadRequestException('请提供有效的邮箱或手机号');
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 2. 生成验证码
|
||||
const verificationCode = await this.verificationService.generateCode(
|
||||
identifier,
|
||||
verificationType
|
||||
);
|
||||
|
||||
// 3. 发送验证码
|
||||
let isTestMode = false;
|
||||
|
||||
if (this.isEmail(identifier)) {
|
||||
const result = await this.emailService.sendVerificationCode({
|
||||
email: identifier,
|
||||
code: verificationCode,
|
||||
nickname: user.nickname,
|
||||
purpose: 'login_verification'
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new BadRequestException('验证码发送失败,请稍后重试');
|
||||
}
|
||||
|
||||
isTestMode = result.isTestMode;
|
||||
} else {
|
||||
// TODO: 实现短信发送
|
||||
console.log(`短信验证码(${identifier}): ${verificationCode}`);
|
||||
isTestMode = true; // 短信也是测试模式
|
||||
}
|
||||
|
||||
return { code: verificationCode, isTestMode };
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试验证码信息
|
||||
*
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface VerificationEmailOptions {
|
||||
/** 用户昵称 */
|
||||
nickname?: string;
|
||||
/** 验证码用途 */
|
||||
purpose: 'email_verification' | 'password_reset';
|
||||
purpose: 'email_verification' | 'password_reset' | 'login_verification';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,9 +167,15 @@ export class EmailService {
|
||||
if (purpose === 'email_verification') {
|
||||
subject = '【Whale Town】邮箱验证码';
|
||||
template = this.getEmailVerificationTemplate(code, nickname);
|
||||
} else {
|
||||
} else if (purpose === 'password_reset') {
|
||||
subject = '【Whale Town】密码重置验证码';
|
||||
template = this.getPasswordResetTemplate(code, nickname);
|
||||
} else if (purpose === 'login_verification') {
|
||||
subject = '【Whale Town】登录验证码';
|
||||
template = this.getLoginVerificationTemplate(code, nickname);
|
||||
} else {
|
||||
subject = '【Whale Town】验证码';
|
||||
template = this.getEmailVerificationTemplate(code, nickname);
|
||||
}
|
||||
|
||||
return await this.sendEmail({
|
||||
@@ -322,6 +328,68 @@ export class EmailService {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录验证码模板
|
||||
*
|
||||
* @param code 验证码
|
||||
* @param nickname 用户昵称
|
||||
* @returns HTML模板
|
||||
*/
|
||||
private getLoginVerificationTemplate(code: string, nickname?: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录验证码</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
|
||||
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.code-box { background: #fff; border: 2px dashed #667eea; padding: 20px; text-align: center; margin: 20px 0; border-radius: 8px; }
|
||||
.code { font-size: 32px; font-weight: bold; color: #667eea; letter-spacing: 5px; }
|
||||
.footer { text-align: center; margin-top: 20px; color: #666; font-size: 12px; }
|
||||
.info { background: #e3f2fd; border: 1px solid #bbdefb; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔐 登录验证码</h1>
|
||||
<p>Whale Town 安全登录</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>你好${nickname ? ` ${nickname}` : ''}!</h2>
|
||||
<p>您正在使用验证码登录 Whale Town。请使用以下验证码完成登录:</p>
|
||||
|
||||
<div class="code-box">
|
||||
<div class="code">${code}</div>
|
||||
<p style="margin: 10px 0 0 0; color: #666;">登录验证码</p>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<strong>📱 使用说明:</strong>
|
||||
<ul style="margin: 10px 0 0 20px;">
|
||||
<li>验证码 5 分钟内有效</li>
|
||||
<li>请在登录页面输入此验证码</li>
|
||||
<li>验证码仅限本次登录使用</li>
|
||||
<li>请勿将验证码泄露给他人</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>如果您没有尝试登录,请忽略此邮件,或联系客服确认账户安全。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿回复</p>
|
||||
<p>© 2025 Whale Town Game. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取欢迎邮件模板
|
||||
*
|
||||
|
||||
@@ -371,4 +371,57 @@ export class SendEmailVerificationDto {
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
@IsNotEmpty({ message: '邮箱不能为空' })
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录请求DTO
|
||||
*/
|
||||
export class VerificationCodeLoginDto {
|
||||
/**
|
||||
* 登录标识符
|
||||
* 支持邮箱或手机号登录
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '登录标识符,支持邮箱或手机号',
|
||||
example: 'test@example.com',
|
||||
minLength: 1,
|
||||
maxLength: 100
|
||||
})
|
||||
@IsString({ message: '登录标识符必须是字符串' })
|
||||
@IsNotEmpty({ message: '登录标识符不能为空' })
|
||||
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* 验证码
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '6位数字验证码',
|
||||
example: '123456',
|
||||
pattern: '^\\d{6}$'
|
||||
})
|
||||
@IsString({ message: '验证码必须是字符串' })
|
||||
@IsNotEmpty({ message: '验证码不能为空' })
|
||||
@Matches(/^\d{6}$/, { message: '验证码必须是6位数字' })
|
||||
verification_code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送登录验证码请求DTO
|
||||
*/
|
||||
export class SendLoginVerificationCodeDto {
|
||||
/**
|
||||
* 登录标识符
|
||||
* 支持邮箱或手机号
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '登录标识符,支持邮箱或手机号',
|
||||
example: 'test@example.com',
|
||||
minLength: 1,
|
||||
maxLength: 100
|
||||
})
|
||||
@IsString({ message: '登录标识符必须是字符串' })
|
||||
@IsNotEmpty({ message: '登录标识符不能为空' })
|
||||
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
|
||||
identifier: string;
|
||||
}
|
||||
Reference in New Issue
Block a user