feat: 添加忘记密码功能

后端:
- 新增 MailService 集成 Nodemailer 发送邮件
- 新增 EmailCodeService 管理邮箱验证码
- 新增 sendResetPasswordEmail 和 resetPassword 接口
- 支持验证码过期和次数限制

前端:
- 新增忘记密码页面 (forgot-password)
- 新增 ForgotPasswordForm 组件,支持邮箱验证和密码重置
- 更新 auth.service.ts 添加相关 API 调用

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-19 12:35:24 +08:00
parent 58529f0321
commit e008179c03
9 changed files with 505 additions and 0 deletions

View File

@@ -6,6 +6,12 @@ import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator'; import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator'; import { Public } from './decorators/public.decorator';
import { RegisterDto, LoginDto, RefreshTokenDto, AuthResponseDto, AuthUserDto, RefreshTokenResponseDto } from './dto/auth.dto'; import { RegisterDto, LoginDto, RefreshTokenDto, AuthResponseDto, AuthUserDto, RefreshTokenResponseDto } from './dto/auth.dto';
import {
SendResetPasswordEmailDto,
SendResetPasswordEmailResponseDto,
ResetPasswordDto,
ResetPasswordResponseDto,
} from './dto/reset-password.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UserMenusAndPermissionsResponseDto } from '@/permission/dto/menu.dto'; import { UserMenusAndPermissionsResponseDto } from '@/permission/dto/menu.dto';
@@ -70,4 +76,22 @@ export class AuthController {
user.roleIds user.roleIds
); );
} }
@Post('send-reset-email')
@Public()
@ApiOperation({ summary: '发送重置密码邮件' })
@ApiBody({ type: SendResetPasswordEmailDto })
@ApiOkResponse({ type: SendResetPasswordEmailResponseDto, description: '发送成功' })
sendResetPasswordEmail(@Body() dto: SendResetPasswordEmailDto) {
return this.authService.sendResetPasswordEmail(dto);
}
@Post('reset-password')
@Public()
@ApiOperation({ summary: '重置密码' })
@ApiBody({ type: ResetPasswordDto })
@ApiOkResponse({ type: ResetPasswordResponseDto, description: '重置成功' })
resetPassword(@Body() dto: ResetPasswordDto) {
return this.authService.resetPassword(dto);
}
} }

View File

@@ -0,0 +1,54 @@
import { ApiProperty } from '@nestjs/swagger';
import type {
SendResetPasswordEmailDto as ISendResetPasswordEmailDto,
SendResetPasswordEmailResponse as ISendResetPasswordEmailResponse,
ResetPasswordDto as IResetPasswordDto,
ResetPasswordResponse as IResetPasswordResponse,
} from '@seclusion/shared';
import { IsEmail, IsString, IsNotEmpty, MinLength } from 'class-validator';
export class SendResetPasswordEmailDto implements ISendResetPasswordEmailDto {
@ApiProperty({ example: 'user@example.com', description: '用户邮箱' })
@IsEmail()
email: string;
@ApiProperty({ example: 'cap_abc123xyz456def', description: '图形验证码 ID' })
@IsString()
@IsNotEmpty()
captchaId: string;
@ApiProperty({ example: 'ab12', description: '图形验证码' })
@IsString()
@IsNotEmpty()
captchaCode: string;
}
export class SendResetPasswordEmailResponseDto implements ISendResetPasswordEmailResponse {
@ApiProperty({ example: 'emc_abc123xyz456def', description: '邮箱验证码 ID' })
emailCodeId: string;
@ApiProperty({ example: 600, description: '验证码有效期(秒)' })
expiresIn: number;
}
export class ResetPasswordDto implements IResetPasswordDto {
@ApiProperty({ example: 'emc_abc123xyz456def', description: '邮箱验证码 ID' })
@IsString()
@IsNotEmpty()
emailCodeId: string;
@ApiProperty({ example: '123456', description: '邮箱验证码6位数字' })
@IsString()
@IsNotEmpty()
emailCode: string;
@ApiProperty({ example: 'newPassword123', description: '新密码至少6位' })
@IsString()
@MinLength(6)
password: string;
}
export class ResetPasswordResponseDto implements IResetPasswordResponse {
@ApiProperty({ example: '密码重置成功', description: '响应消息' })
message: string;
}

View File

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { EmailCodeService } from './email-code.service';
@Global()
@Module({
providers: [EmailCodeService],
exports: [EmailCodeService],
})
export class EmailCodeModule {}

