From fcd5df1ad357a8e890de2e5ad85929d4246c6217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Tr=C4=99bski?= Date: Fri, 5 Feb 2016 14:38:37 +0100 Subject: [PATCH] Initial commit Kibana-Keystone plugin with keystone authentication. Change-Id: I1fe1e5b028a753e8e22af4b6a31305d225f0a914 --- .babelrc | 3 + .eslintrc | 75 +++++++++ .gitignore | 3 + LICENSE | 11 ++ gulpfile.js | 143 ++++++++++++++++ index.js | 59 +++++++ package.json | 53 ++++++ server/__tests__/healthcheck.spec.js | 222 +++++++++++++++++++++++++ server/__tests__/proxy.spec.js | 174 +++++++++++++++++++ server/__tests__/retrieveToken.spec.js | 172 +++++++++++++++++++ server/__tests__/util.spec.js | 36 ++++ server/healthcheck/index.js | 112 +++++++++++++ server/proxy/index.js | 25 +++ server/proxy/proxy.js | 93 +++++++++++ server/proxy/retrieveToken.js | 68 ++++++++ server/session/index.js | 45 +++++ server/util/index.js | 27 +++ 17 files changed, 1321 insertions(+) create mode 100644 .babelrc create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 gulpfile.js create mode 100644 index.js create mode 100644 package.json create mode 100644 server/__tests__/healthcheck.spec.js create mode 100644 server/__tests__/proxy.spec.js create mode 100644 server/__tests__/retrieveToken.spec.js create mode 100644 server/__tests__/util.spec.js create mode 100644 server/healthcheck/index.js create mode 100644 server/proxy/index.js create mode 100644 server/proxy/proxy.js create mode 100644 server/proxy/retrieveToken.js create mode 100644 server/session/index.js create mode 100644 server/util/index.js 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; +}