Skip to content

Matthew Volk

Build your own user authentication REST API using Node and MongoDB

JavaScript, Authentication, MongoDB, Node.js6 min read

Last Updated: June 20th 2020

Project Introduction

Chris Anderson from Microsoft once described Javascript as the "English of Programming Languages" - a lot of people can speak at least a little bit of it, even it it's bad. It's not a perfect language, but it's a language which is relatively easy to learn for a lot of people.

In this tutorial, I'll show you how to build a simple REST API using Node.js, Express, MongoDB, and Mongoose.

MongoDB

For this project, I'm going to be using a cloud-hosted, free version of MongoDB called MongoDB Atlas. Go ahead and click the link, sign up for an account, and follow this guide. You should end up with a connection string that looks similar to:

1mongodb+srv://<dbusername>:<password>@dev-nycjj.mongodb.net/<dbname>?retryWrites=true&w=majority

Copy that string to your clipboard and head into the next step.

Folder Structure & Initial Routes

Open up your terminal and navigate to the directory you'd like to create your project folder in. Copy and paste the following line into your terminal

1mkdir PROJECT_NAME && cd PROJECT_NAME && npm init

Walk through each step of the npm init script (our entry point is going to be index.js).

When you are done, create a .env file in the root of your project directory.

Open the .env file in your text editor and enter:

.env
1MONGODB_URI=URL_YOU_COPIED_FROM_EARLIER

Save that file, and head back to your terminal. Run the following:

1npm install express mongoose bcryptjs cors dotenv jsonwebtoken body-parser passport-jwt passport

Once installed, create an index.js file in the root directory of your project to serve as your main entry point file. You'll want to import all of the modules you need for this file, found below:

index.js
1require("dotenv").config();
2const passport = require("passport");
3const express = require("express");
4const path = require("path");
5const bodyParser = require("body-parser");
6const cors = require("cors");
7const mongoose = require("mongoose");
8mongoose.set("useUnifiedTopology", true);

Below your imports, initialize your app variable:

index.js
1require("dotenv").config();
2const passport = require("passport");
3const express = require("express");
4const path = require("path");
5const bodyParser = require("body-parser");
6const cors = require("cors");
7const mongoose = require("mongoose");
8mongoose.set("useUnifiedTopology", true);
9
10const app = express();

Right under that, create a variable for my port number so that it is easily accessible and can be modified from one location in your file:

index.js
1require("dotenv").config();
2const passport = require("passport");
3const express = require("express");
4const path = require("path");
5const bodyParser = require("body-parser");
6const cors = require("cors");
7const mongoose = require("mongoose");
8mongoose.set("useUnifiedTopology", true);
9const app = express();
10
11const port = 3000;

Then go ahead and use app.listen to tell your app which port to listen for:

index.js
1require("dotenv").config();
2const passport = require("passport");
3const express = require("express");
4const path = require("path");
5const bodyParser = require("body-parser");
6const cors = require("cors");
7const mongoose = require("mongoose");
8mongoose.set("useUnifiedTopology", true);
9const app = express();
10const port = 3000;
11
12app.get("/", (req, res) => {
13 res.send("Hello, world!");
14});
15
16app.listen(port, () => {
17 console.log(`Server running at: http://localhost:${port}`);
18});

You should have enough code to successfully run your server to check if you've run into any bugs at this point.

Now we want to install Nodemon globally, so that we don't need to stop and start our node server everytime we make a change to a file.

npm install -g nodemon

Once installed, run nodemon in your terminal from within your application directory and wait for your Server running at: ... message to let you know your app is running on the port you specified earlier.

Next, we'll go ahead and install our CORS middleware, so that we can make requests to this API from a different domain name. If you'd like to learn more about CORS and what it does, the MDN docs have a great article I recommend reading.

Since we installed the CORS npm module in the beginning of this project, integrating the CORS middleware is as simple as adding:

index.js
1require("dotenv").config();
2const passport = require("passport");
3const express = require("express");
4const path = require("path");
5const bodyParser = require("body-parser");
6const cors = require("cors");
7const mongoose = require("mongoose");
8mongoose.set("useUnifiedTopology", true);
9const app = express();
10const port = 3000;
11
12app.use(cors());
13
14app.get("/", (req, res) => {
15 res.send("Hello, world!");
16});
17
18app.listen(port, () => {
19 console.log(`Server running at: http://localhost:${port}`);
20});

