Now that we have the email capture phase in place, we can start diving into the administration panel.

This panel will be served on the /admin route. We need authentication first. How would we do that?

We want a single user to ever log into the application in the admin side.

This means that most of the traditional authentication systems are overkill: we don’t really need to setup a username and password authentication, OAuth or any of that.

What I will do in this case is, I will create a single JWT token and send it to the client that first loads the /admin URL.

I assume you will deploy this application in a server, and right after you open /admin in your browser, and that browser will receive the JWT token.

No other user can then access the application admin side, except you. Well, you can’t even access it using another browser. Let’s go with this limitation for learning purposes, and we’ll later setup a better system in another app.

It’s simple but we’ll introduce several concepts.

Let’s start.

Introducing JWT

JWT stands for JSON Web Token and it’s one of the most popular authentication mechanisms on the Internet.

Things basically work in this way: when a token is generated, it’s sent to the browser, which stores it in a cookie. The cookie is then passed back to the server on every request, making sure the client is authenticated when performing the request.

This token can also host bits of data, like a user identifier for example. It’s all unencrypted data, so you can’t store sensitive information in there.

In Node.js we have a great library to interact with JWT: jsonwebtoken. Add it to your package.json file.

We create an auth.js file with this content:

const jwt = require('jsonwebtoken')

exports.createJWT = options => {
  return jwt.sign({}, process.env.JWT_SECRET, {
    expiresIn: options.maxAge || 3600
  })
}

exports.verifyJWT = token => {
  return new Promise((resolve, reject) => {
    jwt.verify(token, process.env.JWT_SECRET, (err, decodedToken) => {
      if (err || !decodedToken) {
        return reject(err)
      }
      resolve(decodedToken)
    })
  })
}

This module exports 2 functions: createJWT and verifyJWT. Those use the jsonwebtoken module to create and verify the authenticity of a token. I won’t go in the details of the implementation, but it’s important to see that they abstract the tiny details, and verifyJWT returns a promise.

They use the JWT_SECRET environment variable as the secret value. Put it into your .env file, and assign it a random string value of your choice.

Now, let’s go and use this library in our main server.js file.

Here, first we add those 2 requires on top:

const fs = require('fs')
const { createJWT, verifyJWT } = require('./auth')
const cookieParser = require('cookie-parser')

fs will be used to write a file to disk when the application authentication is initialized. We’ll create an empty file in .data/initialized.

cookie-parser will be used to check the cookies. The JWT token once generated is sent to the client as a token cookie. The client will send it back on every request, and we must parse the cookies to access and verify it. Express by default does not provide any cookies facility, so we use the cookie-parser middleware for that.

We then add the cookieParser() function as an Express middleware:

app.use(cookieParser())

We listen on the /admin route, and we first check if the .data/initialized file exists:

const initializedFile = './.data/initialized'

app.get('/admin', (req, res) => {
  if (fs.existsSync(initializedFile)) {
    //admin auth already initialized
  } else {
    //admin auth not initialized
  }
})

If it does not exist, which is the initial state of the app, we create a JWT token and we set it as the cookie in the response.

The token is created with duration of 1 year.

The browser will automatically save this cookie, and send it back in every response.

We create the .data/initialized file, so subsequent requests will find it and determine that the token has already been sent and authentication is done.

We then set the httpOnly and secure flags in the cookie. This is very important because this minimizes the security issues, first by requiring the cookie to only be sent via HTTPS, and by making it only accessible by the server, and not by JavaScript in the client.

In the end, we send the ./views/admin.html file contents.

Create this file, and just write “ADMIN!” into it, so that we know we were allowed admin access.

const initializedFile = './.data/initialized'

app.get('/admin', (req, res) => {
  if (fs.existsSync(initializedFile)) {
    //admin auth already initialized
  } else {
    const token = createJWT({
      maxAge: 60 * 24 * 365 //1 year
    })

    fs.closeSync(fs.openSync('./.data/initialized', 'w'))

    res.cookie('token', token, { httpOnly: true, secure: true })
    res.sendFile(__dirname + '/views/admin.html')
  }
})

If the file exists, which happens on every subsequent request, we verify the token, and either send an error message, or the ./views/admin.html page.

const initializedFile = './.data/initialized'

app.get('/admin', (req, res) => {
  if (fs.existsSync(initializedFile)) {
    verifyJWT(req.cookies.token).then(decodedToken => {
      res.sendFile(__dirname + '/views/admin.html')
    }).catch(err => {
      res.status(400).json({message: "Invalid auth token provided."})
    })
  } else {
    const token = createJWT({
      maxAge: 60 * 24 * 365 //1 year
    })

    fs.closeSync(fs.openSync('./.data/initialized', 'w'))

    res.cookie('token', token, { httpOnly: true, secure: true })
    res.sendFile(__dirname + '/views/admin.html')
  }
})

Resetting the token

Now when we set the token, we have no way to revoke it. Let’s make an /admin/reset endpoint. When called, the token is verified from the cookies, the ./data/initialized file is removed, so that the next time we access the page, we can get a new one.

app.get('/admin/reset', (req, res) => {
  try {
    if (fs.existsSync(initializedFile)) {
      verifyJWT(req.cookies.token).then(decodedToken => {
        fs.unlink(initializedFile, err => {
          if (err) {
            console.error('Error removing the file')
            res.status(500).end()
            return
          }
          res.send('Session ended')
        })
      }).catch(err => {
        res.status(400).json({message: "Invalid auth token provided."})
      })

    } else {
      res.status(500).json({message: "No session started."})
    }
  } catch(err) {
    console.error(err)
  }
})

A few considerations

This authentication strategy is not ideal in many situations.

For example, we cannot use a different browser to authenticate. We must first call /admin/reset from the authenticated browser, or remove the .data/initialized file from the file system.

But for our simple use case (single-user app), we can live with it. Since we have this very limited use, we can also limit the app access by IP, use a server-level protection or we can deploy it privately.

Also, there’s another thing. The authentication system is prone to CSRF attacks if we’ll later use forms and AJAX calls.

To mitigate it, we must deploy an additional strategy, and it will be the subject of one of the assignments I propose in the end.

We must first add another cookie we call XSRF-TOKEN, with a unique value, and we’ll send it in an additional HTTP header called X-XSRF-TOKEN in the HTTP requests.

Keep this in mind before deploying it in the wild.

The project at this point

The full project is available at https://glitch.com/edit/#!/node-course-project-newsletter-e


Go to the next lesson