In part 2 we did an intro to AWS lambda and later in part 4 we did a deep dive. We've removed a bunch of dependencies from our code and configured aws to do the heavy lifting. However, authorization is still done by some NPM-package and implemented in our code. That means we are responsible for maintaining that and making things work.
Here in part 5 we're going to separate our auth logic into an authorizer function and make sure our code only runs if the JWT is valid. We'll continue refactoring the Users part of our backend in the process.
The final repo for part 5 on github
Get Started
You can simply continue from where we left things in Part 4. If you didn't, clone the part 4 repo and follow the get started instructions in it's README.md.
The Situation
- Authorization logic is tightly coupled with our business logic, express.js and JavaScript. All of it is taken care of by various NPM packages.
- Errors and statuscodes are dealt with directly in the endpoints, cluttering our logic. We want to be able to stop our code execution and return either by throwing an error, anywhere without having to catch it, or simply return an object when things go as expected.
- Input validation is done in code. It contains flaws, is very inconsistent and clutters our functions. Let's replace these with JSON Schemas!
var mongoose = require('mongoose');
var router = require('express').Router();
var passport = require('passport');
var User = mongoose.model('User');
var auth = require('../auth');
router.get('/user', auth.required, function(req, res, next){
User.findById(req.payload.id).then(function(user){
if(!user){ return res.sendStatus(401); }
return res.json({user: user.toAuthJSON()});
}).catch(next);
});
router.put('/user', auth.required, function(req, res, next){
User.findById(req.payload.id).then(function(user){
if(!user){ return res.sendStatus(401); }
// only update fields that were actually passed...
if(typeof req.body.user.username !== 'undefined'){
user.username = req.body.user.username;
}
if(typeof req.body.user.email !== 'undefined'){
user.email = req.body.user.email;
}
if(typeof req.body.user.bio !== 'undefined'){
user.bio = req.body.user.bio;
}
if(typeof req.body.user.image !== 'undefined'){
user.image = req.body.user.image;
}
if(typeof req.body.user.password !== 'undefined'){
user.setPassword(req.body.user.password);
}
return user.save().then(function(){
return res.json({user: user.toAuthJSON()});
});
}).catch(next);
});
router.post('/users/login', function(req, res, next){
if(!req.body.user.email){
return res.status(422).json({errors: {email: "can't be blank"}});
}
if(!req.body.user.password){
return res.status(422).json({errors: {password: "can't be blank"}});
}
passport.authenticate('local', {session: false}, function(err, user, info){
if(err){ return next(err); }
if(user){
user.token = user.generateJWT();
return res.json({user: user.toAuthJSON()});
} else {
return res.status(422).json(info);
}
})(req, res, next);
});
The Cleanup
Let's start with the first two endpoints; GET /user and PUT /user
- ES6 Syntax helps a lot with cleaning up old JavaScript code
- We abstract getting a user by id into it's own function and handle that error only once
- That error being thrown will take care of setting our statuscode and returning a response as Lambda will catch all errors and if finding a pattern that matches [401] it will automatically turn it into a 401 response
- Authorization is done as a separate authorizer function ensuring that if the user isn't authorized, the function won't ever be called. I.e. our function doesn't have to worry about it
const mongoose = require('mongoose')
mongoose.connect(process.env.MONGODB_URI)
require('../../models/User')
const User = mongoose.model('User')
const getUserById = async (id) => {
let user = await User.findById(id)
if (!user) throw new Error(`[403] No user with ${id}`)
return user
}
module.exports.get = async (event, context) => {
let user = await getUserById(event.principalId)
return { user: user.toAuthJSON() }
}
module.exports.put = async (event, context) => {
let user = await getUserById(event.principalId)
const { username, email, bio, image, password } = event.body
if (username) user.username = username
if (email) user.email = email
if (bio) user.bio = bio
if (image) user.image = image
if (password) user.setPassword(password)
await user.save()
return { user: user.toAuthJSON() }
}
module.exports.login = async (event, context) => {
const { email, password } = event.body.user
let user = await User.findOne({ email })
if (!user) return { statusCode: 401, body: {message: `No user with e-mail ${email}`} }
if (user.validPassword(password)) {
user.token = user.generateJWT()
return { user: user.toAuthJSON() }
} else {
return { statusCode: 401, body: {message: 'Invalid password'} }
}
}
- In the login function we are returning objects with status codes and messages. In these cases, the emails not existing and passwords being wrong is expected behaviour.
- Throwing errors for expected behaviours is a bad practice.
- Instead we're returning objects that follow a standarized format, which Lambda & API Gateway automatically will map to responses with proper response codes.
- In these expected cases, we likely have some data we also wan't to return to the front-end for user feedback. Hence handling these in codes without templates makes sense.
Updated Serverless.yml
Below are the new additions to our Serverless.yml file in order to handle these new additions:
usersGet:
handler: src/handlers/api/users.get
events:
- http:
method: get
path: /api/user
integration: lambda
authorizer: authorize
response:
statusCodes:
403:
pattern: .*[403].*
template:
application/json: ${file(src/errors/error-response-template.yml)}
usersPut:
handler: src/handlers/api/users.put
events:
- http:
method: put
path: /api/user
integration: lambda
authorizer: authorize
request:
schema:
application/json: ${file(src/schemas/usersPut.json)}
response:
statusCodes:
200:
pattern: .*is already taken.*
template:
application/json: ${file(src/errors/error-response-template.yml)}
403:
pattern: .*[403].*
template:
application/json: ${file(src/errors/error-response-template.yml)}
usersLogin:
handler: src/handlers/api/users.login
events:
- http:
method: post
path: /api/users/login
integration: lambda
request:
schema:
application/json: ${file(src/schemas/usersLogin.json)}
- We've added the authorizer function at the top (more on this shortly) and then added it to each endpoint that requires authorization.
- You can see the error handling attached under statusCodes.
- For the PUT handler, notice that I've added the error template/handler we implemented in the last part of the tutorial! Because this function also uses mongoose.save(), we can re-use this error handler.
- You might notice that all errors use the same basic template. I'm going to refactor this later in a later tutorial part as a dedicated error handling deep-dive.
- Each endpoint that has an input, i.e. a request body, is linked to a JSON Schema that takes care of the validation for us. I wrote about how these work in [the last part of this tutorial].
Authorizer Function
For an in-depth overview of how these work, checkout the excellent post Lambda Custom Authorizers by Alex de Brie
const jwt = require('jsonwebtoken')
const buildIAMPolicy = (userId, effect, resource, context) => { // #1
return {
principalId: userId,
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: '*', // #2
},
],
},
context,
}
}
module.exports.handler = (event, context, callback) => {
try {
// #3
const token = event.authorizationToken.split('Token ')[1]
const decoded = jwt.verify(token, process.env.TOKEN_SECRET)
const user = decoded.username
// #4
const effect = 'Allow'
const userId = decoded.id
const authorizerContext = { user }
const policyDocument = buildIAMPolicy(userId, effect, event.methodArn, authorizerContext)
callback(null, policyDocument)
} catch (e) {
callback('Unauthorized') // Return a 401 Unauthorized response
}
}
- AWS has extremely powerful IAM, Indentity Access Management, features that you can leverage. However, we're going simple here: If the JWT is valid, we're always going to return the same standard policy.
- Here we're specifying a wildcard, allowing any of our resources to be accessed if the JWT is valid. However, you could use this to provide certain levels of users with access to some functions and others not etc.
- Our regular JWT verify and auth logic.
- The event.methodArn is the Amazon Resource Number for the Lambda the user is trying to access. We are also passing the contents of the JWT as authorizerContext, which can then be accessed by our lambda.
So this is now a re-usable JWT authorizer that you can plug into any of your serverless apps, not tied to express.js or tangled up with the rest of your code.
Summary
We've simplified our code greatly and now have a repeatable system for authentication, error handling and input validation that we can use in any of our serverless apps. In addition, because we've defined our infrastructure as code in the serverless file - we've automatically taken care of deployment with these updates.
Top comments (0)