The module basically does us the favor of injecting different headers within our application using res.header, but you can read some more on that here. We'll be using this module with another module we installed called body-parser, which parses incoming request bodies. For example, when you receive a form submission, body-parser will help you parse the form input data. When you receive a GET request with a string query, body-parser will help you parse that URL parameter for validation.

index.js
1require("dotenv").config();
2const passport = require("passport");
3const express = require("express");
4const path = require("path");
5const bodyParser = require("body-parser");
6const cors = require("cors");
7const mongoose = require("mongoose");
8mongoose.set("useUnifiedTopology", true);
9const app = express();
10const port = 3000;
11
12app.use(cors());
13app.use(bodyParser.json());
14
15app.get("/", (req, res) => {
16 res.send("Hello, world!");
17});
18
19app.listen(port, () => {
20 console.log(`Server running at: http://localhost:${port}`);
21});

Next, let's make use of the Express router so that we can encapsulate all of the user routes in another file without cluttering our main entry file. Go ahead and create a new constant called users and have it require the file where we will store our routes. We also need to mount another piece of middleware on the app variable to add the /users prefix to all the routes in the file we create below.

index.js
1require("dotenv").config();
2const passport = require("passport");
3const express = require("express");
4const path = require("path");
5const bodyParser = require("body-parser");
6const cors = require("cors");
7const mongoose = require("mongoose");
8mongoose.set("useUnifiedTopology", true);
9const app = express();
10const port = 3000;
11const users = require("./routes/users");
12
13app.use(cors());
14app.use(bodyParser.json());
15app.use("/users", users);
16
17app.get("/", (req, res) => {
18 res.send("Hello, world!");
19});
20
21app.listen(port, () => {
22 console.log(`Server running at: http://localhost:${port}`);
23});

If you look at nodemon, you'll notice that your app crashed, because it couldn't find the file ./routes/users Create a new directory in your project to match that route we required just now, call it 'routes' and save it. Next, create a new file in that directory called 'users.js' and require the following modules:

routes/users.js
1const express = require("express");
2const router = express.Router();
3
4module.exports = router;

Nodemon should be back up and running.

Next, we'll create the three routes that users should have the ability to interact with; a registration route, an authentication route, and a profile route (which we will eventually protect via the JWT tokenization).

routes/users.js
1const express = require("express");
2const router = express.Router();
3
4router.post("/register", (req, res, next) => {
5 res.send("REGISTER");
6});
7
8router.post("/authenticate", (req, res, next) => {
9 res.send("AUTHENTICATE");
10});
11
12router.get("/profile", (req, res, next) => {
13 res.send("PROFILE");
14});
15
16module.exports = router;

Of course, the parameters being passed into the .send methods are just placeholders, we'll go ahead and fill those out in just a minute.

To test that everything works so far, navigate to localhost:3000/users/authenticate, and you should see AUTHENTICATE on the screen.

To connect to the database, Mongoose exposes a pretty straightforward 'connect' function that you add to your entry file which will run as soon as your start your application to open the port to your database.

index.js
1require("dotenv").config();
2const passport = require("passport");
3const express = require("express");
4const path = require("path");
5const bodyParser = require("body-parser");
6const cors = require("cors");
7const mongoose = require("mongoose");
8mongoose.set("useUnifiedTopology", true);
9const app = express();
10const port = 3000;
11const users = require("./routes/users");
12
13app.use(cors());
14app.use(bodyParser.json());
15app.use("/users", users);
16
17mongoose
18 .connect(process.env.MONGODB_URI, { useNewUrlParser: true })
19 .then(() => {
20 console.log("Successfully connected to MongoDB");
21 })
22 .catch((error) => console.error(error));
23
24mongoose.connection.on("error", (err) => {
25 console.error("Connection to MongoDB interrupted, attempting to reconnect");
26});
27
28app.get("/", (req, res) => {
29 res.send("Hello, world!");
30});
31
32app.listen(port, () => {
33 console.log(`Server running at: http://localhost:${port}`);
34});

Save that file, and nodemon should have refreshed with a new console log letting you know you're connected to the database found in your config file.

If you are curious as to why there are two types of error methods that Mongo needs, it's because there are two classes of errors that can occur with a Mongoose connection.

  • Error on initial connection. If initial connection fails, Mongoose will not attempt to reconnect, it will emit an error event, and the promise mongoose.connect() returns will reject.
  • Error after initial connection was established. Mongoose will attempt to reconnect, and it will emit an error event.

Part 4 - User Model

Next we'll be creating our user model file to handle data such as name, password, email, and username. We'll also have our functions that interact with the database in that file.

