diff --git a/backend/package.json b/backend/package.json index 2914a196..d8b29bcb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,12 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "typeorm-ts-node-commonjs", + "typeorm:run-migrations": "npm run typeorm migration:run -- -d ./src/config/typeorm.config.ts", + "typeorm:generate-migration": "npm run typeorm -- migration:generate ./src/migrations/$npm_config_name -d ./src/config/typeorm.config.ts", + "typeorm:create-migration": "npm run typeorm -- migration:create ./src/migrations/$npm_config_name", + "typeorm:revert-migration": "npm run typeorm -- migration:revert -d ./src/config/typeorm.config.ts" }, "dependencies": { "@nestjs/common": "^10.0.0", @@ -33,6 +38,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cloudinary": "^2.7.0", "cookie-parser": "^1.4.7", "nodemailer": "^7.0.6", "passport": "^0.7.0", @@ -51,6 +57,7 @@ "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", + "@types/multer": "^2.0.0", "@types/node": "^20.3.1", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 2c36ad46..d4b5c961 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -25,6 +25,8 @@ import { AuthResponse } from './interfaces/authResponse.interface'; import { LoginUserDto } from 'src/users/dto/loginUser.dto'; import { GetCurrentUser } from './decorators/getCurrentUser.decorator'; import { LocalAuthGuard } from './guards/local.guard'; +import { VerifyEmailDto } from './dto/verifyEmail.dto'; +import { ResendVerifyEmailDto } from './dto/resendVerifyEmail.dto'; @ApiTags('Auth') @Controller('auth') @@ -61,4 +63,34 @@ export class AuthController { ): Promise { return await this.authService.loginUser(user, response); } + + // VERIFY EMAIL + @Public() + @Post('verify-email') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify user email with token' }) + @ApiOkResponse({ description: 'Email verified successfully.' }) + @ApiBadRequestResponse({ description: 'Invalid or expired token.' }) + @ApiBody({ type: VerifyEmailDto }) + async verifyEmail( + @Body() verifyEmailDto: VerifyEmailDto, + ): Promise<{ message: string }> { + return await this.authService.verifyEmail(verifyEmailDto.token); + } + + // RESEND VERIFICATION EMAIL + @Public() + @Post('resend-verify-email') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Resend email verification link' }) + @ApiOkResponse({ description: 'Verification email sent successfully.' }) + @ApiBadRequestResponse({ description: 'User not found or already verified.' }) + @ApiBody({ type: ResendVerifyEmailDto }) + async resendVerifyEmail( + @Body() resendVerifyEmailDto: ResendVerifyEmailDto, + ): Promise<{ message: string }> { + return await this.authService.resendVerificationEmail( + resendVerifyEmailDto.email, + ); + } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index ff5bc0b4..53668b44 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { RefreshToken } from './entities/refreshToken.entity'; +import { User } from '../users/entities/user.entity'; import { AuthController } from './auth.controller'; import { AuthService } from './providers/auth.service'; import { UsersModule } from '../users/users.module'; @@ -16,11 +17,15 @@ import { RefreshTokenRepositoryOperations } from './providers/RefreshTokenCrud.r import { FindOneRefreshTokenProvider } from './providers/findOneRefreshToken.provider'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; +import { VerifyEmailProvider } from './providers/verifyEmail.provider'; +import { ResendVerificationEmailProvider } from './providers/resendVerificationEmail.provider'; +import { EmailModule } from '../email/email.module'; @Module({ imports: [ - TypeOrmModule.forFeature([RefreshToken]), + TypeOrmModule.forFeature([RefreshToken, User]), forwardRef(() => UsersModule), + EmailModule, ConfigModule, JwtModule.registerAsync({ imports: [ConfigModule], @@ -48,6 +53,8 @@ import { JwtModule } from '@nestjs/jwt'; RefreshTokensProvider, RefreshTokenRepositoryOperations, FindOneRefreshTokenProvider, + VerifyEmailProvider, + ResendVerificationEmailProvider, ], exports: [ AuthService, diff --git a/backend/src/auth/dto/resendVerifyEmail.dto.ts b/backend/src/auth/dto/resendVerifyEmail.dto.ts new file mode 100644 index 00000000..1fa82593 --- /dev/null +++ b/backend/src/auth/dto/resendVerifyEmail.dto.ts @@ -0,0 +1,14 @@ +import { IsEmail, IsNotEmpty, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ResendVerifyEmailDto { + @ApiProperty({ + description: 'User email address', + example: 'john.doe@example.com', + maxLength: 50 + }) + @IsNotEmpty() + @IsEmail() + @MaxLength(50) + email: string; +} \ No newline at end of file diff --git a/backend/src/auth/dto/verifyEmail.dto.ts b/backend/src/auth/dto/verifyEmail.dto.ts new file mode 100644 index 00000000..74bca4ef --- /dev/null +++ b/backend/src/auth/dto/verifyEmail.dto.ts @@ -0,0 +1,16 @@ +import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class VerifyEmailDto { + @ApiProperty({ + description: 'Email verification token', + example: 'abc123def456...', + minLength: 10, + maxLength: 255 + }) + @IsNotEmpty() + @IsString() + @MinLength(10) + @MaxLength(255) + token: string; +} \ No newline at end of file diff --git a/backend/src/auth/providers/auth.service.ts b/backend/src/auth/providers/auth.service.ts index 51ad7c28..767a4690 100644 --- a/backend/src/auth/providers/auth.service.ts +++ b/backend/src/auth/providers/auth.service.ts @@ -5,13 +5,16 @@ import { User } from '../../users/entities/user.entity'; import { LoginUserProvider } from './loginUser.provider'; import { AuthResponse } from '../interfaces/authResponse.interface'; import { Response } from 'express'; +import { VerifyEmailProvider } from './verifyEmail.provider'; +import { ResendVerificationEmailProvider } from './resendVerificationEmail.provider'; @Injectable() export class AuthService { constructor( private readonly usersService: UsersService, - private readonly loginUserProvider: LoginUserProvider, + private readonly verifyEmailProvider: VerifyEmailProvider, + private readonly resendVerificationEmailProvider: ResendVerificationEmailProvider, ) {} // CREATE USER @@ -37,4 +40,16 @@ export class AuthService { ): Promise { return await this.loginUserProvider.loginUser(user, response); } + + public async verifyEmail(token: string): Promise<{ message: string }> { + return await this.verifyEmailProvider.verifyEmail(token); + } + + public async resendVerificationEmail( + email: string, + ): Promise<{ message: string }> { + return await this.resendVerificationEmailProvider.resendVerificationEmail( + email, + ); + } } diff --git a/backend/src/auth/providers/resendVerificationEmail.provider.ts b/backend/src/auth/providers/resendVerificationEmail.provider.ts new file mode 100644 index 00000000..8858bbcd --- /dev/null +++ b/backend/src/auth/providers/resendVerificationEmail.provider.ts @@ -0,0 +1,87 @@ +// ManageHub/backend/src/auth/providers/resendVerificationEmail.provider.ts +import { + Injectable, + BadRequestException, + NotFoundException, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { EmailService } from '../../email/providers/email.service'; +import { ErrorCatch } from '../../utils/error'; +import * as crypto from 'crypto'; + +@Injectable() +export class ResendVerificationEmailProvider { + constructor( + @InjectRepository(User) + private readonly usersRepository: Repository, + private readonly emailService: EmailService, + ) {} + + public async resendVerificationEmail( + email: string, + ): Promise<{ message: string }> { + try { + // Find user by email + const user = await this.usersRepository.findOne({ + where: { email }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Check if user is already verified + if (user.isVerified) { + throw new BadRequestException('Email is already verified'); + } + + // Check rate limiting (5 minutes cooldown) + if (user.lastVerificationEmailSent) { + const timeSinceLastEmail = + new Date().getTime() - user.lastVerificationEmailSent.getTime(); + const cooldownPeriod = 5 * 60 * 1000; // 5 minutes in milliseconds + + if (timeSinceLastEmail < cooldownPeriod) { + const remainingTime = Math.ceil( + (cooldownPeriod - timeSinceLastEmail) / 1000 / 60, + ); + throw new HttpException( + `Please wait ${remainingTime} minute(s) before requesting another verification email`, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + + // Generate new verification token + const verificationToken = crypto.randomBytes(32).toString('hex'); + const verificationTokenExpiry = new Date(); + verificationTokenExpiry.setHours(verificationTokenExpiry.getHours() + 24); // 24 hours expiry + + // Update user with new token and timestamp + await this.usersRepository.update(user.id, { + verificationToken, + verificationTokenExpiry, + lastVerificationEmailSent: new Date(), + }); + + // Send verification email + const emailSent = await this.emailService.sendVerificationEmail( + user.email, + verificationToken, + `${user.firstname} ${user.lastname}`, + ); + + if (!emailSent) { + throw new BadRequestException('Failed to send verification email'); + } + + return { message: 'Verification email sent successfully' }; + } catch (error) { + ErrorCatch(error, 'Failed to resend verification email'); + } + } +} diff --git a/backend/src/auth/providers/verifyEmail.provider.ts b/backend/src/auth/providers/verifyEmail.provider.ts new file mode 100644 index 00000000..a99376c0 --- /dev/null +++ b/backend/src/auth/providers/verifyEmail.provider.ts @@ -0,0 +1,43 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { ErrorCatch } from '../../utils/error'; + +@Injectable() +export class VerifyEmailProvider { + constructor( + @InjectRepository(User) + private readonly usersRepository: Repository, + ) {} + + public async verifyEmail(token: string): Promise<{ message: string }> { + try { + const user = await this.usersRepository.findOne({ + where: { verificationToken: token }, + }); + + if (!user) { + throw new BadRequestException('Invalid verification token'); + } + + if (user.verificationTokenExpiry && new Date() > user.verificationTokenExpiry) { + throw new BadRequestException('Verification token has expired'); + } + + if (user.isVerified) { + throw new BadRequestException('Email is already verified'); + } + + await this.usersRepository.update(user.id, { + isVerified: true, + verificationToken: null, + verificationTokenExpiry: null, + }); + + return { message: 'Email verified successfully' }; + } catch (error) { + ErrorCatch(error, 'Failed to verify email'); + } + } +} \ No newline at end of file diff --git a/backend/src/config/typeorm.config.ts b/backend/src/config/typeorm.config.ts new file mode 100644 index 00000000..8686c7ff --- /dev/null +++ b/backend/src/config/typeorm.config.ts @@ -0,0 +1,25 @@ +import { DataSource } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { config } from 'dotenv'; +import { User } from '../users/entities/user.entity'; +import { RefreshToken } from '../auth/entities/refreshToken.entity'; + +config(); + +const configService = new ConfigService(); + +export default new DataSource({ + type: 'postgres', + host: configService.get('DATABASE_HOST'), + port: +configService.get('DATABASE_PORT'), + username: configService.get('DATABASE_USERNAME'), + password: configService.get('DATABASE_PASSWORD'), + database: configService.get('DATABASE_NAME'), + ssl: + configService.get('NODE_ENV') === 'production' + ? { rejectUnauthorized: false } + : false, + entities: [User, RefreshToken], + migrations: ['src/migrations/*.ts'], + synchronize: false, // Always false for migrations +}); \ No newline at end of file diff --git a/backend/src/email/providers/email.service.ts b/backend/src/email/providers/email.service.ts index f729c4cf..d867f079 100644 --- a/backend/src/email/providers/email.service.ts +++ b/backend/src/email/providers/email.service.ts @@ -108,4 +108,60 @@ export class EmailService { return false; } } + async sendVerificationEmail(userEmail: string, verificationToken: string, userName: string): Promise { + const template = this.getVerificationEmailTemplate(verificationToken, userName); + + return await this.sendEmail({ + to: userEmail, + subject: template.subject, + text: template.text, + html: template.html, + }); + } + + private getVerificationEmailTemplate(verificationToken: string, userName: string): EmailTemplate { + const platformName = 'ManageHub'; + const verificationUrl = `${this.configService.get('FRONTEND_URL')}/auth/verify-email?token=${verificationToken}`; + + return { + subject: `Verify your email - ${platformName}`, + text: `Dear ${userName},\n\nPlease verify your email address by clicking the following link:\n\n${verificationUrl}\n\nThis link will expire in 24 hours.\n\nIf you didn't create an account with ${platformName}, please ignore this email.\n\nBest regards,\nThe ${platformName} Team`, + html: ` +
+
+

Verify Your Email

+
+

📧 Email Verification Required

+

+ Dear ${userName}, +

+

+ Thank you for registering with ${platformName}! To complete your registration, please verify your email address by clicking the button below. +

+ +

+ Or copy and paste this link into your browser:
+ ${verificationUrl} +

+
+

+ ⏰ Important: This verification link will expire in 24 hours. +

+
+

+ If you didn't create an account with ${platformName}, please ignore this email.

+ Best regards,
+ The ${platformName} Team +

+
+
+
+ `, + }; + } } + diff --git a/backend/src/migrations/1727897400000-AddEmailVerificationFields.ts b/backend/src/migrations/1727897400000-AddEmailVerificationFields.ts new file mode 100644 index 00000000..a55ef44a --- /dev/null +++ b/backend/src/migrations/1727897400000-AddEmailVerificationFields.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEmailVerificationFields1727897400000 + implements MigrationInterface +{ + name = 'AddEmailVerificationFields1727897400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" ADD "isVerified" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "users" ADD "verificationToken" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "users" ADD "verificationTokenExpiry" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `ALTER TABLE "users" ADD "lastVerificationEmailSent" TIMESTAMP WITH TIME ZONE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN "lastVerificationEmailSent"`, + ); + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN "verificationTokenExpiry"`, + ); + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN "verificationToken"`, + ); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "isVerified"`); + } +} diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index 48fd4e4e..a3ad8a41 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -47,6 +47,21 @@ export class User { @Column({ type: 'timestamptz', nullable: true }) passwordResetExpiresIn?: Date; + @Exclude() + @Column({ nullable: true }) + verificationToken?: string; + + @Exclude() + @Column({ type: 'timestamptz', nullable: true }) + verificationTokenExpiry?: Date; + + @Exclude() + @Column({ type: 'timestamptz', nullable: true }) + lastVerificationEmailSent?: Date; + + @Column({ default: false }) + isVerified: boolean; + @Column({ default: true }) isActive: boolean; diff --git a/backend/src/users/providers/createUser.provider.ts b/backend/src/users/providers/createUser.provider.ts index ba8f630e..e94ea27a 100644 --- a/backend/src/users/providers/createUser.provider.ts +++ b/backend/src/users/providers/createUser.provider.ts @@ -12,6 +12,7 @@ import { GenerateTokensProvider } from 'src/auth/providers/generateTokens.provid import { RefreshTokenRepositoryOperations } from 'src/auth/providers/RefreshTokenCrud.repository'; import { UserRole } from '../enums/userRoles.enum'; import { EmailService } from '../../email/providers/email.service'; +import * as crypto from 'crypto'; @Injectable() export class CreateUserProvider { constructor( @@ -43,7 +44,9 @@ export class CreateUserProvider { } // Hash the password - const hashedPassword = await this.hashingProvider.hash(createUserDto.password); + const hashedPassword = await this.hashingProvider.hash( + createUserDto.password, + ); createUserDto.password = hashedPassword; // Set default role if not provided @@ -51,15 +54,28 @@ export class CreateUserProvider { createUserDto.role = UserRole.USER; } + // Generate verification token + const verificationToken = crypto.randomBytes(32).toString('hex'); + const verificationTokenExpiry = new Date(); + verificationTokenExpiry.setHours(verificationTokenExpiry.getHours() + 24); // 24 hours expiry + // Create and save the user (or admin) - let user = this.userRepository.create(createUserDto); + let user = this.userRepository.create({ + ...createUserDto, + isVerified: false, + verificationToken, + verificationTokenExpiry, + }); user = await this.userRepository.save(user); // Generate tokens const { accessToken, refreshToken } = await this.generateTokensProvider.generateBothTokens(user); - await this.refreshTokenRepositoryOperations.saveRefreshToken(user, refreshToken); + await this.refreshTokenRepositoryOperations.saveRefreshToken( + user, + refreshToken, + ); const jwtExpirationMs = parseInt( this.configService.get('JWT_REFRESH_EXPIRATION') || '604800000', @@ -74,22 +90,28 @@ export class CreateUserProvider { sameSite: 'none', }); - // Send registration confirmation email + // Send verification email try { - const emailSent = await this.emailService.sendRegistrationConfirmation( + const emailSent = await this.emailService.sendVerificationEmail( user.email, - `${user.firstname} ${user.lastname}` + verificationToken, + `${user.firstname} ${user.lastname}`, ); - + if (!emailSent) { // Log the error but don't fail the registration - console.warn(`Failed to send registration confirmation email to ${user.email}. User registration was successful.`); + console.warn( + `Failed to send verification email to ${user.email}. User registration was successful.`, + ); } else { - console.log(`Registration confirmation email sent successfully to ${user.email}`); + console.log(`Verification email sent successfully to ${user.email}`); } } catch (emailError) { // Log the error but don't fail the registration - console.error(`Error sending registration confirmation email to ${user.email}:`, emailError.message); + console.error( + `Error sending verification email to ${user.email}:`, + emailError.message, + ); console.log('User registration was successful despite email failure.'); }