DEV Community

loading...
Cover image for How to Create Registration & Authentication with Express & PassportJS

How to Create Registration & Authentication with Express & PassportJS

kelvinvmwinuka profile image Kelvin Mwinuka Originally published at kelvinmwinuka.com ・13 min read

In this article, I'm going to demonstrate how to build a user registration and authentication system in ExpressJS. In the previous article, we set up a MongoDB connection using Mongoose. Here we'll be using that connection to save user data and use it for authentication.

This project is available on Github. Feel free to clone it if you'd like to follow along.

Let's start by setting up the necessary packages and libraries for this portion of the project.

Run the following command to install the necessary package:

npm install passport passport-local express-session bcrypt connect-mongo express-flash joi
Enter fullscreen mode Exit fullscreen mode

Here's a breakdown of the packages we've just installed:

  1. passport and passport-local - User authentication.
  2. express-session - Sessions in ExpressJS.
  3. bcrypt - Password encryption and comparison on authentication.
  4. connect-mongo - Mongo store for express sessions.
  5. express-flash - Flashing messages for display in the front-end.
  6. joi - User input validation.

Include bootstrap (optional, as long as the form can send post data to the server, it will work).

In the base.html file, add the link and script tags for the bootstrap imports. They are imported once and then included in every template that extends the base template.

At this stage, base.html file should look like this:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>{{ title }}</title>
    <meta name="description" content="">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- Bootstrap CSS -->
    <link 
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" 
      rel="stylesheet" 
      integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" 
      crossorigin="anonymous">
    {% block styles %}
      {# This block will be replaced by child templates when importing styles #}
    {% endblock %}
  </head>
  <body>
    {% block content %}
      {# This block will be replaced by child templates when adding content to the  #}
    {% endblock %}

    <!-- Bootstrap JavaScript Bundle with Popper -->
    <script 
      src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js" 
      integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW" 
      crossorigin="anonymous">
    </script>
    {% block scripts %}
      {# This block will be replaced by child templates when importing scripts #}
    {% endblock %}
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Implementation

Go into the entry point file and require the following packages:

const session = require('express-session')
const MongoStore = require('connect-mongo')(session)
const passport = require('passport')
Enter fullscreen mode Exit fullscreen mode

Right after the app declaration, add built-in express middleware to parse incoming requests with url-encoded data to process the data that will be received from the forms.

var app = express()
app.use(express.urlencoded({extended: true}))
Enter fullscreen mode Exit fullscreen mode

Next, set up the session middleware. Make sure to place this code after the mongoose connection as we will use the existing mongoose connection to store the session data. Otherwise, you'll have to create a new connection for this.

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: true,
  store: new MongoStore({
    mongooseConnection: mongoose.connection,
    collection: 'sessions'
  }),
  cookie: {
    secure: false
  }
}))
Enter fullscreen mode Exit fullscreen mode

Let's walk through the code above:

  1. We're adding the session middleware to the app.
  2. secret - The string used to encrypt the session. Declare this in the .env file or system environment variables.
  3. resave - Determines whether the session object is saved back into the session store even if it was not modified by the request.
  4. saveUninitialized - Determines whether a new session should be saved into the store even before it's modified.
  5. store - The store used to save session data.

Update models

In this section, I'm referring to the user model we created in the previous article. Take a look here.

Now we need to update the user model in order to enable authentication and password hashing upon saving. We're doing this in the model in order to avoid writing the authentication login in multiple places shall we need it.

This logic is unique to this model so it makes sense to have it here. Navigate to the User.js model file we created previously and add the following code right after the first require statement:

const bcrypt = require('bcrypt')

const saltRounds = 10
Enter fullscreen mode Exit fullscreen mode

After the schema definition, add the following code:

userSchema.pre('save', async function(next){
  if (this.isNew) this.password = await bcrypt.hash(this.password, saltRounds)
  next()
})

userSchema.static('userExists', async function({username, email}){
  let user = await this.findOne({ username })
  if (user) return { username: 'This username is already in use' }
  user = await this.findOne({ email })
  if (user) return { email: 'This email address is already in use' }
  return false
})

