diff --git a/.travis.yml b/.travis.yml index e3095b31..ec4baa39 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,8 @@ notifications: node_js: - '8' - '6' +before_install: + - if [[ `npm -v` != 3* ]]; then npm i -g npm@latest; fi before_script: - npm prune branches: diff --git a/CHANGELOG.md b/CHANGELOG.md index cc3b7a53..342a6372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,70 +4,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [3.0.0] (2017-12-05) - -See full release notes at https://github.com/dadi/api/releases/tag/v3.0.0 - -### Added - -#### Data Connectors - -API Version 3.0 supports multiple data connectors. In previous versions API used MongoDB as a backend; this is now configurable. API Data Connectors are available as NPM packages. To add one to your API installation, run the associated NPM install command: - -`$ npm install @dadi/api-mongodb --save` - -Each data connector has it's own configuration requirements, but API must also be configured to use the data connectors you select. Modify your API configuration as follows: - -```json -{ - "datastore": "@dadi/api-mongodb", // the NPM package name for the data connector to use for the content layer - "auth": { - "tokenUrl": "/token", - "tokenTtl": 1800, - "clientCollection": "clientStore", - "tokenCollection": "tokenStore", - "datastore": "@dadi/api-mongodb", // the NPM package name for the data connector to use for the authentication layer - "database": "test" - } -} -``` - -In addition, the data connector itself normally requires it's own configuration file. For example the MongoDB data connector requires a file using the following naming convention `mongodb..json`. These configuration files should be placed the `config` directory of the API application. - -#### Connection Recovery - -API is now capable of recovering from database connection failures. When API is started with no available database service it will keep retrying until a successful connection can be made, then it runs the normal boot process. - -In addition, if the database connection is lost during normal operation of API, any requests made while the connection is offline will result in a HTTP 503 returned to the client. - -The maximum number of connection retries can be configured in the main configuration file by adding the following block: - -```json -"databaseConnection": { - "maxRetries": 5 // default 10 -} -``` - -### Changed - -* New startup message displayed, with links to documentation -* [#141](https://github.com/dadi/api/issues/141): the internal fields will be prefixed with a special character (`_` by default) which is configurable using the configuration property `internalFieldsPrefix` -* [#180](https://github.com/dadi/api/issues/180): document properties with `null` values are not returned as part of the response -* [#251](https://github.com/dadi/api/issues/251): added a new `/hello` endpoint which returns HTTP 200 and a "Welcome to API" message -* [#263](https://github.com/dadi/api/issues/263): all delete hooks now receive a `deletedDocs` property -* [#314](https://github.com/dadi/api/issues/314): when configuration option `feedback` is `true` we now send a response body when deleting documents -* [#327](https://github.com/dadi/api/issues/327): API becomes capable of recovering from database connection failures -* [#328](https://github.com/dadi/api/issues/328): remove schema validation on settings: 'callback', 'defaultFilters', 'fieldLimiters' and 'count'. Now only requires 'cache' and 'authenticate' -* [#332](https://github.com/dadi/api/issues/332): allow POST to collection endpoints using `text/plain` content-type, which will be converted if it is valid JSON -* Configuration file validation removed, suppressing warnings on application startup -* POST/PUT/DELETE using non-existing document identifiers returns a 404: - -DELETE requests throws a 404 (instead of 204) when deleting a non-existing document by ID. This applies to requests where the document ID is passed in the URL, not when in the body (e.g. DELETE /v1/db/collection/DOC-ID vs DELETE /v1/db/collection). - -POST/PUT requests throw a 404 when updating a non-existing document by ID. This applies to requests where the document ID is passed in the URL, not when in the body (e.g. PUT /v1/db/collection/DOC-ID vs PUT /v1/db/collection). - -Closes [#345](https://github.com/dadi/api/issues/345). - ## [2.2.9] (2017-12-05) ### Changed diff --git a/dadi/lib/auth/tokens.js b/dadi/lib/auth/tokens.js index ccdfd710..f939755b 100755 --- a/dadi/lib/auth/tokens.js +++ b/dadi/lib/auth/tokens.js @@ -15,33 +15,55 @@ module.exports.connect = () => { connection = Connection(dbOptions, null, config.get('auth.datastore')) } -module.exports.generate = (req, res, next) => { - debug('Generate token') - - const handleInvalidCredentials = () => { - const error = new Error('Invalid Credentials') - - error.statusCode = 401 - - res.setHeader( - 'WWW-Authenticate', 'Bearer, error="invalid_credentials", error_description="Invalid credentials supplied"' - ) - - return next(error) - } - - // Look up the credentials supplied in the request body in clientStore - const credentials = { - clientId: req.body.clientId, - secret: req.body.secret - } +module.exports.generate = function (req, res, next) { + // Look up the creds in clientStore + var _done = function (database) { + if ( + typeof req.body.clientId !== 'string' || + typeof req.body.secret !== 'string' + ) { + var error = new Error('Invalid Credentials') + error.statusCode = 401 + res.setHeader('WWW-Authenticate', 'Bearer, error="invalid_credentials", error_description="Invalid credentials supplied"') + return next(error) + } - // Return 401 if the clientId/secret are not plain strings. - if ( - typeof credentials.clientId !== 'string' || - typeof credentials.secret !== 'string' - ) { - return handleInvalidCredentials() + database.collection(clientCollectionName).findOne({ + clientId: req.body.clientId, + secret: req.body.secret + }, function (err, client) { + if (err) return next(err) + + if (client) { + // Generate token + var token + getToken(function (returnedToken) { + token = returnedToken + + // Save token + return tokenStore.set(token, client, function (err) { + if (err) return next(err) + + var tok = { + accessToken: token, + tokenType: 'Bearer', + expiresIn: config.get('auth.tokenTtl') + } + + var json = JSON.stringify(tok) + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Pragma', 'no-cache') + res.end(json) + }) + }) + } else { + var error = new Error('Invalid Credentials') + error.statusCode = 401 + res.setHeader('WWW-Authenticate', 'Bearer, error="invalid_credentials", error_description="Invalid credentials supplied"') + return next(error) + } + }) } const connectionReady = database => { diff --git a/dadi/lib/cors/index.js b/dadi/lib/cors/index.js new file mode 100644 index 00000000..0a6644ec --- /dev/null +++ b/dadi/lib/cors/index.js @@ -0,0 +1,56 @@ +const path = require('path') +const vary = require('vary') + +const config = require(path.join(__dirname, '/../../../config.js')) + +module.exports = function cors (server) { + server.app.use((req, res, next) => { + if (config.get('cors') !== true) { + return next() + } + + const method = req.method && req.method.toLowerCase() + + // Preflight requests set some sensible defaults, but mostly permit anything + // the client asks for by reflecting its headers back. + if (method === 'options') { + // Reflect the requesting `Origin` back to the client, falling back on a + // wildcard permission. + if (req.headers.origin) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin) + vary(res, 'Origin') + } else { + res.setHeader('Access-Control-Allow-Origin', '*') + } + + // Permit all HTTP methods. + res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE') + + // Reflect any requested headers. + if (req.headers['access-control-request-headers']) { + res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers']) + vary(res, 'Access-Control-Request-Headers') + } + + // Explicitly set the content length to 0 for buggy browsers. + res.setHeader('Content-Length', 0) + + // Set the status code to 204 (No Content). + res.statusCode = 204 + + // Do not process further middleware. + return res.end() + } + + // All other requests reflect the requesting `Origin` back to the client, + // effectively enabling CORS for all endpoints and HTTP methods. + if (req.headers.origin) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin) + vary(res, 'Origin') + } else { + res.setHeader('Access-Control-Allow-Origin', '*') + } + + next() + }) +} diff --git a/dadi/lib/help.js b/dadi/lib/help.js index 5db363f7..c4488e0d 100755 --- a/dadi/lib/help.js +++ b/dadi/lib/help.js @@ -23,12 +23,6 @@ module.exports.sendBackJSON = function (successCode, res, next) { return } - if (config.get('cors') === true) { - res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization') - } - res.setHeader('content-type', 'application/json') res.setHeader('content-length', Buffer.byteLength(resBody)) diff --git a/dadi/lib/index.js b/dadi/lib/index.js index 1e894547..b2198cf3 100755 --- a/dadi/lib/index.js +++ b/dadi/lib/index.js @@ -23,6 +23,7 @@ var auth = require(path.join(__dirname, '/auth')) var cache = require(path.join(__dirname, '/cache')) var Connection = require(path.join(__dirname, '/model/connection')) var Controller = require(path.join(__dirname, '/controller')) +var cors = require(path.join(__dirname, '/cors')) var HooksController = require(path.join(__dirname, '/controller/hooks')) var MediaController = require(path.join(__dirname, '/controller/media')) var dadiBoot = require('@dadi/boot') @@ -213,6 +214,8 @@ Server.prototype.start = function (done) { app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' })) app.use(bodyParser.text({ limit: '50mb' })) + cors(self) + // update configuration based on domain var domainConfigLoaded app.use(function (req, res, next) { diff --git a/dadi/lib/model/utils.js b/dadi/lib/model/utils.js index 603c9716..ab70db0f 100644 --- a/dadi/lib/model/utils.js +++ b/dadi/lib/model/utils.js @@ -63,6 +63,39 @@ function sortQueriesByNestedLevel (queries) { return keys.reduce((r, k) => (r[k] = queries[k], r), {}) // eslint-disable-line } +function convertApparentObjectIds (query, schema) { + _.each(Object.keys(query), function (key) { + if (/apiVersion/.test(key)) { + return + } + + var fieldSettings = getSchemaOrParent(key, schema) + var type = fieldSettings ? fieldSettings.type : undefined + + if (key === '$in') { + if (typeof query[key] === 'object' && _.isArray(query[key])) { + var arr = query[key] + _.each(arr, function (value, key) { + if (typeof value === 'string' && ObjectID.isValid(value) && value.match(/^[a-fA-F0-9]{24}$/)) { + arr[key] = ObjectID.createFromHexString(value) + } + }) + query[key] = arr + } + } else if (typeof query[key] === 'object' && query[key] !== null) { + if (typeof type !== 'undefined' && /^Mixed|Object$/.test(type)) { + // ignore + } else if (typeof type === 'undefined' || type !== 'Reference') { // Don't convert query id when it's a Reference field + query[key] = convertApparentObjectIds(query[key], schema) + } + } else if (typeof query[key] === 'string' && !/^Mixed|Reference|Object$/.test(type) && ObjectID.isValid(query[key]) && query[key].match(/^[a-fA-F0-9]{24}$/)) { + query[key] = ObjectID.createFromHexString(query[key]) + } + }) + + return query +} + function makeCaseInsensitive (obj, schema) { var newObj = _.clone(obj) diff --git a/package.json b/package.json index 2a9cc101..de364e75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dadi/api", - "version": "3.0.0", + "version": "2.2.9", "main": "main.js", "scripts": { "create-client": "cd ../../.. && node ./node_modules/@dadi/api/utils/create-client.js", @@ -48,7 +48,8 @@ "streamifier": "^0.1.1", "underscore": "1.8.3", "underscore-contrib": "^0.3.0", - "validator": "7.0.0" + "validator": "7.0.0", + "vary": "^1.1.2" }, "devDependencies": { "@commitlint/cli": "~4.1.1", diff --git a/test/acceptance/auth.js b/test/acceptance/auth.js index e8443037..026ccadd 100755 --- a/test/acceptance/auth.js +++ b/test/acceptance/auth.js @@ -70,22 +70,6 @@ describe('Authentication', function () { }) }) - it('should not issue token if creds contain query keywords', function (done) { - var client = request('http://' + config.get('server.host') + ':' + config.get('server.port')) - - client - .post(tokenRoute) - .send({ - clientId: {'$ne': null}, - secret: {'$ne': null} - }) - .expect(401, function (err, res) { - res.headers['www-authenticate'].should.exist - res.headers['www-authenticate'].should.eql('Bearer, error="invalid_credentials", error_description="Invalid credentials supplied"') - done() - }) - }) - it('should allow requests containing token', function (done) { help.getBearerToken(function (err, token) { var client = request('http://' + config.get('server.host') + ':' + config.get('server.port')) @@ -562,60 +546,4 @@ describe('Authentication', function () { }, 300) }) }) - - it('should allow an OPTIONS request for the token route', function (done) { - var oldCors = config.get('cors') - config.set('cors', true) - - var _done = function (err) { - config.set('cors', oldCors) - done(err) - } - - help.getBearerToken(function (err, token) { - var client = request('http://' + config.get('server.host') + ':' + config.get('server.port')) - - client - .options('/token') - .set('Origin', 'http://example.com') - .set('Access-Control-Request-Method', 'GET') - .set('Access-Control-Request-Headers', 'X-Requested-With') - .expect('content-type', 'application/json') - .expect('access-control-allow-origin', '*') - .expect('Access-Control-Allow-Methods', 'POST, OPTIONS') - .expect(200) - .end(function (err, res) { - if (err) return _done(err) - _done() - }) - }) - }) - - it('should contain the correct CORS headers when cors = true', function (done) { - var oldCors = config.get('cors') - config.set('cors', true) - - var _done = function (err) { - config.set('cors', oldCors) - done(err) - } - - help.getBearerToken(function (err, token) { - var client = request('http://' + config.get('server.host') + ':' + config.get('server.port')) - - client - .options('/vtest/testdb/test-schema') - .set('Origin', 'http://example.com') - .set('Access-Control-Request-Method', 'GET') - .set('Access-Control-Request-Headers', 'X-Requested-With') - .set('Authorization', 'Bearer ' + token) - .expect('content-type', 'application/json') - .expect('access-control-allow-origin', '*') - .expect(200) - .end(function (err, res) { - if (err) return _done(err) - _done() - }) - }) - }) }) diff --git a/test/acceptance/cors.js b/test/acceptance/cors.js new file mode 100644 index 00000000..7aca06c6 --- /dev/null +++ b/test/acceptance/cors.js @@ -0,0 +1,94 @@ +const app = require(__dirname + '/../../dadi/lib/') +const config = require(__dirname + '/../../config') +const help = require(__dirname + '/help') +const request = require('supertest') + +const ORIGINAL_CORS = config.get('cors') + +describe('CORS', () => { + let client + + before(function (done) { + help.createClient(null, function () { + app.start(function (err) { + if (err) return done(err) + + setTimeout(function () { + done() + }, 500) + }) + }) + }) + + after(function (done) { + help.removeTestClients(function () { + app.stop(done) + }) + }) + + before(() => { + config.set('cors', true) + }) + + after(() => { + config.set('cors', ORIGINAL_CORS) + }) + + beforeEach(() => { + client = request('http://' + config.get('server.host') + ':' + config.get('server.port')) + }) + + it('responds to OPTIONS requests with a 204', done => { + client + .options('/') + .expect(204) + .end(done) + }) + + it('reflects the Origin header back to the client for OPTIONS requests', done => { + client + .options('/') + .set('Origin', 'http://orig.in') + .expect('Access-Control-Allow-Origin', 'http://orig.in') + .end(done) + }) + + it('pemits all HTTP methods for OPTIONS requests', done => { + client + .options('/') + .expect('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE') + .end(done) + }) + + it('varies on Origin for OPTIONS requests with an Origin', done => { + client + .options('/') + .set('Origin', 'http://orig.in') + .expect('Vary', 'Origin') + .end(done) + }) + + it('permits any requested headers for OPTIONS requests', done => { + client + .options('/') + .set('Access-Control-Request-Headers', 'authorization,content-type') + .expect('Access-Control-Allow-Headers', 'authorization,content-type') + .end(done) + }) + + it('varies on Access-Control-Request-Headers for OPTIONS requests with Access-Control-Request-Headers', done => { + client + .options('/') + .set('Access-Control-Request-Headers', 'authorization,content-type') + .expect('Vary', 'Access-Control-Request-Headers') + .end(done) + }) + + it('reflects the Origin header back to the client for all other requests', done => { + client + .get('/') + .set('Origin', 'http://orig.in') + .expect('Access-Control-Allow-Origin', 'http://orig.in') + .end(done) + }) +})