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
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

View file

@ -39,6 +39,11 @@ export class AuthController {
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 ==== //
@Get('login')

View file

@ -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<string> A reset code
*/
private async generateResetCode(): Promise<string> {
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<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
*/
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> {
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 })
email: string;
@Column({ name: 'pending_email', length: MAX_STRING_LENGTH, nullable: true })
pendingEmail: string | null;
@Column({ name: 'email_verified', default: false })
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 { 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<User> {
async getUserById(id: number, relations: string[] = []): Promise<User> {
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<User>(cacheKey);
const cachedUser = await this.redisService.get<User>(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<void>
*/
@Span()
async generatePasswordResetCode(userId: string): Promise<string> {
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<void> {
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<void>
*/
@Span()
async validatePasswordResetCode(code: string): Promise<string> {
const userId = await this.redisService.get(passwordResetKeyGenerate(code));
async updatePasswordByUserId(userId: string, password: string): Promise<void> {
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.
* @param userId The user's ID
* @returns string The generated code
* Mark email as verified
* @param email The email address
* @returns Promise<boolean>
*/
@Span()
async generateEmailVerificationCode(userId: string): Promise<string> {
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<boolean> {
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<string> {
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<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 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}`;