Welcome to the grand finale! We've come a long way. First, we built the basic view, some HTML, CSS, client-side JS that employed sessionStorage. The sessionStorage made it such that our dismissed popup would not return until a new browsing session.

Then, things got serious (in a good way). In part 2, we created routes, a promotions model and controller, and a functioning database! We then coded our API endpoints, connecting our front end with our back end.

I alluded to a little surprise at the end of part 2, so let's reveal that first.

Setting a promotion live

As a quick refresher, here is the route we expect:

  app.post('/promo/set_live', api.set_live);

The view

How should our view look? Nothing too complicated. A dropdown containing the titles and ids of each promotion should do the trick.

  if promos.length
    form(method="POST" action="/promo/set_live")
      select(name="promos_select")
        option(value="#") -- None --
        each promo in promos
          option(value="#{promo._id}" selected=promo.live == true)!=promo.title
      input.button(type="submit" value="Submit")
else
  p There are no promotions so far.

How about the endpoint?

Let's think this through. Here's what we know:

  • A promotion is considered to be live if it's live field is set to true.
  • Only one promotion can be live at a given time.
  • It's possible that to have no live promotions.

Here are the basic steps:

  1. Find the live record (if it exists) and set its live field to false.
  2. If the id being passed into the function is #—meaning the user selected -- None -- from the dropdown—we're already done. Redirect back from whence we came.
  3. If the id is valid, find the record associated with it, update it and redirect back.

This was my first attempt, and it contained a major bug 🐛. See if you can find it by scanning the following code. Don't worry if you don't see the bug. I'll explain it in a moment.

  exports.set_live = function(req, res) {
  var id = req.body.promos_select;

  Promo.findOneAndUpdate(
    { "live" : "true" },
    { $set: { "live" : false } },
    function(err) {
      if (err) throw err;
    }
  )

  if (id === '#') { // '-- None -- ' was selected
    res.redirect('back');
  } else { // set the chosen promo live
    Promo.findOneAndUpdate(
      { _id: id },
      { $set: { "live" : true } },
      function(err, promo) {
        if (err) throw err;

        res.redirect('back');
      }
    )
  }
}

At first, it seemed to be working. But sometimes, when I'd set a promotion to live and submit the form, the database would contain no live promotion. How is it possible that our code only sometimes works?

The answer lies in the asynchronous nature of JavaScript. Although the instructions I wrote above appear to have an order, there is no enforcement of that order. I am calling Promo.findOneAndUpdate twice, but the second instruction can finish before the first one!

Do you see the problem now? What was occasionally happening is that the second instruction executed first:

"Set the record with the provided id to true."

Then, the first instruction would execute:

"Set the record with the live field of true to false."

Uh-oh. Now all our records have their live fields set to false.

How can we fix this?

async/await

Since Node versions >= 8, we've got async/await, which targets issues like this one. We'd like our code to read like synchronous code (first do this, then do that), while still enjoying the non-blocking, asynchronous nature of JavaScript. With async/await, we can have the best of both worlds.

The way we use it is to first add the keyword async before the function containing the instructions we intend to order. In our case, it would look like this:

  exports.set_live = async function(req, res) { ... }

Then, we add await as needed, to each call to the database:

  await Promo.findOneAndUpdate( ... )

Magically, this show-stopping bug disappeared. Order of execution was rescued, and the code worked flawlessly. Here's the full exports.set_live endpoint:

  exports.set_live = async function(req, res) {
  var id = req.body.promos_select;

  await Promo.findOneAndUpdate(
    { "live" : "true" },
    { $set: { "live" : false } },
    function(err) {
      if (err) throw err;
    }
  )

  if (id === '#') { // '-- None -- ' was selected
    res.redirect('back');
  } else { // set the chosen promo live
    await Promo.findOneAndUpdate(
      { _id: id },
      { $set: { "live" : true } },
      function(err, promo) {
        if (err) throw err;

        res.redirect('back');
      }
    )
  }
}

Security

Currently, anyone can visit our /promo route and alter our data. Let's fix this.

