diff --git a/.env.example b/.env.example index 011abe7e..8f88b660 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,7 @@ SMTP_USER= SMTP_PASSWORD= EMAIL_FROM=noreply@stellaiverse.com EMAIL_VERIFICATION_URL=http://localhost:3000/auth/verify-email +PASSWORD_RESET_URL=http://localhost:3000/auth/password-reset # Oracle Service Configuration # Contract address for on-chain oracle (set to actual deployed contract) ORACLE_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 95b52adc..1ecb56e4 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -34,7 +34,7 @@ import { EnhancedAuthService } from "./enhanced-auth.service"; import { User } from "src/user/entities/user.entity"; import { EmailVerification } from "./entities/email-verification.entity"; import { Wallet } from "./entities/wallet.entity"; -import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity"; +import { RefreshToken, TwoFactorAuth, PasswordResetToken } from "./entities/auth.entity"; @Module({ imports: [ @@ -47,6 +47,7 @@ import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity"; Wallet, RefreshToken, TwoFactorAuth, + PasswordResetToken, ]), ], controllers: [AuthController, EnhancedAuthController], diff --git a/src/auth/dto/auth.dto.ts b/src/auth/dto/auth.dto.ts index b8e18e11..e0f6c637 100644 --- a/src/auth/dto/auth.dto.ts +++ b/src/auth/dto/auth.dto.ts @@ -60,3 +60,20 @@ export class AuthStatusDto { role: string; }; } + +export class PasswordResetRequestDto { + @IsEmail() + @IsNotEmpty() + email: string; +} + +export class PasswordResetConfirmDto { + @IsString() + @IsNotEmpty() + token: string; + + @IsString() + @IsNotEmpty() + @MinLength(8) + newPassword: string; +} diff --git a/src/auth/email.service.ts b/src/auth/email.service.ts index af04026b..c85d8c92 100644 --- a/src/auth/email.service.ts +++ b/src/auth/email.service.ts @@ -213,6 +213,80 @@ export class EmailService { }; } + async sendPasswordResetEmail( + email: string, + token: string, + ): Promise<{ messageId: string; previewUrl?: string }> { + const resetUrl = `${this.configService.get("PASSWORD_RESET_URL")}?token=${token}`; + + const info = await this.transporter.sendMail({ + from: this.configService.get("EMAIL_FROM"), + to: email, + subject: "Reset your password - StellAIverse", + html: ` + + + + + + +
+
+

🔑 Reset Your Password

+
+
+

Hello!

+

You've requested to reset your password for your StellAIverse account.

+

Click the button below to reset your password:

+

+ Reset Password +

+

Or copy and paste this link into your browser:

+
${resetUrl}
+

This link will expire in 15 minutes.

+

If you didn't request this password reset, you can safely ignore this email.

