diff --git a/Gruntfile.js b/Gruntfile.js index 240d1fe..b9e7296 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,5 +1,11 @@ module.exports = function(grunt) { + var DEV_SERVER_PORT = 8383; + var TEST_SERVER_PORT = 8387; + + // Tell our Express server that Grunt launched it + process.env.GRUNTED = true; + // Project configuration. grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), @@ -77,7 +83,7 @@ module.exports = function(grunt) { options: { reporter: 'spec', }, - src: ['coverage/test/server/runsDatastoreTest.js'] + src: ['coverage/test/api/apiTest.js'] }, coverage: { options: { @@ -85,11 +91,25 @@ module.exports = function(grunt) { quiet: true, captureFile: 'coverage/coverage.html' }, - src: ['coverage/test/api/*.js'] + src: ['coverage/test/core/*.js', 'coverage/test/api/*.js'] } }, express: { - test: { + dev: { + options: { + port: 8383, + server: './bin/server.js', + serverreload: true, + showStack: true + } + }, + testServer: { + options: { + port: 8387, + server: './bin/server.js' + } + }, + testSuite: { options: { port: 8388, bases: 'test/www' @@ -114,10 +134,15 @@ module.exports = function(grunt) { 'jshint' ]); + grunt.registerTask('dev', [ + 'express:dev' + ]); + grunt.registerTask('test', [ 'build', 'jshint', - 'express:test', + 'express:testServer', + 'express:testSuite', 'clean:coverage', 'blanket', 'copy:coverage', @@ -128,7 +153,8 @@ module.exports = function(grunt) { grunt.registerTask('test-current-work', [ 'build', 'jshint', - 'express:test', + 'express:testServer', + 'express:testSuite', 'clean:coverage', 'blanket', 'copy:coverage', diff --git a/bin/server.js b/bin/server.js index 3cd54f7..8fda8f9 100644 --- a/bin/server.js +++ b/bin/server.js @@ -7,8 +7,11 @@ var server = require('http').createServer(app); var bodyParser = require('body-parser'); var compress = require('compression'); +var authMiddleware = require('../lib/server/authMiddleware'); + app.use(compress()); app.use(bodyParser.json()); +app.use(authMiddleware); // Initialize the controllers @@ -16,7 +19,13 @@ var apiController = require('../lib/server/controllers/apiController') var uiController = require('../lib/server/controllers/uiController')(app); -// Launch the server -server.listen(settings.serverPort, function() { - console.log('Listening on port %d', server.address().port); -}); \ No newline at end of file +// Let's start the server! +if (!process.env.GRUNTED) { + // The server is not launched by Grunt + server.listen(settings.serverPort, function() { + console.log('Listening on port %d', server.address().port); + }); +} + +// For Grunt +module.exports = app; \ No newline at end of file diff --git a/bower.json b/bower.json index f248d80..b308e91 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,7 @@ { "name": "yellowlabtools", "dependencies": { - "angular": "~1.3.1", + "angular": "~1.3.5", "ngModal": "git://github.com/gmetais/ngModal.git#1.2.3" } } diff --git a/lib/server/authMiddleware.js b/lib/server/authMiddleware.js new file mode 100644 index 0000000..e9f68c7 --- /dev/null +++ b/lib/server/authMiddleware.js @@ -0,0 +1,52 @@ +var config = require('../../server_config/settings.json'); + +var jwt = require('jwt-simple'); +var debug = require('debug')('authMiddleware'); + + +var authMiddleware = function(req, res, next) { + 'use strict'; + + if (req.path.indexOf('/api/') === 0) { + + // Test if it's an authorized key + if (req.headers && req.headers['x-api-key'] && isApiKeyValid(req.headers['x-api-key'])) { + next(); + return; + } + + // Test if it's an authorized token + if (req.headers && req.headers['x-api-token'] && isTokenValid(req.headers['x-api-token'])) { + next(); + return; + } + + res.status(401).send('Unauthorized'); + } +}; + + +function isApiKeyValid(apiKey) { + return (config.authorizedKeys[apiKey]) ? true : false; +} + + +function isTokenValid(token) { + + var data = null; + + try { + jwt.decode(token, config.tokenSalt); + } catch(err) { + debug('Error while decoding token'); + debug(err); + return false; + } + + return data.expire && + data.expire > Date.now() && + data.application && + config.authorizedApplications.indexOf(data.application) >= 0; +} + +module.exports = authMiddleware; \ No newline at end of file diff --git a/lib/server/controllers/apiController.js b/lib/server/controllers/apiController.js index ee4e0de..a1c0952 100644 --- a/lib/server/controllers/apiController.js +++ b/lib/server/controllers/apiController.js @@ -13,10 +13,7 @@ var ApiController = function(app) { var runsDatastore = new RunsDatastore(); var resultsDatastore = new ResultsDatastore(); - // Retrieve the list of all runs - /*app.get('/api/runs', function(req, res) { - // NOT YET - });*/ + // Create a new run app.post('/api/runs', function(req, res) { @@ -107,6 +104,11 @@ var ApiController = function(app) { } }); + // Retrieve the list of all runs + /*app.get('/api/runs', function(req, res) { + // NOT YET + });*/ + // Delete one run by id /*app.delete('/api/runs/:id', function(req, res) { deleteRun() @@ -125,7 +127,7 @@ var ApiController = function(app) { // Exists app.head('/api/runs/:id', function(req, res) { existsX(); - // Retourne 200 si existe ou 404 si n'existe pas + // Returns 200 if the result exists or 404 if not }); */ diff --git a/package.json b/package.json index 9ceae90..0e8fe6c 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,17 @@ "main": "./lib/yellowlabtools.js", "dependencies": { "async": "~0.9.0", - "body-parser": "~1.9.2", - "compression": "~1.2.0", + "body-parser": "~1.10.0", + "compression": "~1.2.1", "debug": "^2.1.0", "express": "~4.10.4", + "jwt-simple": "^0.2.0", "phantomas": "1.7.0", "rimraf": "^2.2.8", "socket.io": "~1.2.0" }, "devDependencies": { - "chai": "^1.9.2", + "chai": "^1.10.0", "grunt": "^0.4.5", "grunt-blanket": "^0.0.8", "grunt-contrib-clean": "^0.6.0", @@ -29,11 +30,12 @@ "grunt-contrib-less": "^0.12.0", "grunt-express": "^1.4.1", "grunt-fontsmith": "^0.9.1", - "grunt-mocha-test": "^0.12.2", + "grunt-mocha-test": "^0.12.4", "matchdep": "^0.3.0", "mocha": "^2.0.1", - "phantomjs": "^1.9.10", + "phantomjs": "^1.9.12", "q": "^1.1.2", + "request": "^2.49.0", "sinon": "^1.12.1", "sinon-chai": "^2.6.0" }, diff --git a/server_config/settings-prod.json b/server_config/settings-prod.json index f7b4cb5..8d06c6a 100644 --- a/server_config/settings-prod.json +++ b/server_config/settings-prod.json @@ -1,4 +1,12 @@ { "serverPort": 80, - "googleAnalyticsId": "UA-54493828-1" + "googleAnalyticsId": "UA-54493828-1", + + "authorized-keys": { + + }, + "tokenSalt": "", + "authorizedApplications": [ + + ] } \ No newline at end of file diff --git a/server_config/settings.json b/server_config/settings.json index fb400fe..70f03bf 100644 --- a/server_config/settings.json +++ b/server_config/settings.json @@ -1,4 +1,12 @@ { "serverPort": 8383, - "googleAnalyticsId": "" + "googleAnalyticsId": "", + + "authorizedKeys": { + "1234567890": "contact@gaelmetais.com" + }, + "tokenSalt": "lake-city", + "authorizedApplications": [ + "frontend" + ] } \ No newline at end of file diff --git a/test/api/apiTest.js b/test/api/apiTest.js new file mode 100644 index 0000000..e2469a0 --- /dev/null +++ b/test/api/apiTest.js @@ -0,0 +1,171 @@ +var should = require('chai').should(); +var request = require('request'); +var jwt = require('jwt-simple'); + +var config = { + "authorizedKeys": { + "1234567890": "test@test.com" + }, + "tokenSalt": "test-salt", + "authorizedApplications": ["wooot"] +}; + +var apiUrl = 'http://localhost:8387/api'; +var wwwUrl = 'http://localhost:8388'; + +describe('api', function() { + + var runId; + + it('should not accept a query if there is no key in headers', function(done) { + this.timeout(5000); + + request({ + method: 'POST', + url: apiUrl + '/runs', + body: { + url: wwwUrl + '/simple-page.html', + waitForResponse: false + }, + json: true + }, function(error, response, body) { + if (!error && response.statusCode === 401) { + done(); + } else { + done(error || response.statusCode); + } + }); + }); + + it('should refuse a query with an invalid key', function(done) { + this.timeout(5000); + + request({ + method: 'POST', + url: apiUrl + '/runs', + body: { + url: wwwUrl + '/simple-page.html', + waitForResponse: false + }, + json: true, + headers: { + 'X-Api-Key': 'invalid' + } + }, function(error, response, body) { + if (!error && response.statusCode === 401) { + done(); + } else { + done(error || response.statusCode); + } + }); + }); + + it('should accept a query with a valid key', function(done) { + this.timeout(5000); + + request({ + method: 'POST', + url: apiUrl + '/runs', + body: { + url: wwwUrl + '/simple-page.html', + waitForResponse: false + }, + json: true, + headers: { + 'X-Api-Key': Object.keys(config.authorizedKeys)[0] + } + }, function(error, response, body) { + if (!error && response.statusCode === 200) { + + runId = body.runId; + runId.should.be.a('string'); + done(); + + } else { + done(error || response.statusCode); + } + }); + }); + + it('should refuse an expired token', function(done) { + this.timeout(5000); + + request({ + method: 'POST', + url: apiUrl + '/runs', + body: { + url: wwwUrl + '/simple-page.html', + waitForResponse: false + }, + json: true, + headers: { + 'X-Api-Token': jwt.encode({ + application: config.authorizedApplications[0], + expire: Date.now() - 60000 + }, config.tokenSalt) + } + }, function(error, response, body) { + if (!error && response.statusCode === 401) { + done(); + } else { + done(error || response.statusCode); + } + }); + }); + + it('should refuse a token from an unknown app', function(done) { + this.timeout(5000); + + request({ + method: 'POST', + url: apiUrl + '/runs', + body: { + url: wwwUrl + '/simple-page.html', + waitForResponse: false + }, + json: true, + headers: { + 'X-Api-Token': jwt.encode({ + application: 'unknown-app', + expire: Date.now() + 60000 + }, config.tokenSalt) + } + }, function(error, response, body) { + if (!error && response.statusCode === 401) { + done(); + } else { + done(error || response.statusCode); + } + }); + }); + + it('should accept a good token', function(done) { + this.timeout(5000); + + request({ + method: 'POST', + url: apiUrl + '/runs', + body: { + url: wwwUrl + '/simple-page.html', + waitForResponse: false + }, + json: true, + headers: { + 'X-Api-Token': jwt.encode({ + application: config.authorizedApplications[0], + expire: Date.now() + 60000 + }, config.tokenSalt) + } + }, function(error, response, body) { + if (!error && response.statusCode === 200) { + + runId = body.runId; + runId.should.be.a('string'); + done(); + + } else { + done(error || response.statusCode); + } + }); + }); +});