Create a directory called models and require the following modules inside a file called user.js:

models/user.js
1const mongoose = require("mongoose");
2const bcrypt = require("bcryptjs");

Now let's create the user schema:

models/user.js
1const mongoose = require("mongoose");
2const bcrypt = require("bcryptjs");
3
4const UserSchema = mongoose.Schema({
5 name: {
6 type: String,
7 },
8 email: {
9 type: String,
10 required: true,
11 },
12 password: {
13 type: String,
14 required: true,
15 },
16});
17
18module.exports = mongoose.model("User", UserSchema);

Part 5 - The 'REGISTER' Path

Back in the root directory of your file navigate to routes/users.js and require the schema we created in part 4 to the top of the file:

routes/users.js
1const express = require("express");
2const router = express.Router();
3
4const User = require("../models/user");
5
6router.post("/register", (req, res, next) => {
7 res.send("REGISTER");
8});
9
10router.post("/authenticate", (req, res, next) => {
11 res.send("AUTHENTICATE");
12});
13
14router.get("/profile", (req, res, next) => {
15 res.send("PROFILE");
16});
17
18module.exports = router;

Navigate down to the router.post request for the /register path and change the callback function body to:

routes/users.js
1const express = require("express");
2const router = express.Router();
3
4const User = require("../models/user");
5
6router.post("/register", (req, res, next) => {
7 /**
8 * Note, there is nothing in this code to stop someone from registering
9 * the same email twice. This is just an example application to explain
10 * high level concepts, but if I were building this to be deployed into
11 * production, I would add a function to search the database
12 * for a user with the email in the request right here.
13 */
14 let newUser = new User({
15 name: req.body.name,
16 email: req.body.email,
17 username: req.body.username,
18 password: req.body.password,
19 });
20
21 newUser.save((err) => {
22 if (err) throw new Error("User did not save");
23 console.log("User saved", newUser);
24 res.status(201).send("User created!");
25 });
26});
27
28router.post("/authenticate", (req, res, next) => {
29 res.send("AUTHENTICATE");
30});
31
32router.get("/profile", (req, res, next) => {
33 res.send("PROFILE");
34});
35
36module.exports = router;

Head back to models/user.js and add the pre middleware function that we're using above at the bottom of the user.js model file:

models/user.js
1const mongoose = require("mongoose");
2const bcrypt = require("bcryptjs");
3
4const UserSchema = mongoose.Schema({
5 name: {
6 type: String,
7 },
8 email: {
9 type: String,
10 required: true,
11 },
12 password: {
13 type: String,
14 required: true,
15 },
16});
17
18UserSchema.pre("save", function (next) {
19 var user = this;
20 if (!user.isModified("password")) return next();
21
22 bcrypt.genSalt(5, function (err, salt) {
23 if (err) return next(err);
24
25 bcrypt.hash(user.password, salt, function (err, hash) {
26 if (err) return next(err);
27 user.password = hash;
28 next();
29 });
30 });
31});
32
33module.exports = mongoose.model("User", UserSchema);

Part 6 - Try it Out

At this point, we can go ahead and make a POST request to our application's register endpoint with some user data in the POST body to ensure that the application properly saves it.

I'll be using Postman, but feel free to user any other utility that is capable of sending HTTP requests like curl or HTTPie.

Make your POST request to http://localhost:3000/users/register and then for your post body, go ahead and pass a JSON object that looks like:

1{
2 "name": "John Doe",
3 "email": "jdoe@gmail.com",
4 "username": "john",
5 "password": "123456"
6}

You should receive a response body that looks like:

1User created!

You can then log into your MongoDB Atlas account, find your cluster, click the button that says "Collections" and you should be able to view the information you just saved

*Note: You can refactor the response on the /register POST method to keep the user logged in after registering, because right now this endpoint just creates the user and the user would be expected to manually log in after creating their account. I suggest waiting until finishing the next section, Part 7 - Authentication before doing that.

Part 7 - Authentication

In this part, we will set up Passport.js with a JWT strategy to authenticate users and receive tokens.

In our index.js file, add the following lines of code:

