feat: worked on email verification

This commit is contained in:
Kakious 2024-08-19 17:54:33 -04:00
parent 9881f79534
commit d1e93a3631
7 changed files with 213 additions and 31 deletions

View file

@ -91,16 +91,31 @@ export class AuthController {
@Get('verify-email') @Get('verify-email')
@UseGuards(LoginGuard) @UseGuards(LoginGuard)
@Render('auth/verify-email')
@ApiExcludeEndpoint() @ApiExcludeEndpoint()
public async getVerifyEmail(@Query('code') code?: string): Promise<any> { public async verifyEmail(@Res() response: Response, @Query('code') code: string): Promise<any> {
if (!code) { if (!code) {
//TODO: Write error page. return response.render('base/error', {
error_header: 'Invalid Verification Code',
error_message:
'The verification code provided is invalid. Please try sending your verification email again.',
button_name: 'Go Back to Login',
button_link: '/auth/login',
});
} }
return { try {
login: 'login', await this.authService.markEmailVerified(code);
}; } catch (e) {
return response.render('base/error', {
error_header: 'Invalid Verification Code',
error_message:
'The verification code provided is invalid. Please try sending your verification email again.',
button_name: 'Go Back to Login',
button_link: '/auth/login',
});
}
response.redirect('/auth/login');
} }
//TODO: Work on interaction view. //TODO: Work on interaction view.

View file

@ -42,8 +42,11 @@ export class AuthService {
}); });
} }
await this.userService.createUser(username, email, password); const user = await this.userService.createUser(username, email, password);
await this.mailService.sendVerificationEmail(email, '11111');
const emailVerificationCode = await this.generateCode(PASSWORD_RESET_CACHE_KEY);
await this.storeEmailVerifyCode(emailVerificationCode, user.id);
await this.mailService.sendVerificationEmail(email, emailVerificationCode);
return { error: false, message: 'User registered' }; return { error: false, message: 'User registered' };
} }
@ -180,7 +183,7 @@ export class AuthService {
await this.cleanupOldEmailVerificationCode(userId); await this.cleanupOldEmailVerificationCode(userId);
await Promise.all([ await Promise.all([
this.redisService.set(code, userId, PASSWORD_RESET_EXPIRATION), this.redisService.set(getEmailVerifyKey(code), userId, PASSWORD_RESET_EXPIRATION),
this.redisService.set(getUserToVerifyKey(userId), code, PASSWORD_RESET_EXPIRATION), this.redisService.set(getUserToVerifyKey(userId), code, PASSWORD_RESET_EXPIRATION),
]); ]);
} }

View file

@ -25,8 +25,8 @@ 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 }) @Column({ name: 'pending_email', type: String, length: MAX_STRING_LENGTH, nullable: true })
pendingEmail: string | null; pendingEmail?: string | null;
@Column({ name: 'email_verified', default: false }) @Column({ name: 'email_verified', default: false })
emailVerified: boolean; emailVerified: boolean;

View file

