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:
@@ -6,6 +6,12 @@ import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
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 { UserMenusAndPermissionsResponseDto } from '@/permission/dto/menu.dto';
|
||||
@@ -70,4 +76,22 @@ export class AuthController {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
54
apps/api/src/auth/dto/reset-password.dto.ts
Normal file
54
apps/api/src/auth/dto/reset-password.dto.ts
Normal 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;
|
||||
}
|
||||
10
apps/api/src/common/email-code/email-code.module.ts
Normal file
10
apps/api/src/common/email-code/email-code.module.ts
Normal 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 {}
|
||||
70
apps/api/src/common/email-code/email-code.service.ts
Normal file
70
apps/api/src/common/email-code/email-code.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
apps/api/src/common/mail/mail.module.ts
Normal file
10
apps/api/src/common/mail/mail.module.ts
Normal 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 {}
|
||||
50
apps/api/src/common/mail/mail.service.ts
Normal file
50
apps/api/src/common/mail/mail.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
apps/web/src/app/(auth)/forgot-password/page.tsx
Normal file
43
apps/web/src/app/(auth)/forgot-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
apps/web/src/components/forms/ForgotPasswordForm.tsx
Normal file
222
apps/web/src/components/forms/ForgotPasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import type {
|
||||
LoginDto,
|
||||
RefreshTokenResponse,
|
||||
RegisterDto,
|
||||
SendResetPasswordEmailDto,
|
||||
SendResetPasswordEmailResponse,
|
||||
ResetPasswordDto,
|
||||
ResetPasswordResponse,
|
||||
} from '@seclusion/shared';
|
||||
|
||||
import { API_ENDPOINTS } from '@/config/constants';
|
||||
@@ -37,4 +41,22 @@ export const authService = {
|
||||
getMe: (): Promise<AuthUser> => {
|
||||
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 }
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user