userSchema.static('authenticate', async function(username, plainTextPassword){
  const user = await this.findOne({ $or: [ {email: username}, {username} ] })
  if (user && await bcrypt.compare(plainTextPassword, user.password)) return user
  return false
})
Enter fullscreen mode Exit fullscreen mode

There are a few things happening here:

  1. The first is a pre-save hook. This runs before every document save. We use it to determine if the current document is new (not an update call). If the document is new, hash the password. Always save a hashed password rather than plain text.
  2. The second block is a static method that checks if the user exists. We will query the database by username and then email. If a user is found, return an object specifying which one is already in use. Otherwise, return false.
  3. The third method is a static method added to the schema. We're using this to authenticate the user. If the user exists and the password comparison between plainTextPassword and the hashed user password passes, return the user object. Otherwise, return false for. failed authentication.

Registration

Create the registration form; a simple form that collects the user's name, username, email address and password.

Place this code in 'register.html' in the views folder.

{% extends 'base.html' %}

{% set title = 'Register' %}

{% block styles %}
  <style>
    form {
      margin-top: 20px;
      margin-left: 20px;
      margin-right: 20px;
    }
  </style>
{% endblock %}

{% block content %}
  <form action="/register" method="POST">
    <div class="mb-3">
      <label for="name" class="form-label">Name</label>
      <input 
        type="text" 
        class="form-control {% if messages.name_error %}is-invalid{% endif %}" 
        id="name" 
        name="name"
        value="{{ messages.name or '' }}"
        placeholder="Full Name">
      <div class="invalid-feedback">{{ messages.name_error }}</div>
    </div>
    <div class="mb-3">
      <label for="username" class="form-label">Username</label>
      <input 
        type="text" 
        class="form-control {% if messages.username_error %}is-invalid{% endif %}" 
        id="username" 
        name="username"
        value="{{ messages.username or '' }}"
        placeholder="Username">
      <div class="invalid-feedback">{{ messages.username_error }}</div>
    </div>
    <div class="mb-3">
      <label for="email" class="form-label">Email address</label>
      <input 
        type="email" 
        class="form-control {% if messages.email_error %}is-invalid{% endif %}" 
        id="email"
        name="email"
        value="{{ messages.email or '' }}"
        placeholder="Email Address">
      <div class="invalid-feedback">{{ messages.email_error }}</div>
    </div>
    <div class="mb-3">
      <label for="password" class="form-label">Password</label>
      <input 
        type="password" 
        class="form-control {% if messages.password_error %}is-invalid{% endif %}" 
        id="password" 
        name="password" 
        value="{{ messages.password or '' }}"
        placeholder="Password">
      <div class="invalid-feedback">{{ messages.password_error }}</div>
    </div>
    <div>
      <button type="submit" class="btn btn-primary">Sign me up!</button>
    </div>
  </form>
{% endblock %}

{% block scripts %}
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

We're using nunjucks to implement some dynamic behaviour.

The first is adding the is-invalid class to the form controls using flashed messages from the server. This adds an error message attached to the form control.

The second is setting the previous value entered by the user (an optional UX feature for the purposes of this tutorial).

After creating the register template, create the routes associated with the template.

Create a folder named 'routes' in the root of the project. This folder will hold all of our routes. Create a file 'register.js' in this folder. The contents of this file should be as follows:

var router = require('express').Router()
const Joi = require('joi')
const { User } = require('../models')

const validateRegistrationInfo = async (req, res, next) => {
  for(let [key, value] of Object.entries(req.body)) {
    req.flash(`${key}`, value)
  }
  /* Validate the request parameters.
  If they are valid, continue with the request.
  Otherwise, flash the error and redirect to registration form. */
  const schema = Joi.object({
    name: Joi.string().required(),
    username: Joi.string().alphanum().min(6).max(12).required(),
    email: Joi.string()
        .email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } }).required(),
    password: Joi.string().min(8).required()
  })

  const error = schema.validate(req.body, { abortEarly: false }).error
  if (error) {
    error.details.forEach(currentError => {
      req.flash(`${currentError.context.label}_error`, currentError.message)
    })
    return res.redirect('/register')
  }

  /** Check if user exists */
  const userExists = await User.userExists(req.body)
  if (userExists) {
    for(let [key, message] of Object.entries(userExists)) {
      req.flash(`${key}`, message)
    }
    return res.redirect('/register')
  }

  next()  
}

