5 - Authentication

We will add a simple user authentication based on user + password. Then the backend communication is secured based on jsonwebtoken ((source https://scotch.io/tutorials/authenticate-a-node-js-api-with-json-web-tokens)

The flow can be summarized like this

  • Client calls /login with user + password
  • Backend
    • finds the user on mongo
    • check password with a hash algorythm
    • if correct then generate a token using a secret + user.id. The token will contain an encrypted version of the user.
    • send the token back
  • Client should be responsible for storing the token locally and then include it as a header in each call to the API.

Session and State

Notice that this solution is 100% stateless. The backend doesn't store information for each user. It relays on the client including a valid token on each call.

So we don't have session

The Model

We will need to change our db model for the user to include password and a couple of methods to encrypt it and make sure we don't store it in plain text.

This is our new models/User.js

import mongoose from 'mongoose'
import bcrypt from 'bcrypt'
import { enumOf } from './mongoose-utils'

const SALT_WORK_FACTOR = 10

const Schema = mongoose.Schema

export const Roles = {
  DEV: 'DEV',
  ADMIN: 'ADMIN'
}

const schema = Schema({
  created_at: { type: Date, default: Date.now },
  name: { type: String },
  email: { type: String, unique: true, required: true },
  password: { type: String, required: true },
  roles: [enumOf(Roles)]
})

// password encryption
schema.pre('save', function(next) {
  const user = this

  if (!user.isModified('password')) return next()

  bcrypt.genSalt(SALT_WORK_FACTOR, (err, salt) => {
    if (err) return next(err)

    bcrypt.hash(user.password, salt, (error, hash) => {
      if (error) return next(error)

      user.password = hash
      return next()
    })
  })
})

schema.methods.comparePassword = function(candidatePassword) {
  return new Promise((resolve, reject) => {
    bcrypt.compare(candidatePassword, this.password, (err, isMatch) => {
      if (err) return reject(err)
      return resolve(isMatch)
    })
  })
}

const User = mongoose.model('User', schema)

export default User

Notice that we:

  • Added a "password" field
  • Added a mongoose "pre" hook, that (with an ugly code) hashes the password before saving/inserting the user.
  • Include a "comparePassword" instance method to check a password (for login)

Routes

Now we need routes to implement login. Lets create a routes/auth.js

import express from 'express'
import User from '../models/User'

const router = express.Router()

import { createToken } from '../config/auth'

const fail = () => ({ status: 'error', message: 'Authentication failed. Wrong user or password' })
const success = (user, token) => ({
  status: 'ok',
  token,
  user: {
    _id: user._id,
    name: user.name,
    email: user.email,
    roles: user.roles
  }
})

/* eslint consistent-return:0 */
router.post('/authenticate', async (req, res) => {
  const { email, password } = req.body

  const user = await User.findOne({ email })
  if (!user) {
    res.status(403).json(fail())
    return
  }
  const validPassword = await user.comparePassword(password)

  if (!validPassword) {
    return res.status(403).json(fail())
  }

  const token = createToken(user, req.config.auth.secret_key, req.config.auth.expiresIn)
  res.json(success(user, token))
})

export default router

This code just adds a new route POST /authenticate which receives two fields: email and password.

It then fetches the user, checks if the password is valid (using comparePassword) If it is valid then creates a new token and returns it.

"createToken" is a function in another file, which setups app authentication for express (next section)

Middleware

We also need to intercept all request to the backend to make sure that the token is valid. For that we use an express middleware.

A new file in config/auth.js

import jwt from 'jsonwebtoken';
import { isTest } from '../utils/environmental'

class AuthFeature {

  setup(app) {
    this.config = app.config
    app.use(::this.authenticate)
  }

  authenticate(req, res, next) {
    if (this.isOpen(req.path)) {
      next()
    } else {
      const token = getTokenFromRequest(req)
      if (token) {
        try {
          /* eslint-disable no-param-reassign */
          req.token = jwt.verify(token, this.config.auth.secret_key)
          next()
        } catch (error) {
          this.fail(res, error.message)
        }
      } else {
        this.fail(res, 'jwt required.')
      }
    }
  }

  isOpen(path) {
    return this.nonSecureRoutes().some(route => path.indexOf(route) === 0)
  }

  fail(res, error) {
    res.status(401).json({ status: 'error', error })
  }

  nonSecureRoutes() {
    // ADD here
    return [
      ...(isTest() ? ['/'] : []),
      '/auth/authenticate'
    ]
  }

  teardown() {
  }

}

export default new AuthFeature()


const getTokenFromRequest = (req) => {
  if (req.headers.authorization &&
      req.headers.authorization.split(' ')[0] === 'Bearer') {
    return req.headers.authorization.split(' ')[1]
  }
  return null
}

export function createToken(user, secretKey, expiresIn) {
  return jwt.sign({ id: user._id }, secretKey, { expiresIn })
}

This "feature" adds a new middleware function to express. The authenticate method which intercepts all calls. If the call should be "secured" then it checks that there is a token, and that it is valid. In that case it moves forward, otherwise fails.

There are some routes that are "open" for example the /auth/authenticate used to login, of course. That are "exceptions" defined in "nonSecureRoots".

This file also contains the logic to create a token and retrieve it from the request (header)

Now can start the app and make sure it works (you should create tests for this ! :P)

Perform a curl without a token

$ curl http://localhost:8001/
    {"status":"error","error":"jwt required."}

A curl to an open URL

curl -X POST http://localhost:8001/auth/authenticate
{"status":"error","message":"Authentication failed. Wrong user or password"}

To test the flow you need to first have a user in mongo. Grab the user email and the password (plain password) and do

curl -X POST http://localhost:8001/auth/authenticate -d "[email protected]&password=mypasswordblah"

This generates a response like this

{"status":"ok","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjU4NDg0YTc4M2QwMDhkNTlkZTVhNWRjNSIsImlhdCI6MTUwMjQ2ODE4MywiZXhwIjoxNTAyNTU0NTgzfQ.6EnbVlGm6CnrgxuW9qXb368Uck8WnztwFc40b6mm4B8","user":{"_id":"58484a783d008d59de5a5dc5","name":"jfernandes","email":"[email protected]","roles":["DEV","ADMIN"]}}

Grab the token from there Now you can use it for further request.

 curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjU4NDg0YTc4M2QwMDhkNTlkZTVhNWRjNSIsImlhdCI6MTUwMjQ2ODE4MywiZXhwIjoxNTAyNTU0NTgzfQ.6EnbVlGm6CnrgxuW9qXb368Uck8WnztwFc40b6mm4B8" http://localhost:8001/

That should now work fine (now JWT error)

results matching ""

    No results matching ""