index.js
1require("dotenv").config();
2const passport = require("passport");
3const express = require("express");
4const path = require("path");
5const bodyParser = require("body-parser");
6const cors = require("cors");
7const mongoose = require("mongoose");
8mongoose.set("useUnifiedTopology", true);
9const app = express();
10const port = 3000;
11const users = require("./routes/users");
12
13app.use(cors());
14app.use(bodyParser.json());
15app.use("/users", users);
16
17app.use(passport.initialize());
18app.use(passport.session());
19
20mongoose
21 .connect(process.env.MONGODB_URI, { useNewUrlParser: true })
22 .then(() => {
23 console.log("Successfully connected to MongoDB");
24 })
25 .catch((error) => console.error(error));
26
27mongoose.connection.on("error", (err) => {
28 console.error("Connection to MongoDB interrupted, attempting to reconnect");
29});
30
31app.get("/", (req, res) => {
32 res.send("Hello, world!");
33});
34
35app.listen(port, () => {
36 console.log(`Server running at: http://localhost:${port}`);
37});

Now, we are going to configure a strategy we'd like to use for the Passport tokenization.

Create a folder in the root of your project directory called config, then inside it create a file called passport.js.
At the top of that file, require the following:

config/passport.js
1const JwtStrategy = require("passport-jwt").Strategy;
2const ExtractJwt = require("passport-jwt").ExtractJwt;
3const User = require("../models/user");

Then, export the following at the bottom of the file:

config/passport.js
1const JwtStrategy = require("passport-jwt").Strategy;
2const ExtractJwt = require("passport-jwt").ExtractJwt;
3const User = require("../models/user");
4
5module.exports = function (passport) {
6 let opts = {};
7 opts.jwtFromRequest = ExtractJwt.fromAuthHeaderWithScheme("jwt");
8 opts.secretOrKey = "secret";
9
10 passport.use(
11 new JwtStrategy(opts, (jwt_payload, done) => {
12 User.getUserById(jwt_payload._id, (err, user) => {
13 if (err) {
14 return done(err, false);
15 }
16 if (user) {
17 return done(null, user);
18 } else {
19 return done(null, false);
20 }
21 });
22 })
23 );
24};

You'll want to make sure you include the export above inside of your index.js file.

index.js
1require("dotenv").config();
2const passport = require("passport");
3require("./config/passport")(passport);
4const express = require("express");
5const path = require("path");
6const bodyParser = require("body-parser");
7const cors = require("cors");
8const mongoose = require("mongoose");
9mongoose.set("useUnifiedTopology", true);
10const app = express();
11const port = 3000;
12const users = require("./routes/users");
13
14app.use(cors());
15app.use(bodyParser.json());
16app.use("/users", users);
17
18app.use(passport.initialize());
19app.use(passport.session());
20
21mongoose
22 .connect(process.env.MONGODB_URI, { useNewUrlParser: true })
23 .then(() => {
24 console.log("Successfully connected to MongoDB");
25 })
26 .catch((error) => console.error(error));
27
28mongoose.connection.on("error", (err) => {
29 console.error("Connection to MongoDB interrupted, attempting to reconnect");
30});
31
32app.get("/", (req, res) => {
33 res.send("Hello, world!");
34});
35
36app.listen(port, () => {
37 console.log(`Server running at: http://localhost:${port}`);
38});

We should also import Passport.js and the JSON web token module into our routes/users.js file:

routes/users.js
1const express = require("express");
2const passport = require("passport");
3const jwt = require("jsonwebtoken");
4const router = express.Router();
5
6const User = require("../models/user");
7
8router.post("/register", (req, res, next) => {
9 res.send("REGISTER");
10});
11
12router.post("/authenticate", (req, res, next) => {
13 res.send("AUTHENTICATE");
14});
15
16router.get("/profile", (req, res, next) => {
17 res.send("PROFILE");
18});
19
20module.exports = router;

Now, you should save the files we were just editing and go check nodemon to go make sure that nodemon isn't breaking. Once you've squashed any possible bugs, we are now going to our routes directory into the users.js file.

