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:
angjustinl
2025-12-19 23:22:40 +08:00
parent 4e2f46223e
commit 9b35a1c500
6 changed files with 468 additions and 6 deletions

View File

@@ -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);
});

View File

@@ -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 };
}
/**
* 调试验证码信息
*

View File

@@ -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>`;
}
/**
* 获取欢迎邮件模板
*