diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 29c5ba7..ed115e7 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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); + } } diff --git a/apps/api/src/auth/dto/reset-password.dto.ts b/apps/api/src/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..5ebd4c8 --- /dev/null +++ b/apps/api/src/auth/dto/reset-password.dto.ts @@ -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; +} diff --git a/apps/api/src/common/email-code/email-code.module.ts b/apps/api/src/common/email-code/email-code.module.ts new file mode 100644 index 0000000..fb6067f --- /dev/null +++ b/apps/api/src/common/email-code/email-code.module.ts @@ -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 {} diff --git a/apps/api/src/common/email-code/email-code.service.ts b/apps/api/src/common/email-code/email-code.service.ts new file mode 100644 index 0000000..126f70f --- /dev/null +++ b/apps/api/src/common/email-code/email-code.service.ts @@ -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 { + const key = `${EMAIL_CODE_PREFIX}${emailCodeId}`; + const data = await this.redisService.getJson(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; + } +} diff --git a/apps/api/src/common/mail/mail.module.ts b/apps/api/src/common/mail/mail.module.ts new file mode 100644 index 0000000..408b2e9 --- /dev/null +++ b/apps/api/src/common/mail/mail.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; + +import { MailService } from './mail.service'; + +@Global() +@Module({ + providers: [MailService], + exports: [MailService], +}) +export class MailModule {} diff --git a/apps/api/src/common/mail/mail.service.ts b/apps/api/src/common/mail/mail.service.ts new file mode 100644 index 0000000..8b68b96 --- /dev/null +++ b/apps/api/src/common/mail/mail.service.ts @@ -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('SMTP_HOST'), + port: this.configService.get('SMTP_PORT', 587), + secure: this.configService.get('SMTP_SECURE') === 'true', + auth: { + user: this.configService.get('SMTP_USER'), + pass: this.configService.get('SMTP_PASS'), + }, + }); + } + + /** + * 发送重置密码验证码邮件 + */ + async sendResetPasswordCode(email: string, code: string): Promise { + const from = this.configService.get('SMTP_FROM', 'noreply@example.com'); + + try { + await this.transporter.sendMail({ + from, + to: email, + subject: '重置密码验证码', + html: ` +
+

重置密码

+

您正在重置密码,验证码为:

+

${code}

+

验证码有效期为 10 分钟,请尽快使用。

+

如果您没有请求重置密码,请忽略此邮件。

+
+ `, + }); + this.logger.log(`重置密码邮件已发送至: ${email}`); + } catch (error) { + this.logger.error(`发送邮件失败: ${email}`, error); + throw error; + } + } +} diff --git a/apps/web/src/app/(auth)/forgot-password/page.tsx b/apps/web/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..dcd58f1 --- /dev/null +++ b/apps/web/src/app/(auth)/forgot-password/page.tsx @@ -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 ( + + + 忘记密码 + + 输入您的邮箱,我们将发送验证码帮助您重置密码 + + + + + + +
+ 想起密码了?{' '} + + 返回登录 + +
+
+
+ ); +} diff --git a/apps/web/src/components/forms/ForgotPasswordForm.tsx b/apps/web/src/components/forms/ForgotPasswordForm.tsx new file mode 100644 index 0000000..d8c439d --- /dev/null +++ b/apps/web/src/components/forms/ForgotPasswordForm.tsx @@ -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(null); + + // Step 1: 发送验证码表单 + const step1Form = useForm({ + resolver: zodResolver(forgotPasswordSchema), + defaultValues: { + email: '', + captchaId: '', + captchaCode: '', + }, + }); + + // Step 2: 重置密码表单 + const step2Form = useForm({ + 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 ( +
+ + ( + + 邮箱 + + + + + + )} + /> + + { + step1Form.setValue('captchaId', id); + step1Form.setValue('captchaCode', code); + }} + disabled={isLoading} + /> + + + + + ); + } + + // Step 2: 重置密码表单 + return ( +
+ + ( + + 邮箱验证码 + + + + + + )} + /> + + ( + + 新密码 + + + + + + )} + /> + + ( + + 确认密码 + + + + + + )} + /> + +
+ + +
+ + + ); +} diff --git a/apps/web/src/services/auth.service.ts b/apps/web/src/services/auth.service.ts index e06adaf..0b28689 100644 --- a/apps/web/src/services/auth.service.ts +++ b/apps/web/src/services/auth.service.ts @@ -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 => { return http.get(API_ENDPOINTS.AUTH.ME); }, + + // 发送重置密码邮件(公开接口,跳过认证) + sendResetPasswordEmail: (data: SendResetPasswordEmailDto): Promise => { + return http.post( + API_ENDPOINTS.AUTH.SEND_RESET_EMAIL, + data, + { skipAuth: true } + ); + }, + + // 重置密码(公开接口,跳过认证) + resetPassword: (data: ResetPasswordDto): Promise => { + return http.post( + API_ENDPOINTS.AUTH.RESET_PASSWORD, + data, + { skipAuth: true } + ); + }, };