feat: implement email verify and password logic
This commit is contained in:
parent
c15d242a3d
commit
9881f79534
6 changed files with 230 additions and 97 deletions
|
@ -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
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`;
|
|
||||||
|
|
Loading…
Reference in a new issue