+
+ +
+ + + `, + text: ` + Reset Your Password - StellAIverse + + You've requested to reset your password for your StellAIverse account. + + Click the link below to reset your password: + ${resetUrl} + + This link will expire in 15 minutes. + + If you didn't request this password reset, you can safely ignore this email. + `, + }); + + const previewUrl = nodemailer.getTestMessageUrl(info); + + if (previewUrl) { + this.logger.log(`Password reset email preview URL: ${previewUrl}`); + } + + return { + messageId: info.messageId, + previewUrl: previewUrl || undefined, + }; + } + /** * Generic send mail method for custom emails */ diff --git a/src/auth/enhanced-auth.controller.ts b/src/auth/enhanced-auth.controller.ts index 579b22e3..3bcb48cb 100644 --- a/src/auth/enhanced-auth.controller.ts +++ b/src/auth/enhanced-auth.controller.ts @@ -15,7 +15,12 @@ import { } from "@nestjs/swagger"; import { JwtAuthGuard } from "./jwt.guard"; import { EnhancedAuthService } from "./enhanced-auth.service"; -import { RegisterDto, LoginDto } from "./dto/auth.dto"; +import { + RegisterDto, + LoginDto, + PasswordResetRequestDto, + PasswordResetConfirmDto, +} from "./dto/auth.dto"; import { TwoFactorSetupDto, TwoFactorVerifyDto, @@ -219,4 +224,55 @@ export class EnhancedAuthController { body.password, ); } + + @Public() + @Post("password-reset") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Request password reset", + description: "Send password reset link to user's email", + }) + @ApiResponse({ + status: 200, + description: "Password reset request processed", + }) + async requestPasswordReset(@Body() passwordResetRequestDto: PasswordResetRequestDto) { + return this.enhancedAuthService.requestPasswordReset(passwordResetRequestDto); + } + + @Public() + @Post("password-reset/confirm") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Confirm password reset", + description: "Reset user's password using the reset token", + }) + @ApiResponse({ + status: 200, + description: "Password reset successfully", + }) + @ApiResponse({ + status: 400, + description: "Invalid or expired token", + }) + async confirmPasswordReset(@Body() passwordResetConfirmDto: PasswordResetConfirmDto) { + return this.enhancedAuthService.confirmPasswordReset(passwordResetConfirmDto); + } + + @Post("logout") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Logout", + description: "Revoke the refresh token", + }) + @ApiResponse({ + status: 200, + description: "Logged out successfully", + }) + async logout(@Body() refreshTokenDto: RefreshTokenDto) { + await this.enhancedAuthService.revokeRefreshToken(refreshTokenDto.refreshToken); + return { message: "Logged out successfully" }; + } } diff --git a/src/auth/enhanced-auth.service.ts b/src/auth/enhanced-auth.service.ts index 3263e0a6..2211b67d 100644 --- a/src/auth/enhanced-auth.service.ts +++ b/src/auth/enhanced-auth.service.ts @@ -20,14 +20,18 @@ import { TwoFactorAuth, TwoFactorType, TwoFactorStatus, + PasswordResetToken, } from "./entities/auth.entity"; import { LoginDto, RegisterDto, RefreshTokenDto, TwoFactorVerifyDto, + PasswordResetRequestDto, + PasswordResetConfirmDto, } from "./dto/auth.dto"; import { TwoFactorSetupDto } from "./dto/kyc.dto"; +import { v4 as uuidv4 } from "uuid"; @Injectable() export class EnhancedAuthService { @@ -38,6 +42,8 @@ export class EnhancedAuthService { private readonly refreshTokenRepository: Repository, @InjectRepository(TwoFactorAuth) private readonly twoFactorRepository: Repository, + @InjectRepository(PasswordResetToken) + private readonly passwordResetTokenRepository: Repository, private readonly jwtService: JwtService, @Inject(forwardRef(() => EmailService)) private readonly emailService: EmailService, @@ -430,7 +436,78 @@ export class EnhancedAuthService { ); } + async revokeRefreshToken(refreshToken: string): Promise { + const tokenEntity = await this.refreshTokenRepository.findOne({ + where: { token: refreshToken }, + }); + if (tokenEntity) { + await this.refreshTokenRepository.update(tokenEntity.id, { + revoked: true, + revokedAt: new Date(), + }); + } + } + async validateUser(userId: string): Promise { return this.userRepository.findOne({ where: { id: userId } }); } + + async requestPasswordReset( + passwordResetRequestDto: PasswordResetRequestDto, + ): Promise<{ message: string }> { + const { email } = passwordResetRequestDto; + const user = await this.userRepository.findOne({ where: { email } }); + + if (!user) { + return { message: "If the email exists, a reset link has been sent." }; + } + + const resetToken = this.generateRefreshToken(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes + + const passwordResetToken = this.passwordResetTokenRepository.create({ + userId: user.id, + token: resetToken, + expiresAt, + }); + + await this.passwordResetTokenRepository.save(passwordResetToken); + + await this.emailService.sendPasswordResetEmail(email, resetToken); + + return { message: "If the email exists, a reset link has been sent." }; + } + + async confirmPasswordReset( + passwordResetConfirmDto: PasswordResetConfirmDto, + ): Promise<{ message: string }> { + const { token, newPassword } = passwordResetConfirmDto; + + const resetToken = await this.passwordResetTokenRepository.findOne({ + where: { token }, + relations: ["user"], + }); + + if ( + !resetToken || + resetToken.expiresAt < new Date() || + resetToken.used + ) { + throw new BadRequestException("Invalid or expired reset token"); + } + + const saltRounds = 12; + const hashedPassword = await bcrypt.hash(newPassword, saltRounds); + await this.userRepository.update(resetToken.user.id, { + password: hashedPassword, + }); + + await this.passwordResetTokenRepository.update(resetToken.id, { + used: true, + }); + + await this.revokeAllRefreshTokens(resetToken.user.id); + + return { message: "Password reset successfully" }; + } } diff --git a/src/auth/entities/auth.entity.ts b/src/auth/entities/auth.entity.ts index 31415fe4..6433f141 100644 --- a/src/auth/entities/auth.entity.ts +++ b/src/auth/entities/auth.entity.ts @@ -106,3 +106,29 @@ export class TwoFactorAuth { @UpdateDateColumn() updatedAt: Date; } + +@Entity("password_reset_tokens") +export class PasswordResetToken { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "uuid" }) + @Index() + userId: string; + + @ManyToOne(() => User) + @JoinColumn({ name: "userId" }) + user: User; + + @Column({ unique: true }) + token: string; + + @Column({ type: "timestamp" }) + expiresAt: Date; + + @Column({ default: false }) + used: boolean; + + @CreateDateColumn() + createdAt: Date; +}