From fccb3a402036587c00962f8929ef53bb4df2627f Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Fri, 30 May 2025 10:23:31 +0100 Subject: [PATCH] feat(auth): implement secure email verification and password reset flows --- backend/src/admin/entities/admin.entity.ts | 15 ++- backend/src/auth/auth.controller.ts | 34 +++++- backend/src/auth/auth.module.ts | 16 ++- backend/src/auth/auth.service.ts | 113 +++++++++++++++++- .../src/auth/dto/email-verification.dto.ts | 13 ++ backend/src/auth/dto/password-reset.dto.ts | 18 +++ .../entities/email-verification.entity.ts | 32 +++++ .../auth/entities/password-reset.entity.ts | 26 ++++ backend/src/auth/services/email.service.ts | 52 ++++++++ 9 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 backend/src/auth/dto/email-verification.dto.ts create mode 100644 backend/src/auth/dto/password-reset.dto.ts create mode 100644 backend/src/auth/entities/email-verification.entity.ts create mode 100644 backend/src/auth/entities/password-reset.entity.ts create mode 100644 backend/src/auth/services/email.service.ts diff --git a/backend/src/admin/entities/admin.entity.ts b/backend/src/admin/entities/admin.entity.ts index 3abec04..b9a7431 100644 --- a/backend/src/admin/entities/admin.entity.ts +++ b/backend/src/admin/entities/admin.entity.ts @@ -1,5 +1,11 @@ import { UserRole } from '../../roles/roles.enum'; -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; // import { UserRole } from '../roles/roles.enum'; @Entity('admins') @@ -22,16 +28,19 @@ export class Admin { @Column({ type: 'enum', enum: UserRole, - default: UserRole.ADMIN + default: UserRole.ADMIN, }) role: UserRole; @Column({ default: true }) isActive: boolean; + @Column({ default: false }) + emailVerified: boolean; + @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; -} \ No newline at end of file +} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index fc80b0b..dcf2653 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,5 +1,10 @@ import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common'; import { AuthService } from './auth.service'; +import { + VerifyEmailDto, + RequestEmailVerificationDto, +} from './dto/email-verification.dto'; +import { ForgotPasswordDto, ResetPasswordDto } from './dto/password-reset.dto'; @Controller('auth') export class AuthController { @@ -26,4 +31,31 @@ export class AuthController { await this.authService.logout(refreshToken); return { message: 'Logged out' }; } -} \ No newline at end of file + + @Post('request-verification') + async requestEmailVerification(@Body() dto: RequestEmailVerificationDto) { + await this.authService.requestEmailVerification(dto.email); + return { message: 'Verification email sent' }; + } + + @Post('verify-email') + async verifyEmail(@Body() dto: VerifyEmailDto) { + await this.authService.verifyEmail(dto.token); + return { message: 'Email verified successfully' }; + } + + @Post('forgot-password') + async forgotPassword(@Body() dto: ForgotPasswordDto) { + await this.authService.forgotPassword(dto.email); + return { + message: + 'If an account exists with this email, a password reset link has been sent', + }; + } + + @Post('reset-password') + async resetPassword(@Body() dto: ResetPasswordDto) { + await this.authService.resetPassword(dto.token, dto.newPassword); + return { message: 'Password reset successfully' }; + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 0120171..8a671c6 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,16 +1,24 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { RefreshToken } from './entities/refresh-token.entity'; -import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { EmailVerification } from './entities/email-verification.entity'; +import { PasswordReset } from './entities/password-reset.entity'; import { Admin } from '../admin/entities/admin.entity'; +import { EmailService } from './services/email.service'; @Module({ imports: [ - TypeOrmModule.forFeature([RefreshToken, Admin]), + TypeOrmModule.forFeature([ + RefreshToken, + EmailVerification, + PasswordReset, + Admin, + ]), ], - providers: [AuthService], controllers: [AuthController], + providers: [AuthService, EmailService], exports: [AuthService], }) export class AuthModule {} \ No newline at end of file diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 26ce6a8..7d6b600 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,12 +1,16 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { RefreshToken } from './entities/refresh-token.entity'; +import { EmailVerification } from './entities/email-verification.entity'; +import { PasswordReset } from './entities/password-reset.entity'; import { Admin } from '../admin/entities/admin.entity'; import * as jwt from 'jsonwebtoken'; import * as bcrypt from 'bcrypt'; import { UserRole } from '../roles/roles.enum'; import * as dotenv from 'dotenv'; +import { EmailService } from './services/email.service'; +import { randomBytes } from 'crypto'; dotenv.config(); @@ -15,8 +19,13 @@ export class AuthService { constructor( @InjectRepository(RefreshToken) private readonly refreshTokenRepo: Repository, + @InjectRepository(EmailVerification) + private readonly emailVerificationRepo: Repository, + @InjectRepository(PasswordReset) + private readonly passwordResetRepo: Repository, @InjectRepository(Admin) private readonly adminRepo: Repository, + private readonly emailService: EmailService, ) {} async validateUser(email: string, password: string, role: string): Promise { @@ -96,4 +105,106 @@ export class AuthService { default: return 7 * 24 * 60 * 60 * 1000; } } + + async requestEmailVerification(email: string): Promise { + const user = await this.adminRepo.findOne({ where: { email } }); + if (!user) { + throw new BadRequestException('User not found'); + } + + // Invalidate any existing verification tokens + await this.emailVerificationRepo.update( + { email, verified: false }, + { verified: true } + ); + + const token = randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + + const verification = this.emailVerificationRepo.create({ + token, + email, + expiresAt, + user, + }); + + await this.emailVerificationRepo.save(verification); + await this.emailService.sendVerificationEmail(email, token); + } + + async verifyEmail(token: string): Promise { + const verification = await this.emailVerificationRepo.findOne({ + where: { token, verified: false }, + relations: ['user'], + }); + + if (!verification) { + throw new BadRequestException('Invalid verification token'); + } + + if (verification.expiresAt < new Date()) { + throw new BadRequestException('Verification token has expired'); + } + + verification.verified = true; + await this.emailVerificationRepo.save(verification); + + if (verification.user) { + verification.user.emailVerified = true; + await this.adminRepo.save(verification.user); + } + } + + async forgotPassword(email: string): Promise { + const user = await this.adminRepo.findOne({ where: { email } }); + if (!user) { + // Don't reveal that the user doesn't exist + return; + } + + // Invalidate any existing reset tokens + await this.passwordResetRepo.update( + { email, used: false }, + { used: true } + ); + + const token = randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + const reset = this.passwordResetRepo.create({ + token, + email, + expiresAt, + user, + }); + + await this.passwordResetRepo.save(reset); + await this.emailService.sendPasswordResetEmail(email, token); + } + + async resetPassword(token: string, newPassword: string): Promise { + const reset = await this.passwordResetRepo.findOne({ + where: { token, used: false }, + relations: ['user'], + }); + + if (!reset) { + throw new BadRequestException('Invalid reset token'); + } + + if (reset.expiresAt < new Date()) { + throw new BadRequestException('Reset token has expired'); + } + + if (!reset.user) { + throw new BadRequestException('User not found'); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + reset.user.password = hashedPassword; + reset.used = true; + + await this.adminRepo.save(reset.user); + await this.passwordResetRepo.save(reset); + } } \ No newline at end of file diff --git a/backend/src/auth/dto/email-verification.dto.ts b/backend/src/auth/dto/email-verification.dto.ts new file mode 100644 index 0000000..c1ebbdf --- /dev/null +++ b/backend/src/auth/dto/email-verification.dto.ts @@ -0,0 +1,13 @@ +import { IsEmail, IsString, IsNotEmpty } from 'class-validator'; + +export class VerifyEmailDto { + @IsString() + @IsNotEmpty() + token: string; +} + +export class RequestEmailVerificationDto { + @IsEmail() + @IsNotEmpty() + email: string; +} \ No newline at end of file diff --git a/backend/src/auth/dto/password-reset.dto.ts b/backend/src/auth/dto/password-reset.dto.ts new file mode 100644 index 0000000..0549f5e --- /dev/null +++ b/backend/src/auth/dto/password-reset.dto.ts @@ -0,0 +1,18 @@ +import { IsEmail, IsString, IsNotEmpty, MinLength } from 'class-validator'; + +export class ForgotPasswordDto { + @IsEmail() + @IsNotEmpty() + email: string; +} + +export class ResetPasswordDto { + @IsString() + @IsNotEmpty() + token: string; + + @IsString() + @IsNotEmpty() + @MinLength(8) + newPassword: string; +} \ No newline at end of file diff --git a/backend/src/auth/entities/email-verification.entity.ts b/backend/src/auth/entities/email-verification.entity.ts new file mode 100644 index 0000000..b4ddc4d --- /dev/null +++ b/backend/src/auth/entities/email-verification.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + CreateDateColumn, +} from 'typeorm'; +import { Admin } from '../../admin/entities/admin.entity'; + +@Entity() +export class EmailVerification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + token: string; + + @Column() + email: string; + + @Column({ default: false }) + verified: boolean; + + @CreateDateColumn() + createdAt: Date; + + @Column() + expiresAt: Date; + + @ManyToOne(() => Admin, { nullable: true }) + user: Admin; +} diff --git a/backend/src/auth/entities/password-reset.entity.ts b/backend/src/auth/entities/password-reset.entity.ts new file mode 100644 index 0000000..a81c33c --- /dev/null +++ b/backend/src/auth/entities/password-reset.entity.ts @@ -0,0 +1,26 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn } from 'typeorm'; +import { Admin } from '../../admin/entities/admin.entity'; + +@Entity() +export class PasswordReset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + token: string; + + @Column() + email: string; + + @Column({ default: false }) + used: boolean; + + @CreateDateColumn() + createdAt: Date; + + @Column() + expiresAt: Date; + + @ManyToOne(() => Admin) + user: Admin; +} \ No newline at end of file diff --git a/backend/src/auth/services/email.service.ts b/backend/src/auth/services/email.service.ts new file mode 100644 index 0000000..9f1fd74 --- /dev/null +++ b/backend/src/auth/services/email.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; + +@Injectable() +export class EmailService { + private transporter: nodemailer.Transporter; + + constructor() { + this.transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || '587'), + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + } + + async sendVerificationEmail(email: string, token: string): Promise { + const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${token}`; + + await this.transporter.sendMail({ + from: process.env.SMTP_FROM, + to: email, + subject: 'Verify your email address', + html: ` +

Email Verification

+

Please click the link below to verify your email address:

+ ${verificationUrl} +

This link will expire in 24 hours.

+ `, + }); + } + + async sendPasswordResetEmail(email: string, token: string): Promise { + const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`; + + await this.transporter.sendMail({ + from: process.env.SMTP_FROM, + to: email, + subject: 'Reset your password', + html: ` +

Password Reset

+

Please click the link below to reset your password:

+ ${resetUrl} +

This link will expire in 1 hour.

+

If you didn't request this, please ignore this email.

+ `, + }); + } +} \ No newline at end of file