feat: implement email verify and password logic

This commit is contained in:
Kakious 2024-08-09 13:46:49 -04:00
parent c15d242a3d
commit 9881f79534
6 changed files with 230 additions and 97 deletions

View file

@ -1,9 +1,20 @@
// Redis Password Reset Const // 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 PASSWORD_RESET_EXPIRATION = 60 * 5; // 5 minutes
export const getResetKey = (code: string): string => `${PASSWORD_RESET_CACHE_KEY}${code}`; 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 // Failed Login Attempts Const
export const FAILED_LOGIN_ATTEMPTS_CACHE_KEY = 'failed_login_attempts:'; export const FAILED_LOGIN_ATTEMPTS_CACHE_KEY = 'failed_login_attempts:';
export const FAILED_LOGIN_ATTEMPTS_EXPIRATION = 60 * 60 * 1; // 1 hour export const FAILED_LOGIN_ATTEMPTS_EXPIRATION = 60 * 60 * 1; // 1 hour

View file

@ -39,6 +39,11 @@ export class AuthController {
return await this.authService.forgotPassword(body.email); return await this.authService.forgotPassword(body.email);
} }
@Post('resend-verification')
public async postResendVerification(@Body() body: ForgotPasswordDto): Promise<any> {
return await this.authService.sendVerificationEmail(body.email);
}
// ==== Render pages ==== // // ==== Render pages ==== //
@Get('login') @Get('login')

View file

