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)