Week 8
Access Control with JWT
Assignment Due
Assignment 2 - Mongo CRUD - is due before 1:00 pm on Friday, March 6th. It is worth 10% of your final grade.
Quiz 6: Data Sanitization 15 mins
There will be a quiz today. It will be worth 2% of your final grade.
Agenda
- AMA (10 mins)
- Quiz (10 mins)
- Register new users (30 mins)
- Break (10 mins)
- Login a user (30 mins)
- Break (10 mins)
- OOP Information Expert Principle (30 mins)
- Break (10 mins)
- Authentication guard middleware (30 mins)
EX8 Authentication & Authorization
Objective
Create the modules necessary to support user registration and authentication.
We will pickup where we left off last week. Clone this GitHub Classroom starter repo for a working version of the code from that exercise.
Auth Router
It is a common practice to separate the authentication related routes from the main API routes. This has several advantages. Chief among them is making it easier to separate user management into a separate micro service or outsource it to a third party like AWS Congnito or Auth0.
Start by creating a new folder in the /routes
folder called auth
. In that new folder, create a new module called index.js
with this placeholder content. We will use it to prove that everything is wired-up correctly and then come back to add the real code.
const express = require('express')
const router = express.Router()
// Register a new user
router.post('/users', async (req, res) => {
res.status(201).send({ data: 'new user created.' })
})
module.exports = router
Tell Express to use that router in app.js
.
app.use('/auth', require('./routes/auth'))
Test it with Postman
Register a new user
Mongoose Model
We need a Mongoose Model so that we can actually talk to the database. Let's keep it simple for now with just four properties: firstName, lastName, email, and password.
Create a module called User.js
in the /models
folder.
const mongoose = require('mongoose')
const schema = new mongoose.Schema({
firstName: { type: String, trim: true, maxlength: 64, required: true },
lastName: { type: String, trim: true, maxlength: 64 },
email: { type: String, trim: true, maxlength: 512, required: true },
password: { type: String, trim: true, maxlength: 70, required: true }
})
const Model = mongoose.model('User', schema) // factory function returns a class
module.exports = Model
POST /auth/users
In the /routers/auth/index.js
module, we can now update the route handler for creating new users. To save time, let's borrow the POST method from the cars router as a starting point and then modify it for our User model.
router.post('/users', sanitizeBody, async (req, res) => {
try {
let newUser = new User(req.sanitizedBody)
await newUser.save()
res.status(201).send({ data: newUser })
} catch (err) {
res.status(500).send({
errors: [
{
status: 'Internal Server Error',
code: '500',
title: 'Problem saving document to the database.'
}
]
})
}
})
Don't forget to require the User model at the top of the router module.
OK. Test it with Postman.
That works, but ...
The password is saved in plain text. That is an enormous security risk!
Encrypt password
Obviously passwords should never be stored in plain text. The current best practice for storing passwords is to use a strong cryptographic algorythm to generate a one-way hash. That means that once the password is encrypted, there is no way to decrypt it.
Wait a minute ... how do we process a login request?
To validate a user supplied password, we encrypt it and then compare that against the encrypted password stored in the database. Given the same inputs, the encrypted hash values should also be the same.
bcrypt
bcrypt is the most widely used encryption library. It has been ported to most popular programming languages including, JavaScript, PHP, Python, C#, and Java.
Let's add bcrypt as a project dependancy.
npm install bcrypt
At the top of the Auth Router module, import the bcrypt library. Then create a new variable called saltRounds
and set it to 14.
const bcrypt = require('bcrypt')
const saltRounds = 14
What is a salt?
A salt is random data that is used in cryptography as additional input along with the user supplied data. This ensures that even if two users have the same plain text password, the hashed values stored in the database will look different.
A new salt is randomly generated for each password. Salts defend against dictionary attacks or against their hashed equivalent, a pre-computed rainbow table attack.
Cryptographic Cost
When hashing the input data, bcrypt will go through a series of iterations or rounds to finally generate a secure hash. The saltRounds
value is applied not as an integer, but as an exponent. Each hash generated will go through 2^saltRounds iterations.
Given a typical 2Ghz core processor, you could expect hashing throughput similar to this ...
rounds=8 : ~40 hashes/sec
rounds=9 : ~20 hashes/sec
rounds=10: ~10 hashes/sec
rounds=11: ~5 hashes/sec
rounds=12: 2-3 hashes/sec
rounds=13: ~1 sec/hash
rounds=14: ~1.5 sec/hash
rounds=15: ~3 sec/hash
rounds=25: ~1 hour/hash
rounds=31: 2-3 days/hash
We want to make the hashing process as slow as is tollerable by legitimate users. The slower it is, the greater deterent against brute-force hacking attemtps. The default value is 10. It is recommended to use a higher value of between 13 and 15.
Regrdless of the number salt rounds, the resulting hashed value will always be 60 characters long.
hash()
Now, in the 'create user' route handler, use the hash
method to encrypt the password. We will replace the user supplied password with an encrypted version before saving to the database.
// ...
let newUser = new User(req.sanitizedBody)
newUser.password = await bcrypt.hash(newUser.password, saltRounds)
await newUser.save()
// ...
OK. Test it with Postman.
Unique email
We will use the user's email property as the login name. So, we need these to be unique in our users
collection, and we need an index on that property to speed up the querries.
In the User model, modify the schema to add unique: true
to the email proptery. This will tell MongoDB to create a 'unique index' on the email property.
WARNING
The unique
Option is Not a Validator. It is a helper method for creating an index with a unique value constraint.
If we attempt to save a new User with the same email address as another User, MongoDB will throw an error ...
week8:auth Error saving new user:
E11000 duplicate key error collection:
mad9124.users index: email_1 dup key: { : "mckennr@algonquincollege.com" }
We must query the database to validate that the new email address is not already registered before saving the new User. To make this query as fast as possible, we don't need to read the actual collection data, we can just see if it is in the index.
The countDocuments()
method on the User model takes a MongoDB query expression and returns the number of matching occurrances in the collection. The JavaScript !!
operator coerces the result into a Boolean value (true or false).
//...
let newUser = new User(req.sanitizedBody)
const itExists = !!(await User.countDocuments({ email: newUser.email }))
if (itExists) {
// return error
}
newUser.password = await bcrypt.hash(newUser.password, saltRounds)
//...
That is the core logic for the unique check. Now, let's expand the handling of the valiation error.
if (itExists) {
return res.status(400).send({
errors: [
{
status: 'Bad Request',
code: '400',
title: 'Validation Error',
detail: `Email address '${newUser.email}' is already registered.`,
source: { pointer: '/data/attributes/email' }
}
]
})
}
TIP
Calling the res.send()
method does not halt execution. So we need the return
keyword to exit the route handler function.
Login a user
OK. You can now successfully register new users. It is time to think about how to handle authenticating them. What should the RESTful URI be? You might think to try POST /auth/user/:id/login
, but that breaks some of the "rules" by overloading the primary resource path and adding an action verb.
It might help if I told you that we would send the client back a "security token" upon successful authentication. The client will then send that back as a header variable to each API call. So, in RESTful terms, what we really want to do is create a new authentication token. This makes more sematic sense.
POST /auth/tokens
This resource path will support only the POST action and will expect the payload to contain the username/loginName and password. Near the bottom of the Auth Router module, let's pseudo code this new route handler.
// Login a user and return an authentication token.
router.post('/tokens', sanitizeBody, async (req, res) => {
// check if the payload.username is valid
// retrieve the stored password hash
// compare the payload.password with the hashed password
// if all is good, return a token
// if any condition failed, return an error message
})
OK. How do we do that? Let's take them one at a time. Use the mongoose findOne()
method on the User model to search for a matching email
. If we find it, then we also have the hashed password. If not, send an error response with a 401 status code.
TIP
The Mongoose findOne()
method returns null
if there is no matching record in the database.
// Login a user and return an authentication token.
router.post('/tokens', sanitizeBody, async (req, res) => {
const { email, password } = req.sanitizedBody
const user = await User.findOne({ email: email })
if (!user) {
return res.status(401).send({ errors: ['we will build this later'] })
}
// compare the payload.password with the hashed password
// if all is good, return a token
// if any condition failed, return an error message
})
Next, use the bcrypt.compare()
method to validate the client supplied password.
// Login a user and return an authentication token.
router.post('/tokens', sanitizeBody, async (req, res) => {
const { email, password } = req.sanitizedBody
const user = await User.findOne({ email: email })
if (!user) {
return res.status(401).send({ errors: ['we will build this later'] })
}
const passwordDidMatch = await bcrypt.compare(password, user.password)
if (!passwordDidMatch) {
return res.status(401).send({ errors: ['we will build this later'] })
}
// if all is good, return a token
// if any condition failed, return an error message
})
If we got this far, everything is good and we can return a token. Add these two lines as a placeholder for the moment. We will look at how to generate an industry standard token in just a minute.
const token = 'iamatoken'
res.status(201).send({ data: { token } })
This should be enough to test our logic. Let's go to our old friend Postman.
It works! But, there is a marked difference in the response time when we give it an invalid email. Hackers can use this timing difference to their advantage. Let's refactor this to mask that difference.
To make sure the timing is consistent, we need the bycrypt.compare()
function to run everytime. So, change it to compare to a new variable called hashedPassword
rather than user.password
. Then conditionally set hashedPassword
.
If there was no User with a matching email (username) then we can set a fake value for the hashedPassword
that will never get a valid match.
TIP
The first three characters signify the bcrypt version. The next three signify the number of salt rounds. The next 22 characters are the salt and the remainder is the encrypted password.
const user = await User.findOne({ email: email })
const hashedPassword = user
? user.password
: `$2b$${saltRounds}$invalidusernameaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`
const passwordDidMatch = await bcrypt.compare(password, hashedPassword)
if (!user || !passwordDidMatch) {
return res.status(401).send({ errors: ['we will build this later'] })
}
const token = 'iamatoken'
res.status(201).send({ data: { token } })
Test it again. Great!
Create a JWT
It is time to generate a real token. We will use the defacto industry standard JSON Web Tokens (JWT). There are libraries for just about every popular programming language. Let's install the Node.js module.
npm install jsonwebtoken
You should read the full documentation on JWTs but here are two critical things to understand.
The payload is hashed, not encrypted. Anyone with some JavaScript can read it. So, never put sensitive data in the token payload.
The token is cryptographically signed using a secret key on your sever. So we can validate that no one has altered the contents of the payload. Which means we can trust it.
Let's check the usage instructions ...
We need to require the library at the top.
const jwt = require('jsonwebtoken')
Then in the route handler we will update the token assignment line. For the payload, we will just supply the authenticated User's the unique _id
property.
We will hard code the secret key for now -- never do this in production!
const token = jwt.sign({ _id: user._id }, 'superSecureSecret')
Test it in Postman ...
If you copy the token returned in Postman and paste it into the JWT Debugger at jwt.io you can verify the contents of the payload.
Yay! This works.
However, we are starting to get more and more logic about the User model leaking into our router module. In a small application, this is not really a big deal. But as an application grows in complexity this can lead to unwanted tight coupling between modules - making it harder to know where to look for implementation code, and harder to swap out the implementation to a third party web service or our own independent microservice.
The solution is to follow the OOP Information expert principle and move some of this logic to custom methods defined in our User model class.
Custom instance methods
The mongoose.Schema
class defines a methods
object to which we can attatch our own custom instance methods. These methods will be available to all Mongoose document objects created with this schema.
In this case, go to the /models/User.js
file and before calling the mongoose.model()
function, add a new property to the userSchema.methods
called generateAuthToken
and set it to hold a regular function. Then paste in the jwt.sign()
method from our router module.
TIP
Since we are creating an "instance method", we need to update the payload. There is no user
variable. Instead we can use the special this
keyword to refer to the instantiated object.
const jwt = require('jsonwebtoken')
const mongoose = require('mongoose')
// ...
schema.methods.generateAuthToken = function () {
return jwt.sign({ _id: this._id }, 'superSecureSecret')
}
const Model = mongoose.model('User', schema)
module.exports = Model
WARNING
Do not use an arrow function here. We need this
inside the function to reference the instatiated model object.
Now update the route handler to call this new method ...
router.post('/tokens', sanitizeBody, async (req, res) => {
const {email, password} = req.sanitizedBody
const user = await User.findOne({email: email})
const hashedPassword = user
? user.password
: `$2b$${saltRounds}$invalidusernameaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`
const passwordDidMatch = await bcrypt.compare(password, hashedPassword)
if (!user || !passwordDidMatch) {
return res.status(401).send({errors: ['we will build this later']})
}
res.status(201).send({data: {token: user.getAuthToken()}})
}
Custom class methods
Similarly, the route handler should not need to know about the password encryption implementation. We can relocate the authentication logic to a static class method and call it like User.authenticate(email, password)
and expect either a User instance or null in return.
This time add a new authenticate
property to the schema.statics
object in the /models/User.js
module. Copy over the logic from the router module and remember to change User
to this
.
const bcrypt = require('bcrypt')
const saltRounds = 14
// ...
schema.statics.authenticate = async function (email, password) {
const user = await this.findOne({ email: email })
const hashedPassword = user
? user.password
: `$2b$${saltRounds}$invalidusernameaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`
const passwordDidMatch = await bcrypt.compare(password, hashedPassword)
return passwordDidMatch ? user : null
// remember if the email did not match, user === null
}
And update the auth router module ...
router.post('/tokens', sanitizeBody, async (req, res) => {
const { email, password } = req.sanitizedBody
const user = await User.authenticate(email, password)
if (!user) {
return res.status(401).send({ errors: ['we will build this later'] })
}
res.status(201).send({ data: { token: user.generateAuthToken() } })
}
Test it with Postman to make sure that everything still works ... yay!
One last change to the POST /auth/tokens route handler ... let's put in a propper error message.
if (!user) {
return res.status(401).send({
errors: [
{
status: 'Unauthorized',
code: '401',
title: 'Incorrect username or password.'
}
]
})
}
Schema lifecylce hooks
There is one more implementation detail that is in the router module that should be moved to the User model. Currently the registration route is taking care of encrypting the password before saving the new User. It shouldn't need to know about this.
Mongoose Schema to the resscue again!
Mongoose provides some special middleware functions that are triggered by lifecycle events. We will use one that is triggered just before a model is saved to do the password encryption.
Add this code block to the /models/User.js
module just below the custom methods that we added eariler.
schema.pre('save', async function (next) {
// Only encrypt if the password property is being changed.
if (!this.isModified('password')) return next()
this.password = await bcrypt.hash(this.password, saltRounds)
next()
})
We can now delete these lines from the router module ...
const bcrypt = require('bcrypt')
const saltRounds = 14
//...
newUser.password = await bcrypt.hash(newUser.password, saltRounds)
OK. This is a much cleaner implementation. The router does routing and the implementation details of how to manage password encryption and authenticate a user are in the User model.
How does our code look now?
Auth Router
const debug = require('debug')('week8:auth')
const User = require('../../models/User')
const sanitizeBody = require('../../middleware/sanitizeBody')
const express = require('express')
const router = express.Router()
// Register a new user
router.post('/users', sanitizeBody, async (req, res) => {
try {
let newUser = new User(req.sanitizedBody)
const itExists = !!(await User.countDocuments({ email: newUser.email }))
if (itExists) {
return res.status(400).send({
errors: [
{
status: 'Bad Request',
code: '400',
title: 'Validation Error',
detail: `Email address '${newUser.email}' is already registered.`,
source: { pointer: '/data/attributes/email' }
}
]
})
}
await newUser.save()
res.status(201).send({ data: newUser })
} catch (err) {
debug('Error saving new user: ', err.message)
res.status(500).send({
errors: [
{
status: 'Server error',
code: '500',
title: 'Problem saving document to the database.'
}
]
})
}
})
// Login a user and return an authentication token.
router.post('/tokens', sanitizeBody, async (req, res) => {
const { email, password } = req.sanitizedBody
try {
const user = await User.authenticate(email, password)
if (!user) {
return res.status(401).send({
errors: [
{
status: 'Unauthorized',
code: '401',
title: 'Authentication failed',
description: 'Incorrect username or password.'
}
]
})
}
res.status(201).send({ data: { token: user.generateAuthToken() } })
} catch (err) {
debug(`Error authenticating user ... `, err.message)
res.status(500).send({
errors: [
{
status: 'Server error',
code: '500',
title: 'Problem saving document to the database.'
}
]
})
}
})
module.exports = router
User Model
const bcrypt = require('bcrypt')
const saltRounds = 14
const jwt = require('jsonwebtoken')
const mongoose = require('mongoose')
const schema = new mongoose.Schema({
firstName: { type: String, trim: true, maxlength: 64, required: true },
lastName: { type: String, trim: true, maxlength: 64 },
email: {
type: String,
trim: true,
maxlength: 512,
required: true,
unique: true
},
password: { type: String, trim: true, maxlength: 60, required: true }
})
schema.methods.generateAuthToken = function () {
return jwt.sign({ _id: this._id }, 'superSecureSecret')
}
schema.statics.authenticate = async function (email, password) {
const user = await this.findOne({ email: email })
const hashedPassword = user
? user.password
: `$2b$${saltRounds}$invalidusernameaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`
const passwordDidMatch = await bcrypt.compare(password, hashedPassword)
return passwordDidMatch ? user : null
}
schema.pre('save', async function (next) {
if (!this.isModified('password')) {
return next()
}
this.password = await bcrypt.hash(this.password, saltRounds)
next()
})
const Model = mongoose.model('User', schema)
module.exports = Model
Protected routes
The whole point of authenticating users, is to restrict access to certain resources. This can be as simple as "are you logged in?" or a more complex Role Based Access Control (RBAC) system that applies a more fine-grained user authorization check.
Let's start with the simple case. Create a new resource route to retrieve the currently logged-in user model. We could create a route formatted as GET /auth/users/:id
, but it is a very common practice to have a dedicated route for the active user. It is usually formatted similar to this, GET /auth/users/me
. Schetch out the logic ... add this to the Auth router module.
// Get the currently logged-in user
router.get('/users/me', async (req, res) => {
// Get the JWT from the request header
// Validate the JWT
// Load the User document from the database using the `_id` in the JWT
// Remember to redact sensitive data like the user's password
// Send the data back to the client.
})
Access to the currently logged-in user is somthing that we will want to do in many route handlers, so let's build that logic in a reusable middleware function. It will tackle the first two steps above and make the token's payload available on the request object as req.user
.
Create a new module called auth.js
in the middleware
folder of your project. Require the jsonwebtoken
module and set the jwtPrivateKey
to the same value that we used in the User model module (we are still hard coding this for now, but we'll look at a better way to handle this in a future module). Then add the basic middleware function signature.
const jwt = require('jsonwebtoken')
const jwtPrivateKey = 'superSecureSecret'
module.exports = (req, res, next) => {
// Get the JWT from the request header
// Validate the JWT
}
The client application will pass the token in a request header called Authorization
. The corresponding value will be in the form of Bearer {{token}}
, where {{token}}
is a placeholder for the actual JWT. Note the word Bearer is capitalized and followed be a space.
We can use the req.header('Authorization')
function to get the token
from the request headers and, if the token is missing, send an authentication error.
const token = req.header('Authorization')
if (!token) {
return res.status(401).send({
errors: [
{
status: 'Unauthorized',
code: '401',
title: 'Authentication failed',
description: 'Missing bearer token'
}
]
})
}
However, the value of the token is a string with two parts. The first part is the token type Bearer
and the second part – after the space – is the actual token. We can use JavaScript's String.prototype.split() to issolate these two values as elements of an array. Then we can apply destructuring assignment to to extract the first two elements of the array into local variables, type
and token
.
const [type, token] = headerValue.split(' ')
To keep things clean and organized, let's move the logic to parse the Authorization header into a separate function.
const parseToken = function (headerValue) {
if (headerValue) {
const [type, token] = headerValue.split(' ')
if (type === 'Bearer' && typeof token !== 'undefined') {
return token
}
return undefined
}
}
module.exports = async (req, res, next) => {
const token = parseToken(req.header('Authorization'))
if (!token)
// ... rest of the module
If we have a token, we can proceed to validate it with the jwt.verify()
method. It takes two arguments: the token, and the secret key. If the token is verified as not having been tampered with, then the payload is decoded and returned. We can then set the decoded payload as the user
key on the request object and call next()
.
const payload = jwt.verify(token, jwtPrivateKey)
req.user = payload
next()
But we need to send a validation error if the token is not good. So, let's wrap that in a try catch block.
try {
const payload = jwt.verify(token, jwtPrivateKey)
req.user = payload
next()
} catch (err) {
res.status(400).send({
errors: [
{
status: 'Bad request',
code: '400',
title: 'Validation Error',
description: 'Invalid bearer token'
}
]
})
}
The final auth middleware module should look like this.
const jwt = require('jsonwebtoken')
const jwtPrivateKey = 'superSecureSecret'
const parseToken = function (headerValue) {
if (headerValue) {
const [type, token] = headerValue.split(' ')
if (type === 'Bearer' && typeof token !== 'undefined') {
return token
}
return undefined
}
}
module.exports = (req, res, next) => {
const token = parseToken(req.header('Authorization'))
if (!token) {
return res.status(401).send({
errors: [
{
status: 'Unauthorized',
code: '401',
title: 'Authentication failed',
description: 'Missing bearer token'
}
]
})
}
try {
const payload = jwt.verify(token, jwtPrivateKey)
req.user = payload
next()
} catch (err) {
res.status(400).send({
errors: [
{
status: 'Bad request',
code: '400',
title: 'Validation Error',
description: 'Invalid bearer token'
}
]
})
}
}
Now we can go back to our auth router module and use this new middleware. Require it at the top.
const authorize = require('../../middleware/auth')
In the GET /auth/users/me
route handler, leverage the authorize
middleware. If the application flow made it this far, we know that the token was valid and the currently logged-in user's _id
is available on the request object.
Remember
When we encoded the token, we included only the user's unique id property.
We can now load the user document from the database and return it to the client.
router.get('/users/me', authorize, async (req, res) => {
const user = await User.findById(req.user._id)
res.send({ data: user })
})
Redact Sensitive Data
One more thing... we have to guard against revealing sensitve data like user.password
. One solution is to chain the Mongoose select() method onto the findById()
request and limit the fields that are returned.
Prefixing field names with -
explicitly tells Mongo to exlude this properties.
router.get('/users/me', authorize, async (req, res) => {
const user = await User.findById(req.user._id).select('-password -__v')
res.send({ data: user })
})
But a better solution is to make this the default behaviour in the User model schema. By overriding the toJSON()
method in the model schema, we can be assured that we never forget to remove unwanted fields in a rout handler.
Add this code snippet to the /models/User.js
module.
schema.methods.toJSON = function () {
const obj = this.toObject()
delete obj.password
delete obj.__v
return obj
}
And then we don't need the select()
method in the route handler.
OK. Let's test it with Postman.
Set the Authorization
header property to be the encoded token string that was returned from the login test – prefixed by Bearer
.
Submit your work
Use git to commit your changes and then push your commits up to the remote GitHub repo. Submit the GitHub repo's URL on Brightspace.
For next week
Before next week's class, please read these additional online resources.
Quiz
There will be a short quiz next class. The questions could come from any of the material referenced above.