@ -3,7 +3,13 @@ import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/
import { UserService } from '../../user/service/user.service'; import { UserService } from '../../user/service/user.service';
import { RedisService } from '../../redis/service/redis.service'; import { RedisService } from '../../redis/service/redis.service';
import { MailService } from '../../mail/mail.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'; import { OidcService } from '../oidc/core.service';
@Injectable() @Injectable()
@ -15,44 +21,6 @@ export class AuthService {
private readonly mailService: MailService, 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 * Register a new user
* @param username The username of the user * @param username The username of the user
@ -99,19 +67,53 @@ export class AuthService {
return this.oidcService.createSession(user.id); return this.oidcService.createSession(user.id);
} }
/** // == Password Reset Logic == //
* Reset code generation logic
* @returns Promise<string> A reset code
*/
private async generateResetCode(): Promise<string> {
let code = Math.random().toString(36).substring(2, 15);
// Check if the code already exists /**
if (await this.redisService.get(code)) { * This will send an email to the user with a link to reset their password.
code = await this.generateResetCode(); * @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<number> {
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<boolean> {
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 * @returns void
*/ */
private async storePasswordResetCode(code: string, userId: string | number): Promise<void> { private async storePasswordResetCode(code: string, userId: string | number): Promise<void> {
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<number | null> { private async getPasswordResetCode(code: string): Promise<number | null> {
return this.redisService.get<number>(getResetKey(code)); return this.redisService.get<number>(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<void> {
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<void> {
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<number | null> {
return this.redisService.get<number>(getEmailVerifyKey(code));
}
/**
* Cleanup the old email verification code
* @param userId The user ID
* @returns void
*/
public async cleanupOldEmailVerificationCode(userId: number): Promise<void> {
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<string> A reset code
*/
private async generateCode(prefix = 'ww:auth-code:'): Promise<string> {
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;
}
} }

View file

@ -25,6 +25,9 @@ export class User {
@Column({ length: MAX_STRING_LENGTH, unique: true }) @Column({ length: MAX_STRING_LENGTH, unique: true })
email: string; email: string;
@Column({ name: 'pending_email', length: MAX_STRING_LENGTH, nullable: true })
pendingEmail: string | null;
@Column({ name: 'email_verified', default: false }) @Column({ name: 'email_verified', default: false })
emailVerified: boolean; emailVerified: boolean;

View file

@ -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 { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { hash, verify } from 'argon2'; import { hash, verify } from 'argon2';
@ -9,9 +14,8 @@ import { Span } from 'nestjs-otel';
import { import {
DISABLED_USER_ERROR, DISABLED_USER_ERROR,
INVALID_CREDENTIALS_ERROR, INVALID_CREDENTIALS_ERROR,
passwordResetKeyGenerate,
USER_NOT_FOUND_ERROR, USER_NOT_FOUND_ERROR,
userCacheKey, userCacheKeyGenerate,
} from '../user.constant'; } from '../user.constant';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
@ -32,14 +36,13 @@ export class UserService {
* @throws NotFoundException * @throws NotFoundException
*/ */
@Span() @Span()
async getUserById(id: string, relations: string[] = []): Promise<User> { async getUserById(id: number, relations: string[] = []): Promise<User> {
if (this.clsService.get('authType') === 'session') { if (this.clsService.get('authType') === 'session') {
if (this.clsService.get('user').id === id) { if (this.clsService.get('user').id === id) {
return this.clsService.get('user'); return this.clsService.get('user');
} }
} }
const cacheKey = userCacheKey + id; const cachedUser = await this.redisService.get<User>(userCacheKeyGenerate(id));
const cachedUser = await this.redisService.get<User>(cacheKey);
if (cachedUser && relations.length === 0) { if (cachedUser && relations.length === 0) {
return cachedUser; return cachedUser;
@ -64,7 +67,7 @@ export class UserService {
const user = await queryBuilder.getOne(); const user = await queryBuilder.getOne();
if (user && relations.length === 0) { if (user && relations.length === 0) {
await this.redisService.set(cacheKey, user); await this.redisService.set(userCacheKeyGenerate(id), user);
} }
if (!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 * @param userId The user's ID
* @returns string The generated code * @returns Promise<void>
*/ */
@Span() @Span()
async generatePasswordResetCode(userId: string): Promise<string> { async markEmailVerified(userId: number): Promise<void> {
const code = Math.random().toString(36).slice(-8); await this.userRepository.update(userId, { emailVerified: true });
await this.redisService.set(passwordResetKeyGenerate(code), userId, 60 * 60 * 24);
return code; await this.clearUserCache(userId);
} }
/** /**
* Validates a password reset code * Update a user's password
* @param code The password reset code * @param userId The user's ID
* @returns string The user's ID * @param password The new password
* @returns Promise<void>
*/ */
@Span() @Span()
async validatePasswordResetCode(code: string): Promise<string> { async updatePasswordByUserId(userId: string, password: string): Promise<void> {
const userId = await this.redisService.get(passwordResetKeyGenerate(code)); 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<void>
*/
@Span()
async updatePendingEmail(userId: string, email: string): Promise<void> {
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<void> {
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. * Mark email as verified
* @param userId The user's ID * @param email The email address
* @returns string The generated code * @returns Promise<boolean>
*/ */
@Span() @Span()
async generateEmailVerificationCode(userId: string): Promise<string> { async verifyEmail(email: string): Promise<boolean> {
const code = Math.random().toString(36).slice(-8); const user = await this.userRepository.findOne({ where: { email } });
await this.redisService.set(passwordResetKeyGenerate(code), userId, 60 * 60 * 24);
return code;
}
/** if (!user) {
* Validates a email verification code throw new BadRequestException('No user found with that email');
* @param code The email verification code
* @returns string The user's ID
*/
@Span()
async validateEmailVerificationCode(code: string): Promise<string> {
const userId = await this.redisService.get(passwordResetKeyGenerate(code));
if (!userId) {
throw new UnauthorizedException('Invalid or expired code');
} }
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<void> {
await this.redisService.del(userCacheKeyGenerate(userId));
} }
} }

View file

@ -7,10 +7,5 @@ export const DISABLED_USER_ERROR = 'User is disabled';
export const userCacheTTL = 60 * 60 * 24; // 24 hours export const userCacheTTL = 60 * 60 * 24; // 24 hours
export const userCacheKey = 'ww-auth:user'; 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 userCacheKeyGenerate = (lookup: string | number) => `${userCacheKey}:${lookup}`;
export const emailVerifyKeyGenerate = (lookup: string | number) => `${emailVerifyKey}:${lookup}`;
export const passwordResetKeyGenerate = (lookup: string | number) =>
`${passwordResetKey}:${lookup}`;