First, in our app.js, we need to pull in express-session. This will allow us to keep an admin's session alive after a successful login.

  const session = require('express-session');

Next (also in app.js), we need to use our session. This should be added before we create our routes.

  app.use(session({
  secret: process.env.session_secret,
  resave: false,
  saveUninitialized: true,
  cookie: { secure: false }
}));

What do these properties mean? We'll refer to the documentation

  • secret:

This is the secret used to sign the session ID cookie.

You should not expose this string to the public, which is why I'm using process.env to provide the value.

  • resave:

Forces the session to be saved back to the session store, even if the session was never modified during the request.

We don't require this, so false is okay to use.

  • saveUninitialized

Forces a session that is "uninitialized" to be saved to the store. A session is uninitialized when it is new but not modified.

For small applications, using the default, true, seemed safe enough. The documentation mentions that this default will soon need to be explicitly defined, which is why I chose to include it.

  • cookie

If secure is set to true, then https is enforced. Since I'm not using https locally, I set secure to false. We could pretty easily detect the application environment and set a variable appropriate to the context.

By itself, the session does nothing to secure our web application. However, it gives us a session object, which allows us to set a global logged-in state that we can check from any function. Before we get into how this works, we need to think about when we'll check authentication.

If a user visits any url within our domain:

  • check if the requested url is private
  • check if the user is logged in

Because it fires each time the page loads, app.use() is a perfect candidate to satisfy our requirements. We add it to app.js, just beneath the session code above.

  app.use(checkAuth);

Let's write the checkAuth function now.

  function checkAuth (req, res, next) {
  const blacklist = ['/secure', '/promo'];
  const unauthenticated = (!req.session || !req.session.authenticated);

  if (blacklist.includes(req.url) && unauthenticated ||
    ~req.url.indexOf('/promo') && unauthenticated) {
    res.render('unauthorized', { status: 403 });

    return;
  }

  next();
}

First, we define our blacklist, the private areas of our application. The /secure route is the first place the admin will land upon a successful login. It will contain a list of admin-related links:

  ul
  li
    a(href="/promo") Promotions
  li
    a(href="/another-thing") Another thing
  li
    a(href="/logout") Log out

You should recognize the next blacklisted path. We've been dealing with /promo since the beginning of our route definitions in part 2.

The next line is interesting:

  const unauthenticated = (!req.session || !req.session.authenticated);

We're defining a user to be unauthenticated if either of two things is true:

  1. There is no session object in existence
  2. req.session.authenticated is false

Finally, we check to see if the user has attempted to reach a blacklisted url in an unauthenticated state. If so, we render the unauthorized view, which informs the user he must be logged in to proceed.

Logging in

Now that we've secured the sensitive pages of our application, how does a user actually log in?

First, we create a /login route. The route will respond to a GET and a POST in different ways. The GET route is straightforward:

  router.get('/login', function(req, res, next) {
  res.render('login', { title: 'Admin login' });
});

We serve the user a login view, which contains an HTML form whose action also points to /login.

  form(method='post', action='/login')
  div
    label(for="username") Username
    input(type="text" name="username" id="username" required)

  div
    label(for="password") Password
    input(type="password" name="password" id="password" required)

  input(type="submit" value="Submit" class="button")

When we post the form, we handle the /login POST request.

  router.post('/login', function(req, res, next) {
  if (req.body.username && req.body.username === process.env.ADMIN_USER &&
      req.body.password && req.body.password === process.env.ADMIN_PASS) {
    req.session.authenticated = true;
    res.redirect('secure');
  } else {
    res.render('login', { title: 'Login error' });
  }
});

If our username and password match our stored credentials for the admin, set the session authentication to true and redirect to the /secure view. Otherwise, render the /login form, because something didn't match up correctly.

End of the road 🎉

I've covered the important parts of this tiny, full-stack feature. To those who read all three parts, thank you! How did I do? Did I leave anything out? Which topic would you like me to cover next? Comments and criticisms are welcome.

Feeling overwhelmed? If you need 1-on-1 help, please contact me here. I offer remote pairing services and will personally walk you through the entire feature.


2,191 0 3