This is the second in a series of posts discussing the implementation of the
4dashes productivity tool. It covers the server-side API implementation
for HTTP web services built on Node.js and Express. It is an in-depth
continuation of the devops discussion for deploying a load-balanced
configuration.
4dashes is implemented as an Express application. The main application, defined
in server.js, loads configuration, configures logging, and detects
unsupported user agents. It delegates the remaining functionality to mounted
sub-applications. Specifically, one serves static assets that comprise the
client-side Angular application (SSL and caching is offloaded to a reverse
proxy) while the other handles API requests. Below is stripped down version of
server.js highlighting configuration loaded for the API sub-application and
the mounting of the two apps:
varexpress=require('express')varapp=express()varLog=require('log')varapi=require('./lib/api')varassets=require('./lib/assets')// define setting for log levelapp.set('log level',process.env.DASHES_LOG_LEVEL||Log.DEBUG)// define setting for port to bind application toapp.set('port',process.env.DASHES_PORT||8080)// define setting for 256 bit token key (base64 encoding)varkey='5zgIDUlloyybplQZtkTzbwoZJusp+SLJj8vjBjqiCh8='app.set('token key',process.env.DASHES_TOKEN_KEY||key)// define setting for max age of tokenapp.set('token age',process.env.DASHES_TOKEN_AGE||60*60*1000)// define setting for mongodb database nameapp.set('db name',process.env.DASHES_DB_NAME||'4dashes')// define setting for mongodb hostsapp.set('db hosts',process.env.DASHES_DB_HOSTS||'localhost')// configure default loggervarlogger=newLog(app.get('log level'))app.use(function(req,res,next){req.log=loggernext()})// log all requests if log level is INFO or higher using log module formatif(app.get('log level')>=Log.INFO){varformat='[:date] INFO :remote-addr - :method :url '+':status :res[content-length] - :response-time ms';express.logger.token('date',function(){returnnewDate()})app.use(express.logger(format))}// mount assets and api sub-applicationsapp.use('/api',require('./lib/api'))app.use('/',require('./lib/assets'))app.listen(app.get('port'))
The file, api.js, defines the Express application responsible for handling
api requests. It is itself comprised of sub-applications that handle requests
for the three core resources: users, tasks, and summaries.
varexpress=require('express')varapp=express()vardb=require('./db')varauth=require('./auth')varusers=require('./users')vartasks=require('./tasks')varsummaries=require('./summaries')// establish database connectionapp.use(db)// parse request body based on content typeapp.use(express.bodyParser())// define setting for token validationapp.set('validate',auth.validate)// mount resourcesapp.use('/token',auth)app.use('/users',users)app.use('/tasks',tasks)app.use('/summaries',summaries)// handle errorsapp.use(function(err,req,res,next){req.log.error(err.message)res.send(500)})module.exports=app
In addition to configuring middleware for parsing JSON request payloads and
converting internal errors into a 500 response code, the api application
imports and uses a database middleware function. This function checks if a
database connection (to a replica set) is established, and if not, attempts to
connect for use in the request pipeline. This approach allowed the startup
order of the application and database servers to be decoupled. The
Mongoose library is used to communicate with MongoDb.
To authenticate and authorize requests, a simple token-based approach was
implemented. Upon successful authentication against the /api/token endpoint,
a signed token is returned in a response header containing the user’s id and
expiration timestamp. Subsequent API requests are authenticated by the token
passed within a request header and authorized by confirming the user’s
ownership of a resource. A new token is returned in each API response extending
the expiration window. To mitigate against session hijacking, all communication
is encrypted with SSL.
The file, auth.js, implements the /api/token endpoint. Additionally, it
exports (as a property of the Express application) a validate middleware
function that is leveraged by the other API sub-applications.
varapp=require('express')()varcrypto=require('crypto')varUser=require('./models/user')varTOKEN_HEADER=app.TOKEN_HEADER='x-access-token'// authenticate user credentials in exchange for an access tokenapp.post('/',function(req,res,next){varkey=req.app.get('token key')varage=req.app.get('token age')User.authenticate(req.body.email,req.body.password,function(err,id){if(err){returnnext(err)}if(id){vartoken=generateToken(id,Date.now()+age,key)req.log.info('token generated for %s',req.body.email)res.set(TOKEN_HEADER,token)res.send(204)}else{res.send(401)}})})// validate signed access token included in request header// TODO improve logging for fail2ban processingapp.validate=function(req,res,next){vartoken=req.get(TOKEN_HEADER)varkey=req.app.get('token key')varage=req.app.get('token age')varcontentsif(token){contents=token.split(':')if(contents.length!==3){returnres.send(401)}User.findById(contents[0],function(err,user){if(err){returnnext(err)}if(!user){req.log.warning('token received with invalid user id: %s',contents[0])res.send(401)}elseif(!validTimestamp(contents[1])){req.log.debug('token received with expired timestamp')res.send(401)}elseif(!validSignature(contents)){req.log.warning('token received with invalid signature: %s',token)res.send(401)}else{req.user=userres.set(TOKEN_HEADER,generateToken(user.id,Date.now()+age,key))next()}})}else{req.log.warning('token not received for %s',req.originalUrl)res.send(401)}functionvalidTimestamp(timestamp){returntimestamp-Date.now()>=0}functionvalidSignature(contents){returngenerateToken(contents[0],contents[1],key)===contents.join(':')}}// generate signed access token with user id and timestampfunctiongenerateToken(id,timestamp,key){varcontent=id+':'+timestampreturncontent+':'+crypto.createHmac('sha256',newBuffer(key,'base64')).update(content).digest('base64')}module.exports=app
The file, user.js, implements the User model that defines the
authenticate method used by the /api/token endpoint. A user’s email address
and plaintext password submitted to the endpoint is checked against the salted
hash stored within the database. A partial implementation of the User model
is shown below highlighting the salted password hashing:
All three resource types follow a similar implementation pattern. Endpoints are
defined and exported as an Express application. The validate middleware is
looked up and used to authenticate requests (in the context of the task
resource shown below, all endpoints are authenticated). In support for the
offline mobile use case, new resources are created with a PUT request based
on a globally unique identifer created by a client. Additionally, the
If-Unmodified-Since conditional header is used to detect potential conflicts
that would arise from use by multiple clients (e.g. mobile and web). Finally,
the PATCH method is implemented using JSON Patch (RPC 6902) to optimize
client requests to modify a resource.
The tasks.js file below is representative of the implementation approach for
all resource endpoints:
varapp=require('express')()varjsonpatch=require('json-patch')varTask=require('./models/task')// validate all requests have an access tokenapp.all('*',function(req,res,next){varvalidate=req.app.get('validate')validate(req,res,next)})// return all incomplete tasks for a userapp.get('/',function(req,res,next){varmodifiedSinceif(req.query['modified-since']){modifiedSince=newDate(req.query['modified-since'])}// return incomplete tasks modified since the specified dateTask.findByIncomplete(req.user,modifiedSince,function(err,tasks){if(err){returnnext(err)}res.json(tasks)})})// lookup a task based on the id within the uriapp.param('id',function(req,res,next,id){Task.findById(id,function(err,task){if(err){returnnext(err)}if(!task){if(req.method==='PUT'){next()}else{res.send(404)}}else{// check that the requesting user is authorizedif(task.user.equals(req.user.id)){req.task=tasknext()}else{res.send(403)}}})})// return the task with the specified idapp.get('/:id',function(req,res){res.json(req.task)})// create or replace a task with the specified idapp.put('/:id',function(req,res,next){varunmodifiedSince=req.get('if-unmodified-since')// create new taskif(!req.task){if(unmodifiedSince){returnres.send(404)}req.body._id=req.params.idreq.body.user=req.user.idreq.body.modified=newDate()Task.create(req.body,function(err){if(err){returnres.send(400)}res.status(201).set('last-modified',req.body.modified).send()})// update existing task if conditional is provided}else{if(!unmodifiedSince){returnres.send(428)}if(isModified(req.task,unmodifiedSince)){returnres.send(412)}vartask=newTask(req.body)task.validate(function(err){if(err){returnres.send(400)}deletereq.body._iddeletereq.body.userreq.body.modified=newDate()Task.update({_id:req.task.id},req.body,function(err){if(err){returnnext(err)}res.status(204).set('last-modified',req.body.modified).send()})})}})// update a task with partial JSON data based on RFC 6902app.patch('/:id',function(req,res,next){varunmodifiedSince=req.get('if-unmodified-since')if(!req.is('application/json-patch+json')){returnres.send(415)}if(!unmodifiedSince){returnres.send(428)}if(isModified(req.task,unmodifiedSince)){returnres.send(412)}try{jsonpatch.apply(req.task,req.body)req.task.modified=newDate()req.task.save(function(err){if(err){returnnext(err)}res.status(204).set('last-modified',req.task.modified).send()})}catch(e){res.send(400)}})// compare dates stripping milliseconds functionisModified(task,unmodifiedSince){varmodified=newDate(task.modified).setMilliseconds(0)returnmodified>newDate(unmodifiedSince)}module.exports=app
After the implementation of all resource endpoints, it was clear that much of
the logic could be abstracted if time had permitted. Ideally, I would have been
interested in exploring a declarative approach to endpoints with a more robust framework for supporting data syncronization across clients.