From 9881f79534c45dae3779b4b543df3a8126b2db9e Mon Sep 17 00:00:00 2001 From: Kakious Date: Fri, 9 Aug 2024 13:46:49 -0400 Subject: [PATCH] feat: implement email verify and password logic --- src/auth/auth.const.ts | 13 +- src/auth/controllers/auth.controller.ts | 5 + src/auth/services/auth.service.ts | 193 ++++++++++++++++++------ src/database/models/user.model.ts | 3 + src/user/service/user.service.ts | 108 ++++++++----- src/user/user.constant.ts | 5 - 6 files changed, 230 insertions(+), 97 deletions(-) diff --git a/src/auth/auth.const.ts b/src/auth/auth.const.ts index f519959..fb42d18 100644 --- a/src/auth/auth.const.ts +++ b/src/auth/auth.const.ts @@ -1,9 +1,20 @@ // Redis Password Reset Const -export const PASSWORD_RESET_CACHE_KEY = 'password_reset:'; +export const PASSWORD_RESET_CACHE_KEY = 'ww-auth:password_reset:'; export const PASSWORD_RESET_EXPIRATION = 60 * 5; // 5 minutes export const getResetKey = (code: string): string => `${PASSWORD_RESET_CACHE_KEY}${code}`; +// Redis Email Verification Const + +export const EMAIL_VERIFY_CACHE_KEY = 'ww-auth:email-verify:'; +export const emailVerifyTTL = 60 * 60 * 24 * 5; // 5 days + +export const USER_TO_VERIFY_CACHE_KEY = 'ww-auth:user-to-verify:'; + +export const getEmailVerifyKey = (code: string): string => `${EMAIL_VERIFY_CACHE_KEY}${code}`; +export const getUserToVerifyKey = (userId: number): string => + `${USER_TO_VERIFY_CACHE_KEY}${userId}`; + // Failed Login Attempts Const export const FAILED_LOGIN_ATTEMPTS_CACHE_KEY = 'failed_login_attempts:'; export const FAILED_LOGIN_ATTEMPTS_EXPIRATION = 60 * 60 * 1; // 1 hour diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index b7d2ddb..13168d8 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -39,6 +39,11 @@ export class AuthController { return await this.authService.forgotPassword(body.email); } + @Post('resend-verification') + public async postResendVerification(@Body() body: ForgotPasswordDto): Promise { + return await this.authService.sendVerificationEmail(body.email); + } + // ==== Render pages ==== // @Get('login') diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 91ea3e9..f9477d8 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -3,7 +3,13 @@ import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/ import { UserService } from '../../user/service/user.service'; import { RedisService } from '../../redis/service/redis.service'; import { MailService } from '../../mail/mail.service'; -import { getResetKey, PASSWORD_RESET_EXPIRATION } from '../auth.const'; +import { + getEmailVerifyKey, + getResetKey, + getUserToVerifyKey, + PASSWORD_RESET_CACHE_KEY, + PASSWORD_RESET_EXPIRATION, +} from '../auth.const'; import { OidcService } from '../oidc/core.service'; @Injectable() @@ -15,44 +21,6 @@ export class AuthService { private readonly mailService: MailService, ) {} - /** - * Mark a password as forgotten. This will send an email to the user with a link to reset their password. - * @param email The email address of the user - * @returns A message indicating that the password reset email has been sent - */ - public async forgotPassword(email: string): Promise<{ error: boolean; message: string }> { - const user = await this.userService.getUserbyEmail(email); - - if (!user) { - throw new BadRequestException('No user found with that email'); - } else { - const resetCode = await this.generateResetCode(); - await this.mailService.sendPasswordResetEmail(email, resetCode); - await this.storePasswordResetCode(resetCode, user.id); - return { error: false, message: 'Password reset email sent' }; - } - } - - /** - * Reset a user's password - * @param code The password reset code - * @param password The new password - */ - public async resetPassword( - code: string, - password: string, - ): Promise<{ error: boolean; message: string }> { - const userId = await this.getPasswordResetCode(code); - - if (!userId) { - throw new BadRequestException('Invalid reset code'); - } - - await this.userService.updatePassword(userId, password); - await this.redisService.del(getResetKey(code)); - return { error: false, message: 'Password reset successfully sent' }; - } - /** * Register a new user * @param username The username of the user @@ -99,19 +67,53 @@ export class AuthService { return this.oidcService.createSession(user.id); } - /** - * Reset code generation logic - * @returns Promise A reset code - */ - private async generateResetCode(): Promise { - let code = Math.random().toString(36).substring(2, 15); + // == Password Reset Logic == // - // Check if the code already exists - if (await this.redisService.get(code)) { - code = await this.generateResetCode(); + /** + * This will send an email to the user with a link to reset their password. + * @param email The email address of the user + * @returns A message indicating that the password reset email has been sent + */ + public async forgotPassword(email: string): Promise<{ error: boolean; message: string }> { + const user = await this.userService.getUserbyEmail(email); + + if (!user) { + throw new BadRequestException('No user found with that email'); + } else { + const resetCode = await this.generateCode(PASSWORD_RESET_CACHE_KEY); + await this.mailService.sendPasswordResetEmail(email, resetCode); + await this.storePasswordResetCode(resetCode, user.id); + return { error: false, message: 'Password reset email sent' }; + } + } + + /** + * Validate a password reset code + * @param code The password reset code + * @returns The user ID associated with the code + */ + public async validateResetCode(code: string): Promise { + const userId = await this.getPasswordResetCode(code); + + if (!userId) { + throw new BadRequestException('Invalid reset code'); } - return code; + return userId; + } + + /** + * Reset a user's password + * @param code The password reset code + * @param password The new password + */ + public async resetPassword(code: string, password: string): Promise { + const userId = await this.validateResetCode(code); + + await this.userService.updatePassword(userId, password); + await this.redisService.del(getResetKey(code)); + + return true; } /** @@ -121,7 +123,7 @@ export class AuthService { * @returns void */ private async storePasswordResetCode(code: string, userId: string | number): Promise { - await this.redisService.set(getResetKey(code), userId, PASSWORD_RESET_EXPIRATION); + await this.redisService.set(code, userId, PASSWORD_RESET_EXPIRATION); } /** @@ -132,4 +134,95 @@ export class AuthService { private async getPasswordResetCode(code: string): Promise { return this.redisService.get(getResetKey(code)); } + + // == Email Verification Logic == // + + /** + * Request a new email verification code + * @param email The email address of the user + * @returns A message indicating that the verification email has been sent + */ + public async sendVerificationEmail(email: string): Promise<{ error: boolean; message: string }> { + const user = await this.userService.getUserbyEmail(email); + + if (!user) { + throw new BadRequestException('No user found with that email'); + } else { + const verificationCode = await this.generateCode(PASSWORD_RESET_CACHE_KEY); + await this.storeEmailVerifyCode(verificationCode, user.id); + await this.mailService.sendVerificationEmail(email, verificationCode); + return { error: false, message: 'Verification email sent' }; + } + } + + /** + * Mark a user's email as verified + * @param code The email verification code + */ + public async markEmailVerified(code: string): Promise { + const userId = await this.getEmailVerifyCode(code); + + if (!userId) { + throw new BadRequestException('Invalid verification code'); + } + + await this.userService.markEmailVerified(userId); + await this.redisService.del(getEmailVerifyKey(code)); + } + + /** + * Store a email verify code in the Redis store + * @param code The email verify code + * @param userId The user ID associated with the code + * @returns void + */ + private async storeEmailVerifyCode(code: string, userId: number): Promise { + await this.cleanupOldEmailVerificationCode(userId); + + await Promise.all([ + this.redisService.set(code, userId, PASSWORD_RESET_EXPIRATION), + this.redisService.set(getUserToVerifyKey(userId), code, PASSWORD_RESET_EXPIRATION), + ]); + } + + /** + * Get a email verify code from the Redis store + * @param code The email verify code + * @returns The user ID associated with the code + */ + private async getEmailVerifyCode(code: string): Promise { + return this.redisService.get(getEmailVerifyKey(code)); + } + + /** + * Cleanup the old email verification code + * @param userId The user ID + * @returns void + */ + public async cleanupOldEmailVerificationCode(userId: number): Promise { + const code = await this.redisService.get(getUserToVerifyKey(userId)); + + if (code) { + await this.redisService.del(code); + } + + await this.redisService.del(getUserToVerifyKey(userId)); + } + + // == Supporting Logic == + + /** + * Reset code generation logic + * @returns Promise A reset code + */ + private async generateCode(prefix = 'ww:auth-code:'): Promise { + let code = Math.random().toString(36).substring(2, 15); + + // Check if the code already exists + if (await this.redisService.get(prefix + code)) { + code = await this.generateCode(prefix); + } + + return code; + } } diff --git a/src/database/models/user.model.ts b/src/database/models/user.model.ts index 5361cc4..8475716 100644 --- a/src/database/models/user.model.ts +++ b/src/database/models/user.model.ts @@ -25,6 +25,9 @@ export class User { @Column({ length: MAX_STRING_LENGTH, unique: true }) email: string; + @Column({ name: 'pending_email', length: MAX_STRING_LENGTH, nullable: true }) + pendingEmail: string | null; + @Column({ name: 'email_verified', default: false }) emailVerified: boolean; diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index f1556ee..b78008a 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -1,4 +1,9 @@ -import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { hash, verify } from 'argon2'; @@ -9,9 +14,8 @@ import { Span } from 'nestjs-otel'; import { DISABLED_USER_ERROR, INVALID_CREDENTIALS_ERROR, - passwordResetKeyGenerate, USER_NOT_FOUND_ERROR, - userCacheKey, + userCacheKeyGenerate, } from '../user.constant'; import { ClsService } from 'nestjs-cls'; @@ -32,14 +36,13 @@ export class UserService { * @throws NotFoundException */ @Span() - async getUserById(id: string, relations: string[] = []): Promise { + async getUserById(id: number, relations: string[] = []): Promise { if (this.clsService.get('authType') === 'session') { if (this.clsService.get('user').id === id) { return this.clsService.get('user'); } } - const cacheKey = userCacheKey + id; - const cachedUser = await this.redisService.get(cacheKey); + const cachedUser = await this.redisService.get(userCacheKeyGenerate(id)); if (cachedUser && relations.length === 0) { return cachedUser; @@ -64,7 +67,7 @@ export class UserService { const user = await queryBuilder.getOne(); if (user && relations.length === 0) { - await this.redisService.set(cacheKey, user); + await this.redisService.set(userCacheKeyGenerate(id), user); } if (!user) { @@ -176,58 +179,81 @@ export class UserService { } /** - * Generates a validation code and stores it in Redis. + * Mark an email as verified * @param userId The user's ID - * @returns string The generated code + * @returns Promise */ @Span() - async generatePasswordResetCode(userId: string): Promise { - const code = Math.random().toString(36).slice(-8); - await this.redisService.set(passwordResetKeyGenerate(code), userId, 60 * 60 * 24); - return code; + async markEmailVerified(userId: number): Promise { + await this.userRepository.update(userId, { emailVerified: true }); + + await this.clearUserCache(userId); } /** - * Validates a password reset code - * @param code The password reset code - * @returns string The user's ID + * Update a user's password + * @param userId The user's ID + * @param password The new password + * @returns Promise */ @Span() - async validatePasswordResetCode(code: string): Promise { - const userId = await this.redisService.get(passwordResetKeyGenerate(code)); + async updatePasswordByUserId(userId: string, password: string): Promise { + const hashedPassword = await hash(password); + await this.userRepository.update(userId, { password: hashedPassword }); + } - if (!userId) { - throw new UnauthorizedException('Invalid or expired code'); + /** + * Update a user's pending email + * @param userId The user's ID + * @param email The new email + * @returns Promise + */ + @Span() + async updatePendingEmail(userId: string, email: string): Promise { + await this.userRepository.update(userId, { pendingEmail: email }); + } + + /** + * Mark a user's pending email as verified + * @param userId The user's ID + */ + @Span() + async markPendingEmailVerified(userId: number): Promise { + const user = await this.getUserById(userId); + + if (!user.pendingEmail) { + throw new BadRequestException('No pending email to verify'); } - return userId; + await this.userRepository.update(userId, { email: user.pendingEmail, pendingEmail: null }); + + await this.clearUserCache(userId); } /** - * Generates a email verification code and stores it in Redis. - * @param userId The user's ID - * @returns string The generated code + * Mark email as verified + * @param email The email address + * @returns Promise */ @Span() - async generateEmailVerificationCode(userId: string): Promise { - const code = Math.random().toString(36).slice(-8); - await this.redisService.set(passwordResetKeyGenerate(code), userId, 60 * 60 * 24); - return code; - } + async verifyEmail(email: string): Promise { + const user = await this.userRepository.findOne({ where: { email } }); - /** - * Validates a email verification code - * @param code The email verification code - * @returns string The user's ID - */ - @Span() - async validateEmailVerificationCode(code: string): Promise { - const userId = await this.redisService.get(passwordResetKeyGenerate(code)); - - if (!userId) { - throw new UnauthorizedException('Invalid or expired code'); + if (!user) { + throw new BadRequestException('No user found with that email'); } - return userId; + await this.markEmailVerified(user.id); + + return true; + } + + /** + * Clear a cache of a user + * @param userId The user's ID + */ + @Span() + async clearUserCache(userId: number): Promise { + await this.redisService.del(userCacheKeyGenerate(userId)); } } diff --git a/src/user/user.constant.ts b/src/user/user.constant.ts index 2aa0a27..301dfc5 100644 --- a/src/user/user.constant.ts +++ b/src/user/user.constant.ts @@ -7,10 +7,5 @@ export const DISABLED_USER_ERROR = 'User is disabled'; export const userCacheTTL = 60 * 60 * 24; // 24 hours export const userCacheKey = 'ww-auth:user'; -export const emailVerifyKey = 'ww-auth:email-verify:'; -export const passwordResetKey = 'ww-auth:password-reset:'; export const userCacheKeyGenerate = (lookup: string | number) => `${userCacheKey}:${lookup}`; -export const emailVerifyKeyGenerate = (lookup: string | number) => `${emailVerifyKey}:${lookup}`; -export const passwordResetKeyGenerate = (lookup: string | number) => - `${passwordResetKey}:${lookup}`;