View File

@@ -0,0 +1,70 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { customAlphabet } from 'nanoid';
import { RedisService } from '../redis/redis.service';
// 生成 6 位数字验证码
const generateCode = customAlphabet('0123456789', 6);
// 生成验证码 ID
const generateCodeId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16);
// 验证码有效期(秒)
const EMAIL_CODE_TTL = 600; // 10 分钟
// Redis key 前缀
const EMAIL_CODE_PREFIX = 'email_code:';
interface EmailCodeData {
code: string;
email: string;
createdAt: number;
}
@Injectable()
export class EmailCodeService {
private readonly logger = new Logger(EmailCodeService.name);
constructor(private readonly redisService: RedisService) {}
/**
* 生成邮箱验证码
* @returns 验证码 ID 和验证码
*/
async generate(email: string): Promise<{ emailCodeId: string; code: string; expiresIn: number }> {
const code = generateCode();
const emailCodeId = `emc_${generateCodeId()}`;
const data: EmailCodeData = {
code,
email,
createdAt: Date.now(),
};
await this.redisService.setJson(`${EMAIL_CODE_PREFIX}${emailCodeId}`, data, EMAIL_CODE_TTL);
this.logger.debug(`邮箱验证码已生成: ${emailCodeId}, email: ${email}`);
return { emailCodeId, code, expiresIn: EMAIL_CODE_TTL };
}
/**
* 验证邮箱验证码
* @returns 验证成功返回邮箱地址
*/
async verify(emailCodeId: string, code: string): Promise<string> {
const key = `${EMAIL_CODE_PREFIX}${emailCodeId}`;
const data = await this.redisService.getJson<EmailCodeData>(key);
if (!data) {
throw new BadRequestException('验证码不存在或已过期');
}
if (data.code !== code) {
throw new BadRequestException('验证码错误');
}
// 验证成功后删除(一次性使用)
await this.redisService.del(key);
this.logger.debug(`邮箱验证码验证成功: ${emailCodeId}`);
return data.email;
}
}