routes/users.js
1const express = require("express");
2const passport = require("passport");
3const jwt = require("jsonwebtoken");
4const router = express.Router();
5
6const User = require("../models/user");
7
8router.post("/register", (req, res, next) => {
9 /**
10 * Note, there is nothing in this code to stop someone from registering
11 * the same email twice. This is just an example application to explain
12 * high level concepts, but if I were building this to be deployed into
13 * production, I would add a function to search the database
14 * for a user with the email in the request right here.
15 */
16 let newUser = new User({
17 name: req.body.name,
18 email: req.body.email,
19 username: req.body.username,
20 password: req.body.password,
21 });
22
23 newUser.save((err) => {
24 if (err) throw new Error("User did not save");
25 console.log("User saved", newUser);
26 res.status(201).send("User created!");
27 });
28});
29
30router.post("/authenticate", (req, res, next) => {
31 const email = req.body.email;
32 const password = req.body.password;
33
34 User.findOne({ email }, (err, user) => {
35 if (err) throw err;
36 if (!user) {
37 return res.json({ success: false, msg: "User not found!" });
38 }
39
40 user.comparePassword(password, (err, isMatch) => {
41 if (err) throw err;
42 if (isMatch) {
43 const token = jwt.sign({ user }, "secret", {
44 expiresIn: 604800, // 1 week in seconds
45 });
46
47 res.json({
48 success: true,
49 token: "JWT " + token,
50 user: {
51 id: user._id,
52 name: user.name,
53 email: user.email,
54 },
55 });
56 } else {
57 return res.json({ success: false, msg: "Wrong password!" });
58 }
59 });
60 });
61});
62
63router.get("/profile", (req, res, next) => {
64 res.send("PROFILE");
65});
66
67module.exports = router;

Quick explanation of the code above; first we're going to check if the email supplied with the client-side request exists. If the email does exist, then we are going to take the password and try to match it to the password associated with the user associated with the email in the database. If the passwords match, we are going to assign the client an authentication token that expires in a week, and then return some JSON containing a JSON Web Token ID to the request origin. If the password does not match, the user is not authenticated and will have to re-enter their password.

We also created a function we have not yet defined, called User.comparePassword(). Let's go ahead and create this function in our User model, as to uphold the separation of concerns between files we have stayed true to throughout this project and make sure everything is encapsulated properly.

At the bottom of the models/user.js file:

models/user.js
1const mongoose = require("mongoose");
2const bcrypt = require("bcryptjs");
3
4const UserSchema = mongoose.Schema({
5 name: {
6 type: String,
7 },
8 email: {
9 type: String,
10 required: true,
11 },
12 password: {
13 type: String,
14 required: true,
15 },
16});
17
18UserSchema.pre("save", function (next) {
19 var user = this;
20 if (!user.isModified("password")) return next();
21
22 bcrypt.genSalt(5, function (err, salt) {
23 if (err) return next(err);
24
25 bcrypt.hash(user.password, salt, function (err, hash) {
26 if (err) return next(err);
27 user.password = hash;
28 next();
29 });
30 });
31});
32
33UserSchema.methods.comparePassword = function (candidatePassword, cb) {
34 bcrypt.compare(candidatePassword, this.password, function (err, isMatch) {
35 if (err) return cb(err);
36 cb(null, isMatch);
37 });
38};
39
40module.exports = mongoose.model("User", UserSchema);

Alright, we should be okay now. That was quite a bit of code we just wrote, but let's go ahead and try to see if it still works.

Open Postman, open a new tab and start drafting a new POST request. the address is going to be http://localhost:3000/users/authenticate. For the body of the request:

1{
2 "username": "john",
3 "password": "123456"
4}

If all goes well, you should see something that looks like:

1{
2 "success": true,
3 "token": "JWT eyJHFJDKVjfkFHJKLEHVJKVnjkVNjkd39057JKDLFjkl7FJKDFLHJkhJkhfjekidvnrinuvdsjkl7853JHIELHuinjkfelnvuincivnsjkLuiHUGLnjKVLDNJNEIFLNVJDKn49329JKELNGiNJEKGRNnkef7FnjKVLn9FNJEK3klJdnJKGLRGLNGJKgNJkg7GjnklgrnjKLgejnkl",
4 "user": {
5 "id": "58a345a628f6455ab2912b2",
6 "name": "John Doe",
7 "username": "john",
8 "email": "jdoe@gmail.com"
9 }
10}
Aside: Why is the end point called /authenticate and not /login?

At the moment, all the /authenticate endpoint does is return a JSON object with the JWT token. In order to make the token useful so that the user's login session persists from request to request, you'll need to tell the client to save that token in their auth headers with each request. Alternatively, and more efficiently, you can store the token into either a cookie, or the localStorage object. More on that here.

Conclusion

All in all, I really enjoyed this little project. I was so extremely frustrated after getting stuck on this issue: https://github.com/matthewvolk/user-auth-node-app/issues/1 for a few days, only to realize I had forgotten to import the actual passport module on a dependency file.

I'm not sure when I'll be finishing this project, as I have recently discovered the joys of Python. I'm hoping to pick this back up sometime during the Summer of 2018.

Until then, stay RESTful ;)

© 2020 by Matthew Volk. All rights reserved.