@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
@ -13,6 +14,7 @@ import { RedisService } from '../../redis/service/redis.service';
import { Span } from 'nestjs-otel'; import { Span } from 'nestjs-otel';
import { import {
DISABLED_USER_ERROR, DISABLED_USER_ERROR,
EMAIL_NOT_VERIFIED_ERROR,
INVALID_CREDENTIALS_ERROR, INVALID_CREDENTIALS_ERROR,
USER_NOT_FOUND_ERROR, USER_NOT_FOUND_ERROR,
userCacheKeyGenerate, userCacheKeyGenerate,
@ -36,7 +38,7 @@ export class UserService {
* @throws NotFoundException * @throws NotFoundException
*/ */
@Span() @Span()
async getUserById(id: number, relations: string[] = []): Promise<User> { async getUserById(id: number | string, 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');
@ -142,9 +144,9 @@ export class UserService {
async createUser(username: string, email: string, password: string): Promise<User> { async createUser(username: string, email: string, password: string): Promise<User> {
const hashedPassword = await hash(password); const hashedPassword = await hash(password);
const user = this.userRepository.create({ email, username, password: hashedPassword }); const userObject = this.userRepository.create({ email, username, password: hashedPassword });
return await this.userRepository.save(user); return await this.userRepository.save(userObject);
} }
/** /**
@ -171,6 +173,10 @@ export class UserService {
throw new UnauthorizedException(INVALID_CREDENTIALS_ERROR); throw new UnauthorizedException(INVALID_CREDENTIALS_ERROR);
} }
if (!user.emailVerified) {
throw new ForbiddenException(EMAIL_NOT_VERIFIED_ERROR);
}
if (user.disabled) { if (user.disabled) {
throw new UnauthorizedException(DISABLED_USER_ERROR); throw new UnauthorizedException(DISABLED_USER_ERROR);
} }

View file

@ -2,6 +2,7 @@ export const USER_NOT_FOUND_ERROR = 'User not found';
export const INVALID_CREDENTIALS_ERROR = export const INVALID_CREDENTIALS_ERROR =
'The email or password you entered is incorrect or the user was not found'; 'The email or password you entered is incorrect or the user was not found';
export const DISABLED_USER_ERROR = 'User is disabled'; export const DISABLED_USER_ERROR = 'User is disabled';
export const EMAIL_NOT_VERIFIED_ERROR = 'Email requires verification';
// Caching Constants for Redis // Caching Constants for Redis

View file

@ -38,6 +38,17 @@
justify-content: center; justify-content: center;
} }
} }
.shake {
animation: shake 0.5s;
animation-iteration-count: 1;
}
@keyframes shake {
0% { transform: translateX(0); }
25% { transform: translateX(-5px); }
50% { transform: translateX(5px); }
75% { transform: translateX(-5px); }
100% { transform: translateX(0); }
}
</style> </style>
</head> </head>
<body class="relative flex items-center justify-start min-h-screen"> <body class="relative flex items-center justify-start min-h-screen">
@ -53,10 +64,11 @@
<div class="relative bg-gray-800 p-8 rounded-lg shadow-lg w-full max-w-md ml-16 login-prompt"> <div class="relative bg-gray-800 p-8 rounded-lg shadow-lg w-full max-w-md ml-16 login-prompt">
<h2 class="text-2xl font-bold mb-2 text-white text-center">Welcome back!</h2> <h2 class="text-2xl font-bold mb-2 text-white text-center">Welcome back!</h2>
<p class="text-gray-400 mb-6 text-center">We're so excited to see you again!</p> <p class="text-gray-400 mb-6 text-center">We're so excited to see you again!</p>
<form action="{{ login_url }}" method="POST" class="space-y-6"> <form id="loginForm" action="{{ login_url }}" method="POST" class="space-y-6">
<div> <div>
<label for="username" class="block text-sm font-medium text-gray-400">Email or Username</label> <label for="username" class="block text-sm font-medium text-gray-400">Email or Username</label>
<input type="text" placeholder="yip@yap.yop" name="username" required class="mt-1 block w-full px-3 py-2 bg-gray-700 text-white border border-gray-600 rounded-md shadow-sm placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> <input type="text" placeholder="yip@yap.yop" name="username" required class="mt-1 block w-full px-3 py-2 bg-gray-700 text-white border border-gray-600 rounded-md shadow-sm placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<p id="username-error" class="text-xs text-red-600 hidden"></p>
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-400">Password</label> <label for="password" class="block text-sm font-medium text-gray-400">Password</label>
@ -74,17 +86,17 @@
<div class="mt-4 text-sm text-center"> <div class="mt-4 text-sm text-center">
<span class="text-gray-400">Need an account? </span><a href="{{ register }}" class="font-medium text-indigo-500 hover:text-indigo-400">Register</a> <span class="text-gray-400">Need an account? </span><a href="{{ register }}" class="font-medium text-indigo-500 hover:text-indigo-400">Register</a>
</div> </div>
<div class="mt-6 flex items-center justify-center text-gray-400 text-xs"> <div class="mt-6 flex items-center justify-center text-gray-400 text-xs">
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 784.69 187.35" width="40%" height="40%"> <svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 784.69 187.35" width="40%" height="40%">
<defs> <defs>
<style> <style>
.cls-1 { .cls-1 {
fill: #fff; fill: #fff;
stroke-width: 0px; stroke-width: 0px;
} }
</style> </style>
</defs> </defs>
<g id="WatchingWaterwolf"> <g id="WatchingWaterwolf">
<g> <g>
<g> <g>
<polygon class="cls-1" points="269.31 78.37 260.14 136.91 248.7 78.37 244.28 78.37 239.92 78.37 235.38 78.37 223.91 137.03 214.7 78.37 198.81 78.37 214.82 165.36 220.98 165.36 225.1 165.36 230.48 165.36 242.08 109.33 253.48 165.36 258.92 165.36 262.92 165.36 269.19 165.36 285.15 78.37 269.31 78.37"/> <polygon class="cls-1" points="269.31 78.37 260.14 136.91 248.7 78.37 244.28 78.37 239.92 78.37 235.38 78.37 223.91 137.03 214.7 78.37 198.81 78.37 214.82 165.36 220.98 165.36 225.1 165.36 230.48 165.36 242.08 109.33 253.48 165.36 258.92 165.36 262.92 165.36 269.19 165.36 285.15 78.37 269.31 78.37"/>
@ -110,9 +122,89 @@
<path id="path0" class="cls-1" d="M80.35,1.12C23.73,10.38-12.01,65.53,3.72,119.36c2.66,9.11,2.57,9.09,4.65.98,1.01-3.96,2.15-7.97,2.51-8.91.46-1.16.46-3.35,0-6.88C1.7,33.58,80.91-15.25,140.59,24.61c62.01,41.42,41.22,138.57-32.4,151.41-9.96,1.74-9.44,1.91-17.14-5.73-7.48-7.42-7.33-7.49-4.35,2.04l1.5,4.82-4.37-.47c-6.64-.71-16.51-3.4-18.16-4.95-4.5-4.23-8.03-17.49-8.49-31.9-.16-5.13-.52-9.35-.8-9.37-3.57-.26-12.18,3.82-18.23,8.63-5.47,4.35-5.51,4.31-3.58-3.27,6.35-24.86,25.51-50.23,46.13-61.08l4.59-2.41-2.5-.47c-1.37-.26-5.19-.32-8.48-.13l-5.99.34,6.26-3.14c7.29-3.66,12.94-5.63,19.12-6.65,2.54-.42,4.42-1.06,4.42-1.5,0-2.75,17.52-24.58,19.72-24.58.32,0,.39,3,.17,6.71-.5,8.33-.65,8.12,9.92,13.97,9.29,5.14,10.94,6.49,10.67,8.77-.32,2.77-2.15,2.36-4.07-.92-1.99-3.39-3.89-4.57-8.77-5.46-4.07-.74-4.29-.51-3.37,3.57,1.14,5.08,4.17,7.32,12.24,9.03,9.23,1.96,24.42,8.39,23.96,10.15-.17.67.2,2,.83,2.95,1.1,1.67,1.09,1.81-.17,3.51-4.4,5.94-10.39,7.17-17.04,3.5-14.31-7.9-23.73-11.17-33.69-11.72-9.43-.51-17,1.63-9.42,2.67,3.57.49,7.31,2.31,9.46,4.63l1.59,1.7h-3.23c-21.98.09-38.75,19.17-33.92,38.6,1.68,6.75,6.31,15.09,12.02,21.63,2.26,2.59,2.38,2.46.96-.94-3.79-9.06-1.92-25.02,3.66-31.38,4.06-4.62,4.44-4.42,4.07,2.11-1.12,19.7,12.31,32.5,37.18,35.43,4.57.54,4.82.24,1.45-1.68-13.6-7.76-23.89-25.44-21.7-37.28,2.68-14.47,11.58-19.55,31.42-17.92l9.68.79,2.88-1.69c6.92-4.05,15.9-14.15,14.15-15.91-.53-.53-6.4-3.31-13.04-6.18l-12.07-5.22-.33-4.1c-.41-5.11-.38-5.07-6.27-9.04-4.45-3-4.87-3.48-4.87-5.5,0-5.65-3.19-17.27-4.52-16.45-.34.21-.82,3.62-1.08,7.58-.48,7.36-1.03,8.5-2.98,6.14-.62-.75-.88-3.38-.9-9.1-.02-8.59-.79-10.81-3.75-10.81-4.6,0-18.11,10.78-26.81,21.39-1.4,1.71-2.77,2.35-7.85,3.71-20.14,5.36-47.11,21.68-54.6,33.04-2.46,3.73-2.09,3.82,3.62.86,11.63-6.03,24.03-9.95,17.65-5.59-18.72,12.81-30.43,34.04-34.32,62.23-.94,6.84,20.19,26.42,37.06,34.32,69.91,32.76,147.48-28.19,131.88-103.62C175.69,26.85,127.77-6.64,80.35,1.12"/> <path id="path0" class="cls-1" d="M80.35,1.12C23.73,10.38-12.01,65.53,3.72,119.36c2.66,9.11,2.57,9.09,4.65.98,1.01-3.96,2.15-7.97,2.51-8.91.46-1.16.46-3.35,0-6.88C1.7,33.58,80.91-15.25,140.59,24.61c62.01,41.42,41.22,138.57-32.4,151.41-9.96,1.74-9.44,1.91-17.14-5.73-7.48-7.42-7.33-7.49-4.35,2.04l1.5,4.82-4.37-.47c-6.64-.71-16.51-3.4-18.16-4.95-4.5-4.23-8.03-17.49-8.49-31.9-.16-5.13-.52-9.35-.8-9.37-3.57-.26-12.18,3.82-18.23,8.63-5.47,4.35-5.51,4.31-3.58-3.27,6.35-24.86,25.51-50.23,46.13-61.08l4.59-2.41-2.5-.47c-1.37-.26-5.19-.32-8.48-.13l-5.99.34,6.26-3.14c7.29-3.66,12.94-5.63,19.12-6.65,2.54-.42,4.42-1.06,4.42-1.5,0-2.75,17.52-24.58,19.72-24.58.32,0,.39,3,.17,6.71-.5,8.33-.65,8.12,9.92,13.97,9.29,5.14,10.94,6.49,10.67,8.77-.32,2.77-2.15,2.36-4.07-.92-1.99-3.39-3.89-4.57-8.77-5.46-4.07-.74-4.29-.51-3.37,3.57,1.14,5.08,4.17,7.32,12.24,9.03,9.23,1.96,24.42,8.39,23.96,10.15-.17.67.2,2,.83,2.95,1.1,1.67,1.09,1.81-.17,3.51-4.4,5.94-10.39,7.17-17.04,3.5-14.31-7.9-23.73-11.17-33.69-11.72-9.43-.51-17,1.63-9.42,2.67,3.57.49,7.31,2.31,9.46,4.63l1.59,1.7h-3.23c-21.98.09-38.75,19.17-33.92,38.6,1.68,6.75,6.31,15.09,12.02,21.63,2.26,2.59,2.38,2.46.96-.94-3.79-9.06-1.92-25.02,3.66-31.38,4.06-4.62,4.44-4.42,4.07,2.11-1.12,19.7,12.31,32.5,37.18,35.43,4.57.54,4.82.24,1.45-1.68-13.6-7.76-23.89-25.44-21.7-37.28,2.68-14.47,11.58-19.55,31.42-17.92l9.68.79,2.88-1.69c6.92-4.05,15.9-14.15,14.15-15.91-.53-.53-6.4-3.31-13.04-6.18l-12.07-5.22-.33-4.1c-.41-5.11-.38-5.07-6.27-9.04-4.45-3-4.87-3.48-4.87-5.5,0-5.65-3.19-17.27-4.52-16.45-.34.21-.82,3.62-1.08,7.58-.48,7.36-1.03,8.5-2.98,6.14-.62-.75-.88-3.38-.9-9.1-.02-8.59-.79-10.81-3.75-10.81-4.6,0-18.11,10.78-26.81,21.39-1.4,1.71-2.77,2.35-7.85,3.71-20.14,5.36-47.11,21.68-54.6,33.04-2.46,3.73-2.09,3.82,3.62.86,11.63-6.03,24.03-9.95,17.65-5.59-18.72,12.81-30.43,34.04-34.32,62.23-.94,6.84,20.19,26.42,37.06,34.32,69.91,32.76,147.48-28.19,131.88-103.62C175.69,26.85,127.77-6.64,80.35,1.12"/>
</g> </g>
</g> </g>
</g> </g>
</svg> </svg>
</div> </div>
</div> </div>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const loginData = {
username: formData.get('username'),
password: formData.get('password'),
};
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(loginData),
});
if (!response.ok) {
const errorMessageDiv = document.getElementById('username-error');
const usernameInput = form.querySelector('input[name="username"]');
const passwordInput = form.querySelector('input[name="password"]');
errorMessageDiv.classList.remove('hidden');
usernameInput.classList.add('border-red-600', 'shake');
passwordInput.classList.add('border-red-600', 'shake');
setTimeout(() => {
usernameInput.classList.remove('shake');
passwordInput.classList.remove('shake');
}, 500);
if (response.status === 401) {
errorMessageDiv.textContent = 'Incorrect Password/Username';
} else if (response.status === 403) {
const errorData = await response.json();
if (errorData.message === 'Account is disabled') {
errorMessageDiv.textContent = 'Account is disabled';
} else if (errorData.message === 'Email requires verification') {
errorMessageDiv.innerHTML = 'Email requires verification. <button id="resend-verification" class="text-indigo-500 hover:text-indigo-400">Resend verification email</button>';
document.getElementById('resend-verification').addEventListener('click', async function() {
try {
const emailResponse = await fetch('/auth/email-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: loginData.username }),
});
if (emailResponse.status === 429) {
errorMessageDiv.textContent = 'Rate limit exceeded. Please wait.';
} else if (emailResponse.ok) {
alert('Verification email resent!');
} else {
errorMessageDiv.textContent = 'Failed to resend verification email. Please try again later.';
}
} catch (error) {
console.error('Error:', error);
errorMessageDiv.textContent = 'An error occurred. Please try again later.';
}
});
}
} else {
errorMessageDiv.textContent = 'An unknown error occurred';
}
} else {
// Handle successful login
alert('Login successful!');
}
} catch (error) {
console.error('Error:', error);
const errorMessageDiv = document.getElementById('username-error');
errorMessageDiv.classList.remove('hidden');
errorMessageDiv.textContent = 'An error occurred. Please try again later.';
}
});
</script>
</body> </body>
</html> </html>

65
views/base/error.hbs Normal file
View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error Page</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
.video-bg {
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
z-index: -1;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
@media (max-width: 640px) {
.video-bg, .overlay {
display: none;
}
.error-prompt {
margin-left: 0;
width: 100%;
max-width: none;
position: static;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
}
}
</style>
</head>
<body class="relative flex items-center justify-start min-h-screen">
{{#if background_image}}
<div class="absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat" style="background-image: url('{{background_image}}')"></div>
{{else}}
<video autoplay muted loop class="video-bg">
<source src="/assets/login.webm" type="video/webm">
Your browser does not support the video tag.
</video>
{{/if}}
<div class="overlay"></div>
<div class="relative bg-gray-800 p-8 rounded-lg shadow-lg w-full max-w-md ml-16 error-prompt">
<h2 class="text-2xl font-bold mb-2 text-white text-center">{{ error_header }}</h2>
<p class="text-gray-400 mb-6 text-center">{{ error_message }}</p>
{{#if button_name}}
<div class="text-center">
<button type="button" class="inline-block px-6 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-500 hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"><a href="{{ button_link }}">{{ button_name }}</a></button>
</div>
</div>
{{/if}}
</div>
</body>
</html>