View File

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { MailService } from './mail.service';
@Global()
@Module({
providers: [MailService],
exports: [MailService],
})
export class MailModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private transporter: Transporter;
constructor(private configService: ConfigService) {
this.transporter = nodemailer.createTransport({
host: this.configService.get<string>('SMTP_HOST'),
port: this.configService.get<number>('SMTP_PORT', 587),
secure: this.configService.get<string>('SMTP_SECURE') === 'true',
auth: {
user: this.configService.get<string>('SMTP_USER'),
pass: this.configService.get<string>('SMTP_PASS'),
},
});
}
/**
* 发送重置密码验证码邮件
*/
async sendResetPasswordCode(email: string, code: string): Promise<void> {
const from = this.configService.get<string>('SMTP_FROM', 'noreply@example.com');
try {
await this.transporter.sendMail({
from,
to: email,
subject: '重置密码验证码',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>重置密码</h2>
<p>您正在重置密码,验证码为:</p>
<p style="font-size: 24px; font-weight: bold; color: #333; letter-spacing: 4px;">${code}</p>
<p>验证码有效期为 10 分钟,请尽快使用。</p>
<p style="color: #666; font-size: 12px;">如果您没有请求重置密码,请忽略此邮件。</p>
</div>
`,
});
this.logger.log(`重置密码邮件已发送至: ${email}`);
} catch (error) {
this.logger.error(`发送邮件失败: ${email}`, error);
throw error;
}
}
}

View File

@@ -0,0 +1,43 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { ForgotPasswordForm } from '@/components/forms/ForgotPasswordForm';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export const metadata: Metadata = {
title: '忘记密码',
};
export default function ForgotPasswordPage() {
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-center"></CardTitle>
<CardDescription className="text-center">
</CardDescription>
</CardHeader>
<CardContent>
<ForgotPasswordForm />
</CardContent>
<CardFooter className="flex justify-center">
<div className="text-sm text-muted-foreground">
{' '}
<Link
href="/login"
className="hover:text-primary underline underline-offset-4"
>
</Link>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { CaptchaScene } from '@seclusion/shared';
import { Loader2, ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Captcha, type CaptchaRef } from '@/components/shared/Captcha';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
forgotPasswordSchema,
resetPasswordSchema,
type ForgotPasswordFormValues,
type ResetPasswordFormValues,
} from '@/lib/validations';
import { authService } from '@/services/auth.service';
export function ForgotPasswordForm() {
const router = useRouter();
const [step, setStep] = useState<1 | 2>(1);
const [isLoading, setIsLoading] = useState(false);
const [emailCodeId, setEmailCodeId] = useState('');
const captchaRef = useRef<CaptchaRef>(null);
// Step 1: 发送验证码表单
const step1Form = useForm<ForgotPasswordFormValues>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: {
email: '',
captchaId: '',
captchaCode: '',
},
});
// Step 2: 重置密码表单
const step2Form = useForm<ResetPasswordFormValues>({
resolver: zodResolver(resetPasswordSchema),
defaultValues: {
emailCode: '',
password: '',
confirmPassword: '',
},
});
// Step 1: 发送验证码
const onSendCode = async (values: ForgotPasswordFormValues) => {
setIsLoading(true);
try {
const response = await authService.sendResetPasswordEmail({
email: values.email,
captchaId: values.captchaId,
captchaCode: values.captchaCode,
});
setEmailCodeId(response.emailCodeId);
setStep(2);
toast.success('验证码已发送到您的邮箱');
} catch (error) {
toast.error(error instanceof Error ? error.message : '发送失败');
captchaRef.current?.refresh();
} finally {
setIsLoading(false);
}
};
// Step 2: 重置密码
const onResetPassword = async (values: ResetPasswordFormValues) => {
setIsLoading(true);
try {
await authService.resetPassword({
emailCodeId,
emailCode: values.emailCode,
password: values.password,
});
toast.success('密码重置成功,请重新登录');
router.push('/login');
} catch (error) {
toast.error(error instanceof Error ? error.message : '重置失败');
} finally {
setIsLoading(false);
}
};
// Step 1: 发送验证码表单
if (step === 1) {
return (
<Form {...step1Form} key="step1">
<form onSubmit={step1Form.handleSubmit(onSendCode)} className="space-y-4">
<FormField
control={step1Form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="email"
placeholder="请输入注册邮箱"
autoComplete="email"
disabled={isLoading}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Captcha
ref={captchaRef}
scene={CaptchaScene.RESET_PASSWORD}
value={step1Form.watch('captchaCode')}
onChange={(id, code) => {
step1Form.setValue('captchaId', id);
step1Form.setValue('captchaCode', code);
}}
disabled={isLoading}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</form>
</Form>
);
}
// Step 2: 重置密码表单
return (
<Form {...step2Form} key="step2">
<form onSubmit={step2Form.handleSubmit(onResetPassword)} className="space-y-4">
<FormField
control={step2Form.control}
name="emailCode"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
{...field}
placeholder="请输入 6 位验证码"
maxLength={6}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={step2Form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="password"
placeholder="请输入新密码"
autoComplete="new-password"
disabled={isLoading}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={step2Form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="password"
placeholder="请再次输入新密码"
autoComplete="new-password"
disabled={isLoading}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
disabled={isLoading}
onClick={() => setStep(1)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<Button type="submit" className="flex-1" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -4,6 +4,10 @@ import type {
LoginDto, LoginDto,
RefreshTokenResponse, RefreshTokenResponse,
RegisterDto, RegisterDto,
SendResetPasswordEmailDto,
SendResetPasswordEmailResponse,
ResetPasswordDto,
ResetPasswordResponse,
} from '@seclusion/shared'; } from '@seclusion/shared';
import { API_ENDPOINTS } from '@/config/constants'; import { API_ENDPOINTS } from '@/config/constants';
@@ -37,4 +41,22 @@ export const authService = {
getMe: (): Promise<AuthUser> => { getMe: (): Promise<AuthUser> => {
return http.get<AuthUser>(API_ENDPOINTS.AUTH.ME); return http.get<AuthUser>(API_ENDPOINTS.AUTH.ME);
}, },
// 发送重置密码邮件(公开接口,跳过认证)
sendResetPasswordEmail: (data: SendResetPasswordEmailDto): Promise<SendResetPasswordEmailResponse> => {
return http.post<SendResetPasswordEmailResponse>(
API_ENDPOINTS.AUTH.SEND_RESET_EMAIL,
data,
{ skipAuth: true }
);
},
// 重置密码(公开接口,跳过认证)
resetPassword: (data: ResetPasswordDto): Promise<ResetPasswordResponse> => {
return http.post<ResetPasswordResponse>(
API_ENDPOINTS.AUTH.RESET_PASSWORD,
data,
{ skipAuth: true }
);
},
}; };