diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 0b2fa04..785e2a2 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -62,10 +62,12 @@ export class AuthService { username: generatedUsername, password: passwordHash, }); - return { - message: 'Registration successful.', - email: user.email, - }; + const code = await this.issueEmailVerificationCode(user.id, user.email); + const message = 'Registration successful. Verification code sent to email.'; + const nodeEnv = this.configService.get('nodeEnv', { infer: true }); + return nodeEnv !== 'production' + ? { message, email: user.email, debugCode: code } + : { message, email: user.email }; } async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> { @@ -83,11 +85,12 @@ export class AuthService { email: dto.email, password: passwordHash, }); - - return { - message: 'Registration successful.', - email: user.email, - }; + const code = await this.issueEmailVerificationCode(user.id, user.email); + const message = 'Registration successful. Verification code sent to email.'; + const nodeEnv = this.configService.get('nodeEnv', { infer: true }); + return nodeEnv !== 'production' + ? { message, email: user.email, debugCode: code } + : { message, email: user.email }; } async login(dto: LoginDto): Promise { @@ -114,21 +117,57 @@ export class AuthService { ): Promise<{ message: string; debugCode?: string }> { const normalizedEmail = dto.email.toLowerCase(); const user = await this.usersService.findByEmail(normalizedEmail); - const message = 'Account verification is optional and can be requested later'; + const message = 'If this email exists, a verification code was sent'; if (!user || user.isDisabled) { return { message }; } - if (user.isVerified) { - return { message: 'Account is already verified' }; + if (user.isEmailVerified) { + return { message: 'Email is already verified' }; } - return { message: 'Account is not verified yet. Verification can be requested later.' }; + const code = await this.issueEmailVerificationCode(user.id, normalizedEmail); + const nodeEnv = this.configService.get('nodeEnv', { infer: true }); + return nodeEnv !== 'production' ? { message, debugCode: code } : { message }; } - async verifyEmail(_dto: VerifyEmailDto): Promise<{ message: string }> { - return { - message: 'Account verification is optional and can be requested later', - }; + async verifyEmail(dto: VerifyEmailDto): Promise<{ message: string }> { + const normalizedEmail = dto.email.toLowerCase(); + const user = await this.usersService.findByEmail(normalizedEmail); + if (!user || user.isDisabled) { + throw new UnauthorizedException('Invalid or expired verification code'); + } + if (user.isEmailVerified) { + return { message: 'Email is already verified' }; + } + + const codeRecord = await this.authRepository.findLatestActiveEmailVerificationCode(user.id); + if (!codeRecord) { + throw new UnauthorizedException('Invalid or expired verification code'); + } + + const maxAttempts = this.configService.get('emailVerification.maxAttempts', { infer: true }); + if (codeRecord.attempts >= maxAttempts) { + await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id); + throw new UnauthorizedException('Verification code attempts exceeded'); + } + + const isMatch = await compareHash(dto.code, codeRecord.codeHash); + if (!isMatch) { + await this.authRepository.incrementEmailVerificationAttempts(codeRecord.id); + const attemptsAfter = codeRecord.attempts + 1; + if (attemptsAfter >= maxAttempts) { + await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id); + } + throw new UnauthorizedException('Invalid or expired verification code'); + } + + await Promise.all([ + this.usersService.markEmailVerified(user.id), + this.authRepository.markEmailVerificationCodeUsed(codeRecord.id), + this.authRepository.markAllEmailVerificationCodesUsedByUser(user.id), + ]); + + return { message: 'Email verified successfully' }; } async refresh(dto: RefreshTokenDto): Promise { @@ -210,6 +249,7 @@ export class AuthService { password: passwordHash, avatar: googleUser.avatar ?? '', isVerified: false, + isEmailVerified: true, }); } diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts index 7bcc910..664493a 100644 --- a/src/modules/email/email.service.ts +++ b/src/modules/email/email.service.ts @@ -73,7 +73,13 @@ export class EmailService { private async send(to: string, subject: string, text: string, html: string): Promise { const enabled = this.configService.get('email.enabled', { infer: true }); if (!enabled) { - return; + const nodeEnv = this.configService.get('nodeEnv', { infer: true }); + if (nodeEnv !== 'production') { + this.logger.warn(`Email delivery is disabled. Skipping email to ${to}`); + return; + } + + throw new ServiceUnavailableException('Email delivery is disabled'); } const fromName = this.configService.get('email.fromName', { infer: true }) ?? 'Oudelaa'; diff --git a/src/modules/users/dto/create-user.dto.ts b/src/modules/users/dto/create-user.dto.ts index c0b2952..a7b6c28 100644 --- a/src/modules/users/dto/create-user.dto.ts +++ b/src/modules/users/dto/create-user.dto.ts @@ -93,6 +93,12 @@ export class CreateUserDto { @IsBoolean() isVerified?: boolean; + @ApiProperty({ required: false, default: false }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isEmailVerified?: boolean; + @ApiProperty({ required: false, enum: MusicRole, isArray: true }) @IsOptional() @IsArray() diff --git a/src/modules/users/schemas/user.schema.ts b/src/modules/users/schemas/user.schema.ts index d683edf..c543b78 100644 --- a/src/modules/users/schemas/user.schema.ts +++ b/src/modules/users/schemas/user.schema.ts @@ -96,6 +96,9 @@ export class User { @Prop({ default: false, index: true }) isVerified!: boolean; + @Prop({ default: false }) + isEmailVerified!: boolean; + @Prop({ default: '', trim: true, maxlength: 120 }) shopName!: string; diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 9f7508f..7eb61aa 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -70,6 +70,7 @@ export class UsersService { longitude: dto.longitude, isPrivate: dto.isPrivate ?? false, isVerified: dto.isVerified ?? false, + isEmailVerified: dto.isEmailVerified ?? false, musicRoles: roles, experienceLevel: dto.experienceLevel ?? ExperienceLevel.BEGINNER, musicGenres: dto.musicGenres ?? [], @@ -295,7 +296,7 @@ export class UsersService { } async markEmailVerified(userId: string): Promise { - const updated = await this.usersRepository.updateById(userId, { isVerified: true }); + const updated = await this.usersRepository.updateById(userId, { isEmailVerified: true }); if (!updated) { throw new NotFoundException('User not found'); }