router.get('/register', (req, res) => res.render('register.html'))

router.post('/register', validateRegistrationInfo, async (req, res) => {
  let savedUser = await (new User(req.body)).save()
  res.redirect('/')
})

module.exports = router
Enter fullscreen mode Exit fullscreen mode

The first significant block of code is a function called validateRegistrationInfo. This is middleware that will be used to validate the user's registration information.

In the first phase of the validation, we immediately flash the current information for pre-population in case we redirect back to the registration page.

Phase 2 is validating each entry against a validation schema. The Joi package makes this process easy.

If there are any errors on validation, flash each error message for that particular entry before redirecting to the register page. Display this error message in the template.

The final phase of validation is checking whether the username/email provided are already in use. If they are, flash the error message before redirecting to the register route.

Create a GET route that simply renders 'register.html'. This is the route we redirect to when validation fails.

Create a post route that receives the data entered by the user in the request body passing the validation middleware to it.

In the route handler itself, we don't have to worry about invalid data as it would have passed all the validation checks if the handler is being executed.

Create a new user using the provided data, save it, and redirect to the home page.

Export this router object and import it in the entry file as follows:

// Import rotues
app.use('/', require('./routes/register'))
Enter fullscreen mode Exit fullscreen mode

Authentication

Now that we've taken care of the registration, it's time to implement the authentication logic of our application.

Start by creating a login form. This form has a username/email field and a password field. We'll also include a condition that checks for an error message to display in an alert. This will be displayed when we redirect to the login page after flashing a message.

Place this form in a 'login.html' template file in the views folder alongside the register template.

{% extends 'base.html' %}

{% set title = 'Login' %}

{% block styles %}
  <style>
    form {
      margin-top: 20px;
      margin-left: 20px;
      margin-right: 20px;
    }
  </style>
{% endblock %}

{% block content %}
  <form action="/login" method="POST">
    {% if messages.error %}
      <div class="alert alert-danger" role="alert">{{ messages.error }}</div>
    {% endif %}
    <div class="mb-3">
      <label for="name" class="form-label">Username or Email</label>
      <input 
        type="text" 
        class="form-control {% if messages.name_error %}is-invalid{% endif %}" 
        id="username" 
        name="username"
        value="{{ messages.name or '' }}">
      <div class="invalid-feedback">{{ messages.name_error }}</div>
    </div>
    <div class="mb-3">
      <label for="name" class="form-label">Password</label>
      <input 
        type="password" 
        class="form-control {% if messages.name_error %}is-invalid{% endif %}" 
        id="password" 
        name="password"
        value="{{ messages.name or '' }}">
      <div class="invalid-feedback">{{ messages.name_error }}</div>
    </div>
    <div>
      <button type="submit" class="btn btn-primary">Login</button>
    </div>
  </form>
{% endblock %}

{% block scripts %}
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

The next task is to define the passport strategy used to authenticate the user. We're using the strategy from passport-local because we're authenticating against our own stored user credentials.

Create a new file in the root of the project called 'passport-helper.js' with the following contents:

const LocalStrategy = require('passport-local').Strategy
const { User } = require('./models')

module.exports = (app, passport) => {

  passport.use(new LocalStrategy((username, password, done) => {
    User.authenticate(username, password)
    .then( user => {
      done(null, user)
    })
    .catch( error => {
      done(error)
    })
  }))

  passport.serializeUser((user, done) => {
    done(null, user._id)
  })

  passport.deserializeUser((id, done) => {
    User.findById(id, (error, user) => {
      if (error) return done(error)
      done(null, user)
    })
  })

  app.use(passport.initialize())
  app.use(passport.session())
}
Enter fullscreen mode Exit fullscreen mode

The first step is importing the Strategy and the User model.

The second step is configuring the strategy. We create a new instance of the strategy passing it a function that takes username, password and a verify callback (done) function that is executed after the authentication process is complete.

The authentication logic is placed inside this function. In order to keep this clean, we will simply use the 'authenticate' static method we created in the user model.

