diff --git a/backend/src/admin/entities/admin.entity.ts b/backend/src/admin/entities/admin.entity.ts index 4af00a6..b9a7431 100644 --- a/backend/src/admin/entities/admin.entity.ts +++ b/backend/src/admin/entities/admin.entity.ts @@ -35,6 +35,9 @@ export class Admin { @Column({ default: true }) isActive: boolean; + @Column({ default: false }) + emailVerified: boolean; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index b741159..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' }; } + + @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 5cfb5ff..ac73022 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,13 +1,23 @@ 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])], - providers: [AuthService], + imports: [ + TypeOrmModule.forFeature([ + RefreshToken, + EmailVerification, + PasswordReset, + Admin, + ]), + ], + providers: [AuthService, EmailService], controllers: [AuthController], exports: [AuthService], }) diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index d94f43d..1ec5fa3 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(); @Injectable() @@ -14,8 +18,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( @@ -108,5 +117,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); } } + 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