diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..c13c5f6 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015"] +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..6a29160 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,75 @@ +--- +parser: babel-eslint + +plugins: + - mocha + +env: + es6: true + amd: true + node: true + mocha: true + browser: true + + +rules: + block-scoped-var: 2 + camelcase: [ 2, { properties: never } ] + comma-dangle: 0 + comma-style: [ 2, last ] + consistent-return: 0 + curly: [ 2, multi-line ] + dot-location: [ 2, property ] + dot-notation: [ 2, { allowKeywords: true } ] + eqeqeq: [ 2, allow-null ] + guard-for-in: 2 + indent: [ 2, 2, { SwitchCase: 1 } ] + key-spacing: [ 0, { align: value } ] + max-len: [ 2, 140, 2, { ignoreComments: true, ignoreUrls: true } ] + new-cap: [ 2, { capIsNewExceptions: [ Private ] } ] + no-bitwise: 0 + no-caller: 2 + no-cond-assign: 0 + no-debugger: 2 + no-empty: 2 + no-eval: 2 + no-extend-native: 2 + no-extra-parens: 0 + no-irregular-whitespace: 2 + no-iterator: 2 + no-loop-func: 2 + no-multi-spaces: 0 + no-multi-str: 2 + no-nested-ternary: 2 + no-new: 0 + no-path-concat: 0 + no-proto: 2 + no-return-assign: 0 + no-script-url: 2 + no-sequences: 2 + no-shadow: 0 + no-trailing-spaces: 2 + no-undef: 2 + no-underscore-dangle: 0 + no-unused-expressions: 0 + no-unused-vars: 0 + no-use-before-define: [ 2, nofunc ] + no-with: 2 + one-var: [ 2, never ] + quotes: [ 2, single ] + semi-spacing: [ 2, { before: false, after: true } ] + semi: [ 2, always ] + space-after-keywords: [ 2, always ] + space-before-blocks: [ 2, always ] + space-before-function-paren: [ 2, { anonymous: always, named: never } ] + space-in-parens: [ 2, never ] + space-infix-ops: [ 2, { int32Hint: false } ] + space-return-throw-case: [ 2 ] + space-unary-ops: [ 2 ] + strict: [ 2, never ] + valid-typeof: 2 + wrap-iife: [ 2, outside ] + yoda: 0 + + mocha/no-exclusive-tests: 2 + mocha/handle-done-callback: 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c635dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +target/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..554472b --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +Copyright 2016 FUJITSU LIMITED + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License +is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +or implied. See the License for the specific language governing permissions and limitations under +the License. diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..bd4f286 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,143 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +var babel = require('babel-register')({ + presets: ['es2015'] +}); + +var gulp = require('gulp'); +var path = require('path'); +var mkdirp = require('mkdirp'); +var Rsync = require('rsync'); +var Promise = require('bluebird'); +var eslint = require('gulp-eslint'); +var rimraf = require('rimraf'); +var tar = require('gulp-tar'); +var gzip = require('gulp-gzip'); +var fs = require('fs'); +var mocha = require('gulp-mocha'); + +var pkg = require('./package.json'); +var packageName = pkg.name + '-' + pkg.version; + +// relative location of Kibana install +var pathToKibana = '../kibana'; + +var buildDir = path.resolve(__dirname, 'build'); +var targetDir = path.resolve(__dirname, 'target'); +var buildTarget = path.resolve(buildDir, pkg.name); +var kibanaPluginDir = path.resolve(__dirname, pathToKibana, 'installedPlugins', pkg.name); + +var exclude = [ + '.git', + '.idea', + 'gulpfile.js', + '.babelrc', + '.gitignore', + '.eslintrc', + '__tests__' +]; + +Object.keys(pkg.devDependencies).forEach(function (name) { + exclude.push(path.join('node_modules', name)); +}); + +function syncPluginTo(dest, done) { + mkdirp(dest, function (err) { + if (err) return done(err); + + var source = path.resolve(__dirname) + '/'; + var rsync = new Rsync(); + + rsync + .source(source) + .destination(dest) + .flags('uav') + .recursive(true) + .set('delete') + .exclude(exclude) + .output(function (data) { + process.stdout.write(data.toString('utf8')); + }); + + rsync.execute(function (err) { + if (err) { + console.log(err); + return done(err); + } + done(); + }); + }); +} + +gulp.task('sync', ['lint'], function (done) { + syncPluginTo(kibanaPluginDir, done); +}); + +gulp.task('lint', function () { + var filePaths = [ + 'gulpfile.js', + 'server/**/*.js', + 'public/**/*.js', + 'public/**/*.jsx' + ]; + + return gulp.src(filePaths) + // eslint() attaches the lint output to the eslint property + // of the file object so it can be used by other modules. + .pipe(eslint()) + // eslint.format() outputs the lint results to the console. + // Alternatively use eslint.formatEach() (see Docs). + .pipe(eslint.formatEach()) + // To have the process exit with an error code (1) on + // lint error, return the stream and pipe to failOnError last. + .pipe(eslint.failOnError()); +}); + +gulp.task('test', function () { + return gulp.src(['server/**/*.spec.js']) + .pipe(mocha({ + compilers: { + js: babel + } + })); +}); + +gulp.task('clean', function (done) { + Promise.each([buildDir, targetDir], function (dir) { + return new Promise(function (resolve, reject) { + rimraf(dir, function (err) { + if (err) return reject(err); + resolve(); + }); + }); + }).nodeify(done); +}); + +gulp.task('build', ['clean'], function (done) { + syncPluginTo(buildTarget, done); +}); + +gulp.task('package', ['build'], function () { + return gulp.src(path.join(buildDir, '**', '*')) + .pipe(tar(packageName + '.tar')) + .pipe(gzip()) + .pipe(gulp.dest(targetDir)); +}); + +gulp.task('dev', ['sync'], function () { + gulp.watch( + ['package.json', 'index.js', 'public/**/*', 'server/**/*'], + ['sync']); +}); diff --git a/index.js b/index.js new file mode 100644 index 0000000..47c68fe --- /dev/null +++ b/index.js @@ -0,0 +1,59 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +module.exports = (kibana) => { + + const session = require('./server/session'); + const proxy = require('./server/proxy'); + const healthCheck = require('./server/healthcheck'); + + return new kibana.Plugin({ + require: ['elasticsearch'], + config : config, + init : init + }); + + function config(Joi) { + + const cookie = Joi.object({ + password : Joi.string() + .min(16) + .default(require('crypto').randomBytes(16).toString('hex')), + isSecure : Joi.boolean() + .default(false), + ignoreErrors: Joi.boolean() + .default(true), + expiresIn : Joi.number() + .positive() + .integer() + .default(24 * 60 * 60 * 1000) // 1 day + }).default(); + + return Joi.object({ + enabled: Joi.boolean().default(true), + url : Joi.string() + .uri({scheme: ['http', 'https']}) + .required(), + port : Joi.number().required(), + cookie : cookie + }).default(); + } + + function init(server) { + session(server); + proxy(server); + healthCheck(this, server).start(); + } + +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a7af291 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "fts-keystone", + "version": "0.0.1", + "description": "Keystone authentication support for Kibana 4.4.x", + "author": "Fujitsu Enabling Software Technology GmbH", + "licenses": "Apache-2.0", + "keywords": [ + "kibana", + "authentication", + "keystone", + "plugin" + ], + "scripts": { + "start": "gulp dev", + "build": "gulp build", + "package": "gulp package", + "test": "gulp test" + }, + "engines": { + "node": "0.12.9", + "npm": "2.14.3" + }, + "main": "gulpfile.js", + "dependencies": { + "yar": "^4.2.0", + "keystone-v3-client": "^0.0.7" + }, + "repository": { + "type": "git", + "url": "http://github.com/FujitsuEnablingSoftwareTechnologyGmbH/fts-keystone.git" + }, + "devDependencies": { + "babel-eslint": "^4.1.8", + "babel-preset-es2015": "^6.3.13", + "babel-register": "^6.4.3", + "bluebird": "^3.2.1", + "boom": "^2.8.0", + "chai": "^3.5.0", + "eslint-plugin-mocha": "^1.1.0", + "gulp": "^3.9.0", + "gulp-eslint": "^1.1.1", + "gulp-gzip": "^1.2.0", + "gulp-mocha": "^2.2.0", + "gulp-tar": "^1.8.0", + "gulp-util": "^3.0.7", + "lodash": "^4.2.1", + "mkdirp": "^0.5.1", + "proxyquire": "^1.7.4", + "rimraf": "^2.5.1", + "rsync": "^0.4.0", + "sinon": "^1.17.3" + } +} diff --git a/server/__tests__/healthcheck.spec.js b/server/__tests__/healthcheck.spec.js new file mode 100644 index 0000000..1da9106 --- /dev/null +++ b/server/__tests__/healthcheck.spec.js @@ -0,0 +1,222 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +const sinon = require('sinon'); +const chai = require('chai'); +const proxyRequire = require('proxyquire'); + +describe('plugins/fts-keystone', ()=> { + describe('healthcheck', ()=> { + + const keystoneUrl = 'http://localhost'; // mocking http + const keystonePort = 9000; + + let healthcheck; // placeholder for the require healthcheck + + let plugin; + let configGet; + let server; + let clock; + + before(function () { + clock = sinon.useFakeTimers(); + }); + after(function () { + clock.restore(); + }); + + beforeEach(function () { + plugin = { + name : 'fts-keystone', + status: { + red : sinon.stub(), + green : sinon.stub(), + yellow: sinon.stub() + } + }; + + configGet = sinon.stub(); + configGet.withArgs('fts-keystone.url').returns(keystoneUrl); + configGet.withArgs('fts-keystone.port').returns(keystonePort); + + server = { + log : sinon.stub(), + config: function () { + return { + get: configGet + }; + } + }; + + }); + + it('should set status to green if keystone available', (done)=> { + let expectedCode = 200; + let healthcheck = proxyRequire('../healthcheck', { + 'http': { + request: (_, callback)=> { + return { + end: () => { + let res = { + statusCode: expectedCode + }; + callback(res); + }, + on : sinon.stub() + }; + } + } + }); + let check = healthcheck(plugin, server); + + check + .run() + .then((code) => { + chai.expect(expectedCode).to.be.equal(code); + chai.expect(plugin.status.green.calledWith('Ready')).to.be.ok; + }) + .finally(done); + + }); + + it('should set status to red if keystone not available', (done) => { + let expectedCode = 500; + let healthcheck = proxyRequire('../healthcheck', { + 'http': { + request: (_, callback)=> { + return { + end: () => { + let res = { + statusCode: expectedCode + }; + callback(res); + }, + on : sinon.stub() + }; + } + } + }); + let check = healthcheck(plugin, server); + + check + .run() + .catch((code) => { + chai.expect(expectedCode).to.be.equal(code); + chai.expect(plugin.status.red.calledWith('Unavailable')).to.be.ok; + }) + .finally(done); + + }); + + it('should set status to red if available but cannot communicate', (done)=> { + let errorListener; + let healthcheck = proxyRequire('../healthcheck', { + 'http': { + request: ()=> { + return { + on : (_, listener)=> { + errorListener = sinon.spy(listener); + }, + end: ()=> { + errorListener(new Error('test')); + } + }; + } + } + }); + let check = healthcheck(plugin, server); + + check + .run() + .catch((error)=> { + let msg = 'Unavailable: Failed to communicate with Keystone'; + chai.expect(errorListener).to.be.ok; + chai.expect(errorListener.calledOnce).to.be.ok; + chai.expect(plugin.status.red.calledWith(msg)).to.be.ok; + + chai.expect(error.message).to.be.equal('test'); + }) + .done(done); + + }); + + it('should run check in period `10000`', ()=> { + let healthcheck = proxyRequire('../healthcheck', { + 'http': { + request: sinon.stub().returns({ + end: sinon.stub(), + on : sinon.stub() + }) + } + }); + + let runChecks = 3; + let timeout = 10000; + + let check = healthcheck(plugin, server); + sinon.spy(check, 'run'); + + // first call + chai.expect(check.isRunning()).to.be.eq(false); + check.start(); + validateFirstCall(); + + // next calls + for (let it = 0; it < runChecks; it++) { + validateNextCallWithTick(it); + } + + function validateFirstCall() { + clock.tick(1); // first call is immediate + chai.expect(check.run.calledOnce).to.be.ok; + chai.expect(check.isRunning()).to.be.eq(true); + } + + function validateNextCallWithTick(it) { + // should be called once for the sake of first call + chai.assert.equal(check.run.callCount, it + 1); + + // run check again + check.start(); + + // assert that tick did not kick in + chai.assert.equal(check.run.callCount, it + 1); + + // kick it in + clock.tick(timeout); + + // and we have another call + chai.expect(check.run.callCount).to.be.eq(it + 2); + } + }); + + it('should return false from stop if not run before', ()=> { + let healthcheck = proxyRequire('../healthcheck', { + 'http': { + request: sinon.stub().returns({ + end: sinon.stub(), + on : sinon.stub() + }) + } + }); + + let check = healthcheck(plugin, server); + sinon.spy(check, 'run'); + + chai.expect(check.stop()).to.be.eq(false); + chai.expect(check.run.called).to.be.eq(false); + }); + + }); +}); diff --git a/server/__tests__/proxy.spec.js b/server/__tests__/proxy.spec.js new file mode 100644 index 0000000..ef07373 --- /dev/null +++ b/server/__tests__/proxy.spec.js @@ -0,0 +1,174 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +const proxyRequire = require('proxyquire'); +const Promise = require('bluebird'); +const sinon = require('sinon'); +const chai = require('chai'); + +describe('plugins/fts-keystone', ()=> { + describe('proxy', ()=> { + describe('proxy_check', ()=> { + + const keystoneUrl = 'http://localhost'; // mocking http + const keystonePort = 9000; + + let server; + let configGet; + + beforeEach(()=> { + configGet = sinon.stub(); + configGet.withArgs('fts-keystone.url').returns(keystoneUrl); + configGet.withArgs('fts-keystone.port').returns(keystonePort); + + server = { + log : sinon.stub(), + config: function () { + return { + get: configGet + }; + } + }; + }); + + it('should do nothing if not /elasticsearch call', ()=> { + let checkSpy = sinon.spy(); + let retrieveTokenSpy = sinon.spy(); + let proxy = proxyRequire('../proxy/proxy', { + 'keystone-v3-client/lib/keystone/tokens': () => { + return {check: checkSpy}; + }, + './retrieveToken' : retrieveTokenSpy + })(server); + let request = { + url: { + path: '/bundles/styles.css' + } + }; + let reply = { + 'continue': sinon.spy() + }; + + proxy(request, reply); + + chai.expect(reply.continue.calledOnce).to.be.ok; + chai.expect(checkSpy.called).to.not.be.ok; + chai.expect(retrieveTokenSpy.called).to.not.be.ok; + }); + + it('should authenticate with keystone', (done)=> { + + let token = '1234567890'; + let checkStub = sinon.stub().returns(Promise.resolve()); + let retrieveTokenStub = sinon.stub().returns(token); + + let proxy = proxyRequire('../proxy/proxy', { + 'keystone-v3-client/lib/keystone/tokens': () => { + return {check: checkStub}; + }, + './retrieveToken' : retrieveTokenStub + })(server); + let request = { + session: { + 'get' : sinon.stub(), + 'set' : sinon.stub() + }, + url : { + path: '/elasticsearch/.kibana' + } + }; + + let reply = { + 'continue': sinon.spy() + }; + let replyCall; + + proxy(request, reply) + .finally(verifyStubs) + .done(done); + + function verifyStubs() { + chai.expect(reply.continue.calledOnce).to.be.ok; + replyCall = reply.continue.firstCall.args; + + chai.expect(replyCall).to.be.empty; + + // other stubs + chai.expect(checkStub.calledOnce).to.be.ok; + chai.expect(checkStub.calledWithExactly({ + headers: { + 'X-Auth-Token' : token, + 'X-Subject-Token': token + } + })).to.be.ok; + + chai.expect(retrieveTokenStub.calledOnce).to.be.ok; + chai.expect(retrieveTokenStub.calledWithExactly(server, request)) + .to.be.ok; + } + }); + + it('should not authenticate with keystone', (done)=> { + let token = '1234567890'; + let checkStub = sinon.stub().returns(Promise.reject({ + statusCode: 666 + })); + let retrieveTokenStub = sinon.stub().returns(token); + let proxy = proxyRequire('../proxy/proxy', { + 'keystone-v3-client/lib/keystone/tokens': () => { + return {check: checkStub}; + }, + './retrieveToken' : retrieveTokenStub + })(server); + let request = { + session: { + 'get' : sinon.stub(), + 'set' : sinon.stub() + }, + url : { + path: '/elasticsearch/.kibana' + } + }; + let reply = sinon.spy(); + let replyCall; + + proxy(request, reply) + .finally(verifyStubs) + .done(done); + + function verifyStubs() { + chai.expect(reply.calledOnce).to.be.ok; + replyCall = reply.firstCall.args[0]; + + chai.expect(replyCall.isBoom).to.be.ok; + + // other stubs + chai.expect(checkStub.calledOnce).to.be.ok; + chai.expect(checkStub.calledWithExactly({ + headers: { + 'X-Auth-Token' : token, + 'X-Subject-Token': token + } + })).to.be.ok; + + chai.expect(retrieveTokenStub.calledOnce).to.be.ok; + chai.expect(retrieveTokenStub.calledWithExactly(server, request)) + .to.be.ok; + } + + }); + + }); + }); +}); diff --git a/server/__tests__/retrieveToken.spec.js b/server/__tests__/retrieveToken.spec.js new file mode 100644 index 0000000..03bb6b8 --- /dev/null +++ b/server/__tests__/retrieveToken.spec.js @@ -0,0 +1,172 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +const sinon = require('sinon'); +const chai = require('chai'); + +const retrieveToken = require('../proxy/retrieveToken'); + +describe('plugins/fts-keystone', ()=> { + describe('proxy', ()=> { + describe('retrieveToken', ()=> { + + let server; + + beforeEach(()=> { + server = { + log: sinon.stub() + }; + }); + + it('should return isBoom if session not available', ()=> { + let request = {}; + let errMsg = /Session support is missing/; + + chai.expect(()=> { + retrieveToken(server, request); + }).to.throw(errMsg); + + request = { + session: undefined + }; + chai.expect(()=> { + retrieveToken(server, request); + }).to.throw(errMsg); + + request = { + session: null + }; + chai.expect(()=> { + retrieveToken(server, request); + }).to.throw(errMsg); + }); + + it('should Boom with unauthorized if token not in header or session', function () { + let expectedMsg = 'You\'re not logged into the OpenStack. Please login via Horizon Dashboard'; + let request = { + session: { + 'get': sinon + .stub() + .withArgs('keystone_token') + .returns(undefined) + }, + headers: {} + }; + + let result = retrieveToken(server, request); + chai.expect(result.isBoom).to.be.true; + chai.expect(result.output.payload.message).to.be.eq(expectedMsg); + chai.expect(result.output.statusCode).to.be.eq(401); + }); + + it('should use session token if requested does not have it', () => { + let expectedToken = 'SOME_RANDOM_TOKEN'; + let yar = { + 'set': sinon + .spy(), + 'get': sinon + .stub() + .withArgs('keystone_token') + .returns(expectedToken) + }; + let request = { + session: yar, + headers: {} + }; + let token; + + token = retrieveToken(server, request); + chai.expect(token).not.to.be.undefined; + chai.expect(token).to.be.eql(expectedToken); + + chai.expect( + yar.get.calledOnce + ).to.be.ok; + chai.expect( + yar.set.calledOnce + ).not.to.be.ok; + chai.expect( + yar.set.calledWithExactly('keystone_token', expectedToken) + ).not.to.be.ok; + }); + + it('should set token in session if not there and request has it', () => { + let expectedToken = 'SOME_RANDOM_TOKEN'; + let yar = { + 'set': sinon + .spy(), + 'get': sinon + .stub() + .withArgs('keystone_token') + .returns(undefined) + }; + let request = { + session: yar, + headers: { + 'x-auth-token': expectedToken + } + }; + let token; + + token = retrieveToken(server, request); + chai.expect(token).to.not.be.undefined; + chai.expect(token).to.be.eql(expectedToken); + + chai.expect( + yar.get.calledOnce + ).to.be.ok; + chai.expect( + yar.set.calledOnce + ).to.be.ok; + chai.expect( + yar.set.calledWithExactly('keystone_token', expectedToken) + ).to.be.ok; + }); + + it('should update token in session if request\'s token is different', ()=> { + let expectedToken = 'SOME_RANDOM_TOKEN'; + let headers = { + 'x-auth-token': expectedToken + }; + let yar = { + 'get': sinon + .stub() + .withArgs('keystone_token') + .returns('SOME_OLD_TOKEN'), + 'set': sinon + .spy() + }; + let token; + let request = { + session: yar, + headers: headers + }; + + token = retrieveToken(server, request); + chai.expect(token).to.not.be.undefined; + chai.expect(token).to.be.eql(expectedToken); + + chai.expect( + yar.get.calledOnce + ).to.be.ok; + chai.expect( + yar.set.calledOnce + ).to.be.ok; + chai.expect( + yar.set.calledWithExactly('keystone_token', expectedToken) + ).to.be.ok; + }); + }); + }); +}); diff --git a/server/__tests__/util.spec.js b/server/__tests__/util.spec.js new file mode 100644 index 0000000..79e877c --- /dev/null +++ b/server/__tests__/util.spec.js @@ -0,0 +1,36 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +const chai = require('chai'); +const util = require('../util'); + +describe('plugins/fts-keystone', ()=> { + describe('util', ()=> { + + const CHECK_STR = 'test.str'; + + it('should return true if starts with ok', ()=> { + chai.expect(util.startsWith(CHECK_STR, 'test')).to.be.ok; + }); + + it('should return false if does not start with', ()=> { + chai.expect(util.startsWith(CHECK_STR, 'str')).not.to.be.ok; + }); + + it('should return false if no prefixes supplied', ()=> { + chai.expect(util.startsWith(CHECK_STR)).not.to.be.ok; + }); + + }); +}); diff --git a/server/healthcheck/index.js b/server/healthcheck/index.js new file mode 100644 index 0000000..ade00be --- /dev/null +++ b/server/healthcheck/index.js @@ -0,0 +1,112 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +const Promise = require('bluebird'); +const url = require('url'); + +const util = require('../util/'); + +module.exports = function (plugin, server) { + let timeoutId; + + const config = server.config(); + const keystoneUrl = config.get('fts-keystone.url'); + const keystonePort = config.get('fts-keystone.port'); + const request = getRequest(); + const service = { + run : check, + start : start, + stop : stop, + isRunning: ()=> { + return !!timeoutId; + } + }; + + return service; + + function getRequest() { + let required; + if (util.startsWith(keystoneUrl, 'https')) { + required = require('https'); + } else { + required = require('http'); + } + return required.request; + } + + function check() { + + return new Promise((resolve, reject)=> { + + const req = request({ + hostname: getHostname(), + port : keystonePort, + method : 'HEAD' + }, (res)=> { + const statusCode = res.statusCode; + if (statusCode >= 400) { + plugin.status.red('Unavailable'); + reject(statusCode); + } else { + plugin.status.green('Ready'); + resolve(statusCode); + } + }); + req.on('error', (error)=> { + plugin.status.red('Unavailable: Failed to communicate with Keystone'); + server.log(['keystone', 'healthcheck', 'error'], `${error.message}`); + reject(error); + }); + + req.end(); + + }); + } + + function getHostname() { + return url.parse(keystoneUrl).hostname; + } + + function start() { + scheduleCheck(service.stop() ? 10000 : 1); + } + + function stop() { + if (!timeoutId) { + return false; + } + + clearTimeout(timeoutId); + timeoutId = undefined; + return true; + } + + function scheduleCheck(ms) { + if (timeoutId) { + return false; + } + + const currentId = setTimeout(function () { + service.run().finally(function () { + if (timeoutId === currentId) { + start(); + } + }); + }, ms); + timeoutId = currentId; + + return true; + } + +}; diff --git a/server/proxy/index.js b/server/proxy/index.js new file mode 100644 index 0000000..79abed2 --- /dev/null +++ b/server/proxy/index.js @@ -0,0 +1,25 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +const proxy = require('./proxy'); + +module.exports = function createProxy(server) { + server.ext( + 'onPreAuth', + proxy(server), + { + after: ['yar'] + } + ); +}; diff --git a/server/proxy/proxy.js b/server/proxy/proxy.js new file mode 100644 index 0000000..29d3f6a --- /dev/null +++ b/server/proxy/proxy.js @@ -0,0 +1,93 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +const Boom = require('boom'); +const retrieveToken = require('./retrieveToken'); +const TokensApi = require('keystone-v3-client/lib/keystone/tokens'); + +const util = require('../util/'); + +module.exports = function (server) { + const config = server.config(); + const tokensApi = new TokensApi({ + url: `${config.get('fts-keystone.url')}:${config.get('fts-keystone.port')}` + }); + + return (request, reply) => { + const requestPath = getRequestPath(request); + let token; + + if (shouldCallKeystone(requestPath)) { + server.log( + ['keystone', 'debug'], + `Call for ${requestPath} detected, authenticating with keystone` + ); + + token = retrieveToken(server, request); + if (token.isBoom) { + return reply(token); + } + + return tokensApi + .check({ + headers: { + 'X-Auth-Token' : token, + 'X-Subject-Token': token + } + }) + .then(onFulfilled, onFailed); + + } + + return reply.continue(); + + function onFulfilled() { + reply.continue(); + } + + function onFailed(error) { + + server.log( + ['keystone', 'error'], + `Failed to authenticate token ${token} with keystone, + error is ${error.statusCode}.` + ); + + if (error.statusCode === 401) { + request.session.clear('keystone_token'); + reply(Boom.forbidden( + ` + You\'re not logged in as a + user who\'s authenticated to access log information + ` + )); + } else { + reply(Boom.internal( + error.message || 'Unexpected error during Keystone communication', + {}, + error.statusCode + )); + } + } + + }; +}; + +function getRequestPath(request) { + return request.url.path; +} + +function shouldCallKeystone(path) { + return util.startsWith(path, '/elasticsearch'); +} diff --git a/server/proxy/retrieveToken.js b/server/proxy/retrieveToken.js new file mode 100644 index 0000000..af6ba14 --- /dev/null +++ b/server/proxy/retrieveToken.js @@ -0,0 +1,68 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +const Boom = require('boom'); + +/** @module */ +module.exports = retrieveToken; + +/** + * Retrieves token from the response header using key X-Keystone-Token. + * If token is found there following actions are taken: + * - if token is not in session, it is set there + * - if token is in session but it differs from the one in request's header, session's token is replaced with new one + * If token is not found in request following actions are taken: + * - if token is also not available in session, error is produced + * - if token is available in session it is used + * + * @param {object} server server object + * @param {object} request current request + * + * @returns {string} current token value + */ + +const HEADER_NAME = 'x-auth-token'; + +function retrieveToken(server, request) { + + if (!request.session || request.session === null) { + server.log(['keystone', 'error'], 'Session is not enabled'); + throw new Error('Session support is missing'); + } + + let tokenFromSession = request.session.get('keystone_token'); + let token = request.headers[HEADER_NAME]; + + if (!token && !tokenFromSession) { + server.log(['keystone', 'error'], + 'Token hasn\'t been located, looked in headers and session'); + return Boom.unauthorized( + 'You\'re not logged into the OpenStack. Please login via Horizon Dashboard' + ); + } + + if (!token && tokenFromSession) { + token = tokenFromSession; + server.log(['keystone', 'debug'], + 'Token lookup status: Found token in session' + ); + } else if ((token && !tokenFromSession) || (token !== tokenFromSession)) { + server.log(['keystone', 'debug'], + 'Token lookup status: Token located in header/session or token changed' + ); + request.session.set('keystone_token', token); + } + + return token; +} diff --git a/server/session/index.js b/server/session/index.js new file mode 100644 index 0000000..da04fb3 --- /dev/null +++ b/server/session/index.js @@ -0,0 +1,45 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +module.exports = function initSession(server) { + + const config = server.config(); + const registerOpts = { + register: require('yar'), + options : { + name : 'kibana_session', + storeBlank : false, + cache : { + expiresIn: config.get('fts-keystone.cookie.expiresIn') + }, + cookieOptions: { + password : config.get('fts-keystone.cookie.password'), + isSecure : config.get('fts-keystone.cookie.isSecure'), + ignoreErrors: config.get('fts-keystone.cookie.ignoreErrors'), + clearInvalid: true + } + } + }; + + const callback = (error) => { + if (!error) { + server.log(['session', 'debug'], 'Session registered'); + } else { + server.log(['session', 'error'], error); + throw error; + } + }; + + server.register(registerOpts, callback); +}; diff --git a/server/util/index.js b/server/util/index.js new file mode 100644 index 0000000..6bffcad --- /dev/null +++ b/server/util/index.js @@ -0,0 +1,27 @@ +/* + * Copyright 2016 FUJITSU LIMITED + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +module.exports = { + startsWith: startsWith +}; + +function startsWith(str) { + var prefixes = Array.prototype.slice.call(arguments, 1); + for (var i = 0; i < prefixes.length; ++i) { + if (str.lastIndexOf(prefixes[i], 0) === 0) { + return true; + } + } + return false; +}