When authenticating in passport, a user object is passed to the verify callback upon successful authentication, otherwise false is returned (provided there's no error thrown in which case, pass the error).

Our authenticate method returns a user object if the user is found and false otherwise so its output is perfect for this scenario.

Once we've configured the strategy, we have to specify the user serialization and deserialization logic.

This step is optional if you're not using sessions, but we're trying to create a login system with sessions so in our case, it's necessary.

The serializeUser method takes a function with a user object and a callback as parameters which determines the data that will be stored in the session itself.

To keep the data stored in the session small, we store only the user ID in the session. This serialization process happens on initial login.

The deserializeUser method takes a function that receives the user ID and a callback. This method runs on all subsequent requests after login/serialization.

The user ID is grabbed from the session and the user is retrieved from the database. Once the user is retrieved, they are stored in req.user.

After serialization/deserialization, make sure to add passport initialize and session middleware to the app. We'll wrap all of this up in a function that takes our app and passport objects as parameters.

Our passport configuration is now complete. The next step is to initialize passport.

In the application entry file, import the function we created in the previous step and then execute it, passing the app and passport objects.

Make sure to have the require statement after the passport require statement. The initialization function must be called after session middleware is defined because the passport session middleware utilises it.

const initializePassport = require('./passport-helper')
...
initializePassport(app, passport)
Enter fullscreen mode Exit fullscreen mode

Now let's create the login routes. Inside the routes folder, create a file called 'login.js' and add the following code:

const createLoginRoutes = passport => {
  const router = require('express').Router()

  router.get('/login', (req, res) => {
    if (req.isAuthenticated()) return res.redirect('/')
    res.render('login.html')
  })

  router.post(
    '/login',
    passport.authenticate('local', {
      failureRedirect: '/login', 
      successRedirect: '/',
      failureFlash: 'User not found', 
    }),
    (error, req, res, next) => {
      if (error) next(error)
    }
  )

  router.get('/logout', (req, res) => {
    req.logout()
    res.redirect('/login')
  })

  return router
}

module.exports = createLoginRoutes
Enter fullscreen mode Exit fullscreen mode

Instead of creating routes in the same way we did in the register route file, we're doing it a bit differently here.

Since we're going to need the passport object, we will instead export a function that accepts a passport object as a parameter, defines the routes and returns the router object.

The first route is a GET route for '/login'. This renders the form when there is no active session. Use the 'isAuthenticated' method provided by passport in the request object in order to determine whether there is currently an active session.

The second route is a POST route from '/login'. This route accepts the form input from the user.

Pass the passport.authenticate middleware to this route to handle the authentication. This middleware accepts the strategy type and an options object.

In the options object, specify the redirect path in case of failure and in case of success. The failureFlash property specifies the message to flash in case of authentication failure. This is the message you should check for and display on the login page.

Finally, create a logout route that calls req.logout to end the current user's session. This logout method is also provided by passport.

Now import the login route creator in the entry file and pass the passport object to it:

app.use('/', require('./routes/auth')(passport))
Enter fullscreen mode Exit fullscreen mode

Update the home page route to the following:

app.get('/', async (req, res) => {
  if (!req.isAuthenticated()) return res.redirect('/login')
  res.render('home.html')
})
Enter fullscreen mode Exit fullscreen mode

The home page route is now a protected route. This means it should only be accessed by an authenticated user.

We achieve this by using the req.isAuthenticated method to make sure the user is authenticated. If not, redirect to the login page.

Go back to the register route file and update the GET route. to the following:

router.get('/register', (req, res) => {
  if (req.isAuthenticated()) return res.redirect('/')
  res.render('register.html')
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article, I demonstrated how to create a simple registration/authentication system in ExpressJS using PassportJS. However, an authentication system is not complete without a password reset feature.

The next article will be a tutorial on creating a password reset feature using mongoose and NodeMailer.

The post How to Create Registration & Authentication with Express & PassportJS appeared first on Kelvin Mwinuka.

If you enjoyed this article, consider following my website for early access to my content before it gets published here (don’t worry, it’s still free with no annoying pop-up ads!). Also, feel free to comment on this post. I’d love to hear your thoughts!

Discussion (0)

pic
Editor guide