Chapter 3: User Authentication.
We keep our book up to date with recent libraries and packages.
The section below is a preview of Browser Extension Book. To read the full text, you will need to purchase the book.
In Chapter 3, you will start with the codebase in the 3-begin folder of our browser extension repo and end up with the codebase in the 3-end folder.
In this chapter, we'll build on our existing Express server and Extension by adding user authentication. This includes setting up models for user data and email templates, integrating AWS Simple Email Service (SES) for email verification, using MongoDB for our database, and utilities to facilitate certain operations. We'll cover the creation of new files and the installation of necessary packages.
We will cover the following topics in this chapter:
- Server
- Installing new dependencies
- Creating and understanding new files
- Environment configuration
- Extension
- Creating and understanding new files
- Modifying files and understanding the changes
- Visual exploration of the user registration process
Server side changes link
Installing dependencies
Since we are going to handle user data, encryption, and communication over the network securely, you need to add several npm packages to your project. Run the following command in your project root to install them:
yarn add aws-sdk bcryptjs cors dotenv jsonwebtoken lodash mongoose sha256
aws-sdk
: For sending emails using AWS SES.bcryptjs
: For hashing and comparing hashed passwords.cors
: To enable CORS (Cross-Origin Resource Sharing) support.dotenv
: To manage environment variables.jsonwebtoken
: To issue JSON Web Tokens for authentication.lodash
: Utility library which can simplify various tasks.mongoose
: MongoDB object modeling tool.sha256
: To hash data securely.
Creating new files
Ensure your project structure includes the following new files in the src
directory:
src/models/users.ts
src/models/email-templates.ts
src/aws-ses.ts
src/mongoose.ts
src/utils.ts
src/server.ts
src/.env
Users model (
src/models/users.ts
)This file defines the user schema and model with Mongoose. It includes email, password, and utility methods for password hashing and checking:
import * as jwt from 'jsonwebtoken'; import * as crypto from 'crypto'; import * as bcrypt from 'bcryptjs'; import sha256 from 'sha256'; import mongoose from 'mongoose'; import getEmailTemplate from './email-templates'; import sendEmail from '../aws-ses'; const isValidEmail = (email) => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } const sendAuthEmail = async (template: string, email: string, token: string) => { const emailTemplate = await getEmailTemplate(template, { token }); try { await sendEmail({ from: `AI-recruiter <${process.env.EMAIL_SUPPORT_FROM_ADDRESS}>`, to: [email], subject: emailTemplate.subject, body: emailTemplate.message, }); } catch (err) { console.log('Email sending error:', err); } } export interface IUserDocument extends mongoose.Document { createdAt: Date; email: string, password?: string, registrationToken?: string, registrationTokenExpires?: Date; isVerified: boolean, } interface IUserModel extends mongoose.Model<IUserDocument> { publicFields(): string[]; generateToken(): { token: string; expires: Date }; register(email: string): Promise<IUserDocument[]>; checkPassword(password: string): void; generateToken(): { token: string; expires: Date }; confirmToken(token: string, type?: string): Promise<string>; setPassword(args: { password: string; passwordConfirmation: string; token: string; }): Promise<string>; createJWTToken(_user: IUserDocument, secret: string): Promise<string> comparePassword(password: string, userPassword: string): Promise<string> login(args: { email: string; password: string; }): Promise<string> } class UserClass extends mongoose.Model { public static publicFields(): string[] { return [ '_id', 'email', ]; } public static async generateToken() { const buffer = await crypto.randomBytes(20); const token = buffer.toString('hex'); return { token, expires: Date.now() + 86400000 }; } public static async register(email: string) { if (!isValidEmail(email)) { throw new Error('Invalid email'); } const prev = await this.find({ email }).countDocuments(); if (prev > 0) { throw new Error('Prompt already exists'); } const { token, expires } = await User.generateToken(); await sendAuthEmail('registration', email, token); return this.create({ createdAt: new Date(), email, isVerified: false, registrationToken: token, registrationTokenExpires: expires, }); } public static checkPassword(password: string) { if (!password.match(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/)) { throw new Error( 'Must contain at least one number and one uppercase and lowercase letter, and at least 8 or more characters' ); } } public static generatePassword(password: string) { const hashPassword = sha256(password); return bcrypt.hash(hashPassword, 10); } public static async confirmToken(token: string, type='registration') { const user = await this.findOne({ [type === 'registration' ? 'registrationToken' : 'resetPasswordToken']: token, [type === 'registration' ? 'registrationTokenExpires' : 'resetPasswordExpires']: { $gt: Date.now() } }); if (!user || !token) { throw new Error('Token is invalid or has expired'); } return user._id; } public static async setPassword({ token, password, passwordConfirmation, }: { password: string; passwordConfirmation: string; token: string; }) { const userId = await this.confirmToken(token); if (password === '') { throw new Error('Password can not be empty'); } if (password !== passwordConfirmation) { throw new Error('Password does not match'); } this.checkPassword(password); await this.updateOne( { _id: userId }, { $set: { password: await this.generatePassword(password), isVerified: true, registrationToken: null, registrationTokenExpires: null } } ); return userId; } public static async createJWTToken(_user: IUserDocument, secret: string) { const user = { _id: _user._id, email: _user.email }; return await jwt.sign({ user }, secret, { expiresIn: '90d' }); } public static comparePassword(password: string, userPassword: string) { const hashPassword = sha256(password); return bcrypt.compare(hashPassword, userPassword); } public static async login({ email, password, }: { email: string; password: string; }) { email = (email || '').toLowerCase().trim(); password = (password || '').trim(); const user = await this.findOne({ email }); if (!user || !user.password) { throw new Error('Invalid login'); } const valid = await this.comparePassword(password, user.password); if (!valid) { throw new Error('Invalid login'); } return await this.createJWTToken( user, process.env.JWT_TOKEN_SECRET || '' ); } } const UserSchemaDefs = { createdAt: { type: Date }, email: { type: String }, password: { type: String }, registrationToken: { type: String }, registrationTokenExpires: { type: Date }, isVerified: { type: Boolean }, }; export const UserSchema = new mongoose.Schema<IUserDocument, IUserModel>(UserSchemaDefs); UserSchema.loadClass(UserClass); const User = mongoose.model<IUserDocument, IUserModel>('users', UserSchema); export default User;
isValidEmail(email: string): boolean
A simple function that checks if the provided email string matches a standard email format using a regular expression.sendAuthEmail(template: string, email: string, token: string): Promise
Asynchronously sends an email using a template, email address, and a token. It handles the construction and sending of the email through AWS SES, catching any errors that occur.UserClass
A class containing static methods to handle various user-related functionalities:- publicFields(): Returns a list of public fields of a user.
- generateToken(): Generates an authentication token.
- register(email: string, sourceOfDiscovery: string): Registers a new user.
- checkPassword(password: string): Validates a password.
- generatePassword(password: string): Generates a hashed password.
- confirmToken(token: string, type?: string): Confirms a token's validity.
- setPassword(args: { password: string; passwordConfirmation: string; token: string; }): Sets or resets a password.
- createJWTToken(_user: IUserDocument, secret: string): Creates a JWT token.
- comparePassword(password: string, userPassword: string): Compares a plaintext password against a hashed password.
- login(args: { email: string; password: string; }): Authenticates a user.
Email Templates Model (
src/models/email-templates.ts
)This file will handle email templates used for communications like password resets:
import * as _ from 'lodash'; import * as mongoose from 'mongoose'; interface EmailTemplateDocument extends mongoose.Document { name: string; subject: string; message: string; } const EmailTemplate = mongoose.model<EmailTemplateDocument>( 'emailTemplates', new mongoose.Schema({ name: { type: String, required: true, unique: true, }, subject: { type: String, required: true, }, message: { type: String, required: true, }, }), ); export async function insertEmailTemplates() { const templates = [ { name: 'registration', subject: 'AI-cruiter registration', message: ` <p> To register your account at AI-cruiter, click the following verification link: <a href="https://workinbiotech.com/ai-cruiter?verification-token=<%= token %>">Click here to verify</a> </p> `, }, ]; for (const t of templates) { const et = await EmailTemplate.findOne({ name: t.name }).setOptions({ lean: true }); const message = t.message.replace(/\n/g, '').replace(/[ ]+/g, ' ').trim(); if (!et) { await EmailTemplate.create(Object.assign({}, t, { message })); } else if (et.subject !== t.subject || et.message !== message) { await EmailTemplate.updateOne({ _id: et._id }, { $set: { message, subject: t.subject } }).exec(); } } } export default async function getEmailTemplate(name: string, params?: any) { await insertEmailTemplates(); const et = await EmailTemplate.findOne({ name }).setOptions({ lean: true }); if (!et) { throw new Error('Email Template is not found in database.'); } return { message: _.template(et.message)(params), subject: _.template(et.subject)(params), }; }
- Function
insertEmailTemplates
: Initializes email templates in the database. It handles creating or updating templates based on their name, subject, and message. - Function
getEmailTemplate
: Retrieves a specific email template by name after ensuring all templates are inserted. It throws an error if the template does not exist.
- Function
AWS SES Configuration (
src/aws-ses.ts
)This file configures AWS Simple Email Service for sending emails:
import * as aws from 'aws-sdk'; export default function sendEmail(options) { const ses = new aws.SES({ apiVersion: 'latest', region: process.env.AWS_REGION_FOR_SES, accessKeyId: process.env.AWS_ACCESSKEYID, secretAccessKey: process.env.AWS_SECRETACCESSKEY, }); return new Promise((resolve, reject) => { ses.sendEmail( { Source: options.from, Destination: { CcAddresses: options.cc, ToAddresses: options.to, }, Message: { Subject: { Data: options.subject, }, Body: { Html: { Data: options.body, }, }, }, ReplyToAddresses: options.replyTo, }, (err, info) => { if (err) { reject(err); } else { resolve(info); } }, ); }); }
- Function
sendEmail
: Configures AWS SES (Simple Email Service) for sending emails. It prepares the email by specifying sender, recipient, subject, and body, then sends it using AWS SES. Handles sending with both Text and HTML content types. - Function
createTransporter
: Sets up a Nodemailer transporter using AWS SES, which includes authentication and configuration settings.
- Function
Mongoose Configuration (
src/mongoose.ts
)Set up mongoose to connect to your MongoDB database:
import mongoose from 'mongoose'; import * as dotenv from 'dotenv'; dotenv.config(); let connection; export const connectToDatabase = async (): Promise<void> => { if (connection?.readyState === 1) { console.log('Already connected to the database'); return; } try { connection = await mongoose.connect(process.env.MONGO_URL, {}); console.log('Successfully connected to the database'); } catch (error) { console.error('Error connecting to the database:', error); } }; export const disconnectFromDatabase = async (): Promise<void> => { if (connection?.readyState !== 1) { console.log('Not connected to the database'); return; } try { await mongoose.disconnect(); console.log('Successfully disconnected from the database'); } catch (error) { console.error('Error disconnecting from the database:', error); } };
- Function
connect
: Establishes a connection to MongoDB using Mongoose with error handling. - Function
disconnect
: Closes the Mongoose connection to MongoDB, primarily for use in scripts or where explicit disconnect is required.
- Function
Utility Functions (
src/utils.ts
)Common utility functions can be added here, such as token generation and password hashing utilities.
import * as dotenv from 'dotenv'; dotenv.config(); export const routeErrorHandling = (fn, callback?: any) => { return async (req, res, next) => { try { await fn(req, res, next); } catch (e) { console.error(e); if (callback) { return callback(res, e, next); } return next(e); } }; }; export const allowedOrigins = (origin, callback) => { const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS || '*'; if (ALLOWED_ORIGINS === '*') { return callback(null, true); } const origins = [ ...ALLOWED_ORIGINS.split(',') ]; if (origins.includes(origin)) { return callback(null, true); } const originEndsWith = (originString, endsArray) => { let value = false; value = endsArray.some(element => { return originString.endsWith(element); }); return value; }; const ends = ["breezy.hr"]; if (!origin || originEndsWith(origin, ends)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }
- Function
routeErrorHandling
: Wrapper for Express route handlers to standardize error handling. - Function
allowedOrigins
: Middleware to handle CORS (Cross-Origin Resource Sharing) by checking if the request origin is allowed, handling specific cases for allowed domains.
- Function
Server Configuration (
src/server.ts
)
It handles database connections, CORS settings, and environment variables. The routes /get-status and /handle-auth provide functionality for various authentication-related actions like login, registration, token confirmation, and password management, using middleware for error handling and user verificationimport * as jwt from 'jsonwebtoken'; import cors from 'cors'; import express from 'express'; import { connectToDatabase } from './mongoose'; import Users from './models/users'; import { routeErrorHandling, allowedOrigins } from "./utils" import * as dotenv from 'dotenv'; dotenv.config(); const port = process.env.PORT || 3000; (async () => { await connectToDatabase(); })(); const app = express(); app.use(express.json()); // Enable CORS for all routes app.use( cors({ credentials: true, origin: allowedOrigins }) ); app.post( '/get-status', routeErrorHandling(async (req, res) => { const loginToken = req.headers['ai-cruiter-auth-token']; let response; try { // verify user token and retrieve stored user information const { user }: any = jwt.verify( loginToken, process.env.JWT_TOKEN_SECRET || '' ); response = { status: 'loggedIn', ...user, }; } catch (e) { response = { status: 'notLoggedIn' }; } return res.json(response); }) ); app.post( '/handle-auth', routeErrorHandling(async (req, res) => { const { action, email, password, passwordConfirmation, token, confirmationType, } = req.body; let data: any; if (action === 'confirm-token') { data = await Users.confirmToken(token, confirmationType); } if (action === 'set-password') { data = await Users.setPassword({ token, password, passwordConfirmation }); } if (action === 'register') { data = await Users.register(email); } if (action === 'login') { data = await Users.login({ email, password }); } return res.json({ data }); }) ); // Error handler app.use((err, _req, res, _next) => { console.error(err); // Log the error for debugging purposes // Handle other types of errors return res.status(500).json({ serverError: err.message }); }); app.get('/', (_, res) => { res.send('ok'); }); app.get('*', (_, res) => { res.sendStatus(403); }); app.listen(port, () => { console.log(`> Ready on ${port}`); });
Middleware: link
CORS (Cross-Origin Resource Sharing) Middleware:
- Purpose: Allows or restricts requests from web pages hosted on origins different from the server's origin, enhancing security and control over who can interact with the server.
- Implementation: It is configured to accept credentials and allows origins that are specified in the
allowedOrigins
array. - Usage:
app.use(cors({ credentials: true, origin: allowedOrigins }))
Express JSON Middleware:
- Purpose: Automatically parses JSON formatted request bodies.
- Usage:
app.use(express.json())
Error Handling Middleware:
- Purpose: Provides a consistent way to handle errors and exceptions that occur within route handlers.
- Implementation: Implemented as
routeErrorHandling
, a function that wraps asynchronous route handlers to catch errors and respond appropriately. - Usage: Used in route definitions, e.g.,
app.post('/get-status', routeErrorHandling(async (req, res) => { ... }))
Routes: link
POST
/get-status
:- Function: Checks the authentication status of a user by verifying a JWT token provided in the request headers.
- Handler: Extracts the token, verifies it using the JWT library, and determines if the user is logged in or not. If the token is valid, it extracts user data from the token and returns it.
- Response: Returns user's authentication status and, if logged in, additional user information.
POST
/handle-auth
:- Function: Handles various authentication-related actions such as confirming tokens, setting passwords, registering new users, logging in.
- Handler: Depending on the action specified in the request body, it calls different methods on the
Users
model to handle the respective authentication task. It supports actions likeconfirm-token
,set-password
,register
, andlogin
. - Response: Returns the result of the authentication action, which could include success messages, error messages, or data related to the user session.
Environment configuration
Copy .env.sample
to .env
and update it with your AWS credentials, database URI, and other environment specific settings.
ALLOWED_ORIGINS=*
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AWS_REGION_FOR_SES=
MONGODB_URI=your_mongodb_uri
JWT_SECRET=your_jwt_secret
EMAIL_SUPPORT_FROM_ADDRESS=
Start the dev server
yarn dev
You've reached the end of the Chapter 3 preview. To continue reading, you will need to purchase the book.
We keep our book up to date with recent libraries and packages.