Introduction
In this article I will be showing you how to create a Dev.to clone with Nodejs, Express, MongoDB, with JWT authentication. This API will give ability to users to create, read, update and delete blog alongside adding an authentication layer to it.
Pre-requisite
- NODEJS- You Should have node installed.
- MongoDB - You Should have mongoDB installed or have an account if you prefer to use the cloud version.
Packages that will be required
You can install the following using npm
or its equivalent
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-mongo-sanitize": "^2.2.0",
"express-rate-limit": "^6.6.0",
"helmet": "^6.0.0",
"hpp": "^0.2.3",
"jsonwebtoken": "^8.5.1",
"mongoose": "^6.7.0",
"morgan": "^1.10.0",
"morgan-json": "^1.1.0",
"nodemailer": "^6.8.0",
"nodemon": "^2.0.20",
"slugify": "^1.6.5",
"validator": "^13.7.0",
"winston": "^3.8.2",
"xss-clean": "^0.1.1"
}
Step 1 - File Structure
Before we write a line of code, lets create a package.json
file by running npm init -y
in our terminal.
Now we would be using a file structure like the one below, which follows the MVC architecture.
Step 2 - Lets create our environmental variables in our .env
file
NODE_ENV=Developemt
PORT=8000
DATABASE_LOCAL=mongodb://127.0.0.1:27017/blogDB
DATABASE_PROD=mongodb+srv://blak:<password>@cluster0.1nb5crb.mongodb.net/blogdb?retryWrites=true&w=majority
DATABASE_PASSWORD=l3Ax3f4CaoJrKtjj
jwt_secret=mytokensecretblogapp20221026
jwt_expires=1h
jwt_cookie_expires=2
Notice I have two database URL, One for the local DB and the other for the cloud DB, You can pick your preference.
Step 3 - Lets write some code in server.js
to start up our server and help us connect to our Database
const mongoose = require('mongoose');
const app = require('./app');
const dotenv = require('dotenv');
dotenv.config({ path: './config.env' });
//CREATE DB CONNECTION
let DB = process.env.DATABASE_PROD.replace(
'<password>',
process.env.DATABASE_PASSWORD
);
if (process.env.NODE_ENV == 'Development') {
DB = process.env.DATABASE_LOCAL;
}
mongoose
.connect(DB, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then((con) => {
console.log('DB Connection Successful');
});
//Connect To Server
const port = process.env.PORT || 8080;
const server = app.listen(port, () => {
console.log(`App running on port ${port}`);
});
Step 4 - Lets write some code in our app.js
file
This file contains code where all our middlewares will be registered, middlewares such as our route middlewares, global error handler middleware, some third-party middlewares for security and rate limiting
const express = require('express');
const app = express();
const UserRouter = require('./routes/userRoute');
const blogRouter = require('./routes/blogRoute');
const appError = require('./utils/appError');
const cookieParser = require('cookie-parser');
const globalErrHandler = require('./controllers/errorController');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
const hpp = require('hpp');
const cors = require('cors');
const morganMiddleware = require('./utils/morgan');
const logger = require('./utils/logger');
//GLOBAL MIDDLEWARES
//Allow cross-origin access
app.use(cors());
//Set security HTTP headers
app.use(helmet());
const limiter = rateLimit({
max: 500,
windowMs: 24 * 60 * 60 * 1000,
standardHeaders: true,
message: 'Too Many Request From this IP, please try again in an hour',
});
//Set API Limit
app.use('/api', limiter);
//Data Sanitization against NOSQL query Injection
app.use(mongoSanitize());
//Data Sanitization against XSS
app.use(xss());
//Morgan Middleware
app.use(morganMiddleware);
//Allow views
app.use(express.static(`${__dirname}/views`));
app.use((req, res, next) => {
req.requestTime = new Date().toISOString();
next();
});
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use('/api/v1/users', UserRouter);
app.use('/api/v1/articles', blogRouter);
//Global Error Handler
app.all('*', (req, res, next) => {
return next(
new appError(404, `${req.originalUrl} cannot be found in this application`)
);
});
app.use(globalErrHandler);
module.exports = app;
Step 5 - Lets create our data models now.
Now that our server is up and connected to our DB, lets start modelling our data. we would start with user model, so lets head into the models folder and create a file called userModel.js
and paste the code below into it.
const mongoose = require('mongoose');
const validator = require('validator');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const userSchema = new mongoose.Schema(
{
firstname: {
type: String,
required: [true, 'Please provide firstname'],
},
lastname: {
type: String,
required: [true, 'Please provide lastname'],
},
email: {
type: String,
required: [true, 'Please Provide Email Address'],
unique: true,
validate: [validator.isEmail, 'Please a valid email address'],
},
username: {
type: String,
unique: true,
},
password: {
type: String,
required: [true, 'Please Provide A Password'],
minlength: 8,
select: false,
},
passwordConfirm: {
type: String,
required: [true, 'Please Fill Password Field'],
validate: function (el) {
return el === this.password;
},
message: 'Password do not match.',
},
resetPasswordToken: String,
resetTokenExpires: Date,
},
{
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
this.passwordConfirm = undefined;
this.username = this.username.toLowerCase();
next();
});
userSchema.methods.createResetToken = function () {
const resetToken = crypto.randomBytes(32).toString('hex');
this.resetPasswordToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
console.log({ resetToken }, this.resetPasswordToken);
this.resetTokenExpires = Date.now() + 30 * 60 * 1000;
return resetToken;
};
userSchema.methods.comparePassword = async function (
signinPassword,
userPassword
) {
return await bcrypt.compare(signinPassword, userPassword);
};
//Virtual Properties To Load articles per user
userSchema.virtual('articles', {
ref: 'Blog',
foreignField: 'author',
localField: '_id',
});
const User = mongoose.model('User', userSchema);
module.exports = User;
In this file we create our model using mongoose and also used some mongoose hooks, methods & virtual properties to:
- Hash our users password before saving to the DB using
bcrypt.js
package for hashing & unhashing. - Create a reset token, when a user forgets his/her password.
- Add a reference to each blog created by a user.
Step 6 - Lets create the signup and login functionality
Head to the controller folder and create a file called authController.js
, and copy the following code
const jwt = require('jsonwebtoken');
const catchAsy = require('./../utils/catchAsync');
const appError = require('./../utils/appError');
const User = require('./../models/userModel');
const sendEmail = require('./../utils/email');
const crypto = require('crypto');
const { promisify } = require('util');
const app = require('../app');
const Logger = require('../utils/logger');
const signToken = (id) => {
return jwt.sign({ id }, process.env.jwt_secret, {
expiresIn: process.env.jwt_expires,
});
};
const createToken = (user, statusCode, res) => {
const token = signToken(user._id);
const cookieOptions = {
expires: new Date(
Date.now() + process.env.jwt_cookie_expires * 60 * 60 * 1000
),
httpOnly: true,
};
if (process.env.NODE_ENV === 'production') cookieOptions.secure = true;
//Send Token To Client
res.cookie('jwt', token, cookieOptions);
//Remove Password From JSON
user.password = undefined;
//Send Response To Client
res.status(statusCode).json({
status: 'Success',
data: {
token,
user,
},
});
};
exports.signup = catchAsy(async (req, res, next) => {
const user = await User.create(req.body);
createToken(user, 200, res);
});
exports.login = catchAsy(async (req, res, next) => {
const { email, password } = req.body;
//If user does not provide any of the required fields
if (!email || !password) {
return next(new appError(401, 'Please Provide Email or Password.'));
}
const user = await User.findOne({ email }).select('+password');
//If Any Field Is Incorrect
if (!user || !(await user.comparePassword(password, user.password))) {
const message = 'Email or Password Incorrect';
return next(new appError(401, message));
}
//If everything checks out, send JWT Token.
Logger.info(`New User Logged in with the ID of ${user.id}`);
createToken(user, 200, res);
});
exports.forgetPassword = catchAsy(async (req, res, next) => {
//1.Get User From Posted Email
const user = await User.findOne({ email: req.body.email });
if (!user) return next(new appError(404, 'Email Address Not Found!'));
//2. Create Token and Save to DB
const resetToken = user.createResetToken();
await user.save({ validateBeforeSave: false });
//3. Send to Client
const resetUrl = `${req.protocol}://${req.get(
'host'
)}/api/v1/users/resetpassword/${resetToken}`;
const message = `You made a request for a password reset, Click on the link to reset your password, reset token is valid for 30mins! ${resetUrl} \n Please Ignore if you did not make this request`;
try {
sendEmail({
email: user.email,
subject: `Your Password Reset Token(Valid 30min)`,
message,
});
res.status(201).json({
status: 'Success',
message: 'Please check inbox for reset token!',
});
} catch (err) {
user.resetPasswordToken = undefined;
user.resetTokenExpires = undefined;
return next(
new appError(
500,
'There was an error sending mail, Please try again later!'
)
);
}
});
exports.resetPassword = catchAsy(async (req, res, next) => {
//1. Grab token from resetUrl
const hashedToken = crypto
.createHash('sha256')
.update(req.params.token)
.digest('hex');
const user = await User.findOne({
resetPasswordToken: hashedToken,
resetTokenExpires: { $gt: Date.now() },
});
//2. Check IF token matches & still valid
if (!user)
return next(
new appError(
400,
'Token Invalid or Expired, Try to reset password again!'
)
);
//3. IF Token is valid
user.password = req.body.password;
user.passwordConfirm = req.body.passwordConfirm;
user.resetPasswordToken = undefined;
user.resetTokenExpires = undefined;
await user.save();
//4. Login User
createToken(user, 200, res);
});
exports.protectRoute = catchAsy(async (req, res, next) => {
const token = req.cookies.jwt;
if (!token) return next(new appError(400, 'Please Login Again!'));
const decoded = await promisify(jwt.verify)(token, process.env.jwt_secret);
//Check if user exists
const currentUser = await User.findById(decoded.id);
//if (decoded.expiresIn > Date.now() + jwt_cookie_expires * 60 * 60 * 1000)
if (!currentUser)
return next(new appError(404, 'Session expired, Login again!'));
//Add user to req object
req.user = currentUser;
next();
});
Here we allow users to register and login and also issue a JWT after a successful login.
Step 7 - Lets mount our routes
Head to the route folder and create a file called userRoute.js
, copy the following code.
const express = require('express');
const authController = require('./../controllers/authController');
const router = express.Router();
router.post('/signup', authController.signup);
router.post('/login', authController.login);
router.post('/forgotPassword', authController.forgetPassword);
router.patch('/resetpassword/:token', authController.resetPassword);
module.exports = router;
lets test what we have now but lets configure some scripts in package.json
file.
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
Now we can go ahead to test our project now by using npm start
in our terminal and if you wrote the code correctly users should be able to create an account and login now.
Step 8 - Lets create our blog model
Now we are done with the users end, lets work on creating the model for the blog itself. Head to the models folder and create another file called blogModel.js
, copy the following code.
const mongoose = require('mongoose');
const validator = require('validator');
const slugify = require('slugify');
const blogSchema = new mongoose.Schema(
{
title: {
type: String,
required: [true, 'Your Blog Requires A Title'],
trim: true,
unique: true,
},
slug: {
type: String,
},
description: {
type: String,
required: [true, 'Please add a description'],
trim: true,
},
author: {
type: mongoose.Schema.ObjectId,
ref: 'User',
},
state: {
type: String,
enum: ['draft', 'published'],
default: 'draft',
},
read_count: {
type: Number,
default: 0,
},
reading_time: {
type: String,
default: 0,
},
tags: {
type: [String],
},
body: {
type: String,
required: [true, 'Your blog must have a body!'],
},
},
{
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
blogSchema.set('timestamps', true);
blogSchema.pre(/^find/, function (next) {
this.populate({
path: 'author',
select: '-__v -resetPasswordToken -resetTokenExpires -password -email',
});
next();
});
blogSchema.pre('save', function (next) {
this.slug = slugify(this.title, { lower: true });
this.tags.forEach((el) => {
el.toLowerCase();
});
next();
});
blogSchema.methods.updateRead = async function () {
this.read_count = this.read_count + 1;
return this.read_count;
};
blogSchema.methods.calcReadTime = async function () {
let words = this.title.length + this.body.length;
let time = words / 200;
const fullTime = time.toString().split('.');
const min = fullTime[0];
const sec = Math.round((fullTime[1] * 60) / 1000);
return [min, sec];
};
const Blog = mongoose.model('Blog', blogSchema);
module.exports = Blog;
In this file we create our model using mongoose and also used some mongoose hooks, methods to:
- Slugify blog titles.
- Calculate the read-time of each blog post.
- Update the read count of each blog.
- Add a reference to the author who created the blog.
Step 9 - Lets create functionalities for the blog
Head to the controller folder and create a file called blogController.js
, and copy the following code
const Blog = require('./../models/blogModel');
const User = require('./../models/userModel');
const catchAsy = require('./../utils/catchAsync');
const appError = require('./../utils/appError');
const slugify = require('slugify');
exports.createBlog = catchAsy(async (req, res, next) => {
req.body.author = req.user._id;
const blog = await Blog.create(req.body);
const readTime = await blog.calcReadTime();
blog.reading_time = `${readTime[0]} min ${readTime[1]} seconds`;
await blog.save({ validateBeforeSave: false });
res.status(200).json({
status: 'Success',
data: {
blog,
},
});
});
exports.getAllBlogs = catchAsy(async (req, res, next) => {
//1. Create a query
let query = Blog.find();
//2. Check if user queries for any blog in draft state.
if (req.query.state == 'draft') {
return next(new appError(403, 'You cannot access unpublished articles!'));
} else {
query = Blog.find(req.query);
}
//Build query for author
if (req.query.author) {
const author = req.query.author;
const user = await User.findOne({ username: author });
if (!user)
return next(
new appError(403, 'Author does not exists or has written no articles')
);
const ID = user.id;
query = Blog.find({ author: ID });
}
//Build query for tags
if (req.query.tag) {
const tag = req.query.tag.split(',');
query = Blog.find({ tags: tag });
}
//Build Query For sort
if (req.query.sort) {
const sort = req.query.sort || 'createdAt';
query = query.sort(sort);
}
//.Add Pagination
const page = req.query.page * 1 || 1;
const limit = req.query.limit * 1 || 20;
const skip = (page - 1) * limit;
query = query.skip(skip).limit(limit);
//Await Query, and filter drafts out.
const blog = await query;
let newblog = [];
if (blog.length == 0) return next(new appError(403, 'No Blog Found'));
blog.forEach((el) => {
if (el.state == 'published') {
newblog.push(el);
}
});
res.status(200).json({
status: 'success',
result: newblog.length,
data: {
newblog,
},
});
});
exports.getBlog = catchAsy(async (req, res, next) => {
const ID = req.params.id;
const blog = await Blog.findById(ID);
if (blog.state == 'draft') {
return next(new appError(403, 'You cannot access unpublished blog'));
}
const count = blog.updateRead();
await blog.save({ validateBeforeSave: false });
res.status(200).json({
status: 'Success',
data: {
blog,
},
});
});
exports.updateBlog = catchAsy(async (req, res, next) => {
//1. Get Blog Id and Perform search in DB
const blogID = req.params.id;
const blog = await Blog.findById(blogID);
//2. Return error when blog cannot be found
if (!blog) return next(new appError(404, 'No Blog Found'));
//3. Check if user is owner of blog
if (blog.author.id === req.user.id) {
//4. If everything checks out, allow user edit blog.
const newBlog = await Blog.findByIdAndUpdate(blogID, req.body, {
new: true,
runValidators: true,
});
//5. Return data to user
res.status(200).json({
status: 'success',
data: {
newBlog,
},
});
} else {
return next(new appError(403, 'Action Forbidden, You cannot Update blog'));
}
});
exports.deleteBlog = catchAsy(async (req, res, next) => {
//1. Get Blog Id and Perform search in DB
const blogID = req.params.id;
const blog = await Blog.findById(blogID);
//2. Return error when blog cannot be found
if (!blog) return next(new appError(404, 'No Blog Found'));
//3. Check if user is owner of blog
if (blog.author.id === req.user.id) {
//4. If everything checks out, allow user delete blog.
const newBlog = await Blog.findByIdAndDelete(blogID);
//5. Return data to user
res.status(204).json({
status: 'success',
data: null,
});
} else {
return next(new appError(403, 'Action Forbidden, You cannot delete blog'));
}
});
exports.myBlog = catchAsy(async (req, res, next) => {
const queryObj = { ...req.query };
const excludedFields = ['page', 'sort', 'limit', 'fields'];
excludedFields.forEach((el) => delete queryObj[el]);
//1. Grab user ID from protect route
const userID = req.user.id;
//2. Use ID To find Blog where it matches the author ID.
let query = Blog.find({ author: userID });
//3. Build Query For Other Query
if (req.query.state) {
const state = req.query.state;
query = Blog.find({ author: userID, state: state });
}
//4.Add Pagination
const page = req.query.page * 1 || 1;
const limit = req.query.limit * 1 || 5;
const skip = (page - 1) * limit;
query = query.skip(skip).limit(limit);
const blog = await query;
res.status(200).json({
status: 'success',
result: blog.length,
data: {
blog,
},
});
});
Here we configure all the functionalities of our blog go through the comments in the code body.
Step 10 - Lets mount our routes for the blog*
Head to the route folder and create a file called blogRoute.js
, copy the following code.
const express = require('express');
const blogController = require('./../controllers/blogController');
const authController = require('./../controllers/authController');
const router = express.Router();
router
.route('/myblogs')
.get(authController.protectRoute, blogController.myBlog);
router
.route('/')
.post(authController.protectRoute, blogController.createBlog)
.get(blogController.getAllBlogs);
router.route('/:id').get(blogController.getBlog);
router
.route('/update/:id')
.patch(authController.protectRoute, blogController.updateBlog);
router
.route('/delete/:id')
.delete(authController.protectRoute, blogController.deleteBlog);
module.exports = router;
Now the major functionalities of our application are done, lets just add some utilities to make our app perfect.
Error Class Util
Head to the util folder and create a file called appError.js
, copy the following code.
class appError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'failed' : 'Server Error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = appError;
To make this appError util work we need a corresponding controller file, so lets create a new controller called errorController.js
and copy the following code.
const appError = require('./../utils/appError');
const handleCastErrorDB = (err) => {
const message = `Invalid ${err.path}: ${err.value}`;
return new appError(400, message);
};
const handleDuplicateKeyDB = (err) => {
const value = err.keyValue.email;
const message = `Duplicate field value: '${value}' Please use another value`;
return new appError(400, message);
};
const handleValidationErrorDB = (err) => {
const errors = Object.values(err.errors).map((el) => el.message);
const message = `Invalid field data ${errors.join('. ')} Critical Error`;
return new appError(400, message);
};
const handleJWTError = (err) =>
new appError(401, 'Invalid token. Please login again');
const handleJWTExpiredError = (err) =>
new appError(401, 'Your token is expired, Please login again');
const errorDev = (err, res) => {
res.status(err.statusCode).json({
status: err.status,
message: err.message,
});
};
const errorProd = (err, res) => {
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message,
});
//Programming Errors or other unknown Error: Don't leak error details
} else {
console.error('ERROR 💣', err);
res.status(500).json({
status: 'error',
message: 'Something went very wrong',
});
}
};
module.exports = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'Server Error';
if (process.env.NODE_ENV == 'Development') {
console.log(err);
errorDev(err, res);
} else if (process.env.NODE_ENV == 'production') {
let error = { ...err };
error.message = err.message;
if (error.name === 'CastError') error = handleCastErrorDB(error);
if (error.code === 11000) error = handleDuplicateKeyDB(error);
if (error._message === 'Validation failed')
error = handleValidationErrorDB(error);
if (error.name === 'JsonWebTokenError') error = handleJWTError(error);
if (error.name === 'TokenExpiredError')
error = handleJWTExpiredError(error);
errorProd(error, res);
console.log(err);
}
};
Now we have successfully setup a robust way of handling both development and production error.
Catch async Util
Head to the util folder and create a file called catchAsync.js
, copy the following code.
module.exports = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next);
}
}
This code block is to handle the repeated use of try and catch block, thereby making our code more readable.
Email Service Util
Head to the util folder and create a file called email.js
, copy the following code.
const nodemailer = require('nodemailer')
const sendEmail = async (options) => {
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD,
},
})
const mailOptions = {
from: 'Kelechi Okoronkwo <hello@blakcoder.tech>',
to: options.email,
subject: options.subject,
text: options.message
}
await transporter.sendMail(mailOptions)
}
module.exports = sendEmail
This util is to help in sending emails to users when requested, for example when users request for a password reset. Finally to make our email service work we would need to update our .env
file with the following code.
EMAIL_USERNAME='4602f30010ad95'
EMAIL_PASSWORD='1f4568221277a4'
EMAIL_HOST='smtp.mailtrap.io'
EMAIL_PORT=2525
I made use of mailtrap for email testing, you can get the following details by creating an account on mailtrap.io.
Finally you can test and deploy to your favorite platform
Here is the complete source code Source Code
Cheers
May the force be with you.
Top comments (0)