From 6d1406c4847a83eee9add3858ec944501905c9f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Zasso?= Date: Thu, 1 Nov 2018 13:15:08 +0100 Subject: [PATCH] feat: add audit actions feature and audit login attempts Refs: https://github.com/cheminfo/rest-on-couch/issues/171 --- bin/rest-on-couch-server.js | 1 - src/audit/actions.js | 55 +++++++++++++++++++++++++++++++ src/config/default.js | 4 +++ src/connect.js | 6 ++-- src/initCouch.js | 34 +++++++++++++++++++ src/server/auth/couchdb/index.js | 10 ++++-- src/server/auth/facebook/index.js | 11 +++++-- src/server/auth/github/index.js | 7 ++-- src/server/auth/google/index.js | 8 +++-- src/server/auth/ldap/index.js | 6 +++- src/server/server.js | 17 +++++++--- src/util/load.js | 4 +-- 12 files changed, 142 insertions(+), 21 deletions(-) create mode 100644 src/audit/actions.js create mode 100644 src/initCouch.js diff --git a/bin/rest-on-couch-server.js b/bin/rest-on-couch-server.js index 3a46a15e..8c67530e 100755 --- a/bin/rest-on-couch-server.js +++ b/bin/rest-on-couch-server.js @@ -6,7 +6,6 @@ const program = require('commander'); const debug = require('../src/util/debug')('bin:server'); const server = require('../src/server/server'); -require('../src/util/load')(); program .option('-c --config ', 'Path to custom config file') diff --git a/src/audit/actions.js b/src/audit/actions.js new file mode 100644 index 00000000..2a16118b --- /dev/null +++ b/src/audit/actions.js @@ -0,0 +1,55 @@ +'use strict'; + +const config = require('../config/config').globalConfig; +const nanoPromise = require('../util/nanoPromise'); +const debug = require('../util/debug')('audit:actions'); +const { open } = require('../connect'); + +const auditEnabled = !!config.auditActions; + +let _globalNano = null; +let _db = null; + +async function ensureNano() { + const newGlobalNano = await open(); + if (_globalNano !== newGlobalNano) { + _db = newGlobalNano.db.use(config.auditActionsDb); + } + return _db; +} + +async function auditAction(action, username, ip, meta) { + if (!auditEnabled) return; + debug('logAction', action, username, ip); + validateString('action', action); + validateString('username', username); + validateString('ip', ip); + const doc = { + action, + username, + ip, + date: new Date().toISOString() + }; + if (meta) { + doc.meta = meta; + } + const db = await ensureNano(); + await nanoPromise.insertDocument(db, doc); +} + +async function auditLogin(username, success, provider, ctx) { + if (!auditEnabled) return; + const action = success ? 'login.success' : 'login.failed'; + await auditAction(action, username, ctx.ip, { provider }); +} + +function validateString(name, value) { + if (typeof value !== 'string') { + throw new TypeError(`${name} must be a string`); + } +} + +module.exports = { + auditAction, + auditLogin +}; diff --git a/src/config/default.js b/src/config/default.js index d423fd67..ddf148a8 100644 --- a/src/config/default.js +++ b/src/config/default.js @@ -39,6 +39,10 @@ module.exports = { }, entryUnicity: 'byOwner', // can be byOwner or global + // Options related to audit logs + auditActions: false, + auditActionsDb: 'roc-audit-actions', + // Options for Zenodo publication zenodo: false, zenodoSandbox: false, diff --git a/src/connect.js b/src/connect.js index 6c686a2f..f8f7689d 100644 --- a/src/connect.js +++ b/src/connect.js @@ -1,6 +1,6 @@ 'use strict'; -const nano = require('nano'); +const nanoLib = require('nano'); const agentkeepalive = require('agentkeepalive'); const config = require('./config/config').globalConfig; @@ -34,7 +34,7 @@ function open() { async function getGlobalNano() { debug.trace('renew CouchDB cookie'); if (config.url && config.username && config.password) { - let _nano = nano({ + let _nano = nanoLib({ url: config.url, requestDefaults: { agent: nanoAgent @@ -45,7 +45,7 @@ async function getGlobalNano() { config.username, config.password ); - return nano({ + return nanoLib({ url: config.url, cookie, requestDefaults: { diff --git a/src/initCouch.js b/src/initCouch.js new file mode 100644 index 00000000..cf118687 --- /dev/null +++ b/src/initCouch.js @@ -0,0 +1,34 @@ +'use strict'; + +const config = require('./config/config').globalConfig; +const { open } = require('./connect'); +const loadCouch = require('./util/load'); +const debug = require('./util/debug')('main:initCouch'); +const nanoPromise = require('./util/nanoPromise'); + +async function initCouch() { + const nano = await open(); + if (config.auditActions) { + await setupAuditActions(nano); + } + loadCouch(); +} + +async function setupAuditActions(nano) { + debug('setup audit actions'); + const auditActionsDb = config.auditActionsDb; + // Check if database is accessible + try { + const dbExists = await nanoPromise.getDatabase(nano, auditActionsDb); + if (!dbExists) { + throw new Error( + `audit actions database does not exist: ${auditActionsDb}` + ); + } + } catch (e) { + debug.error('failed to get audit actions database: %s', auditActionsDb); + throw e; + } +} + +module.exports = initCouch; diff --git a/src/server/auth/couchdb/index.js b/src/server/auth/couchdb/index.js index edb0edbd..96b3f8b2 100644 --- a/src/server/auth/couchdb/index.js +++ b/src/server/auth/couchdb/index.js @@ -2,9 +2,10 @@ const LocalStrategy = require('passport-local').Strategy; -const request = require('../../../util/requestPromise'); +const { auditLogin } = require('../../../audit/actions'); const couchUrl = require('../../../config/config').globalConfig.url; const isEmail = require('../../../util/isEmail'); +const request = require('../../../util/requestPromise'); const util = require('../../middleware/util'); const auth = require('../../middleware/auth'); @@ -20,9 +21,10 @@ exports.init = function (passport, router) { new LocalStrategy( { usernameField: 'username', - passwordField: 'password' + passwordField: 'password', + passReqToCallback: true }, - function (username, password, done) { + function (req, username, password, done) { (async function () { if (!isEmail(username)) { return done(null, false, 'username must be an email'); @@ -38,8 +40,10 @@ exports.init = function (passport, router) { res = JSON.parse(res.body); if (res.error) { + auditLogin(username, false, 'couchdb', req.ctx); return done(null, false, res.reason); } + auditLogin(username, true, 'couchdb', req.ctx); return done(null, { email: res.name, provider: 'local' diff --git a/src/server/auth/facebook/index.js b/src/server/auth/facebook/index.js index 6f6d2fff..4b4cdc0d 100644 --- a/src/server/auth/facebook/index.js +++ b/src/server/auth/facebook/index.js @@ -32,6 +32,8 @@ // } const FacebookStrategy = require('passport-facebook'); +const { auditLogin } = require('../../../audit/actions'); + exports.init = function (passport, router, config) { passport.use( new FacebookStrategy( @@ -39,12 +41,15 @@ exports.init = function (passport, router, config) { clientID: config.appId, clientSecret: config.appSecret, callbackURL: config.publicAddress + config.callbackURL, - enableProof: false + enableProof: false, + passReqToCallback: true }, - function (accessToken, refreshToken, profile, done) { + function (req, accessToken, refreshToken, profile, done) { + const email = profile._json.email; + auditLogin(email, true, 'facebook', req.ctx); done(null, { provider: 'facebook', - email: profile._json.email + email }); } ) diff --git a/src/server/auth/github/index.js b/src/server/auth/github/index.js index 790de424..6a1aa188 100644 --- a/src/server/auth/github/index.js +++ b/src/server/auth/github/index.js @@ -45,6 +45,7 @@ const GitHubStrategy = require('passport-github').Strategy; +const { auditLogin } = require('../../../audit/actions'); const request = require('../../../util/requestPromise'); exports.init = function (passport, router, config) { @@ -53,9 +54,10 @@ exports.init = function (passport, router, config) { { clientID: config.clientID, clientSecret: config.clientSecret, - callbackURL: config.publicAddress + config.callbackURL + callbackURL: config.publicAddress + config.callbackURL, + passReqToCallback: true }, - function (accessToken, refreshToken, profile, done) { + function (req, accessToken, refreshToken, profile, done) { // Get the user's email (async function () { const answer = await request({ @@ -71,6 +73,7 @@ exports.init = function (passport, router, config) { profile.email = answer[0].email; } if (email[0]) profile.email = email[0].email; + auditLogin(profile.email, true, 'github', req.ctx); done(null, { provider: 'github', email: profile.email diff --git a/src/server/auth/google/index.js b/src/server/auth/google/index.js index 78c7ee0a..45fd5acf 100644 --- a/src/server/auth/google/index.js +++ b/src/server/auth/google/index.js @@ -32,6 +32,8 @@ const GoogleStrategy = require('passport-google-oauth20').Strategy; +const { auditLogin } = require('../../../audit/actions'); + exports.init = function (passport, router, config, mainConfig) { // todo we should be able to put a relative callbackURL (add proxy: true) but there is a bug in passport-oauth2 // with the generation of redirect_url. see https://github.com/jaredhanson/passport-oauth2/blob/master/lib/strategy.js#L136 @@ -40,13 +42,15 @@ exports.init = function (passport, router, config, mainConfig) { { clientID: config.clientID, clientSecret: config.clientSecret, - callbackURL: `${mainConfig.publicAddress}/auth/login/google/callback` + callbackURL: `${mainConfig.publicAddress}/auth/login/google/callback`, + passReqToCallback: true }, - function (accessToken, refreshToken, profile, done) { + function (req, accessToken, refreshToken, profile, done) { const email = profile.emails.find((email) => email.type === 'account'); if (!email) { return done(null, false, { message: 'No account email' }); } else { + auditLogin(email.value, true, 'google', req.ctx); done(null, { provider: 'google', email: email.value diff --git a/src/server/auth/ldap/index.js b/src/server/auth/ldap/index.js index b897b8c2..1908b4f5 100644 --- a/src/server/auth/ldap/index.js +++ b/src/server/auth/ldap/index.js @@ -3,12 +3,14 @@ // Doc: https://github.com/vesse/passport-ldapauth#readme const LdapStrategy = require('passport-ldapauth'); +const { auditLogin } = require('../../../audit/actions'); const util = require('../../middleware/util'); const auth = require('../../middleware/auth'); exports.init = function (passport, router, config) { + const strategyConfig = Object.assign({ passReqToCallback: true }, config); passport.use( - new LdapStrategy(config, function (user, done) { + new LdapStrategy(strategyConfig, function (req, user, done) { const data = { provider: 'ldap', email: user.mail, @@ -26,11 +28,13 @@ exports.init = function (passport, router, config) { return Promise.resolve(config.getUserInfo(user)).then( (info) => { data.info = info; + auditLogin(data.email, true, 'ldap', req.ctx); done(null, data); }, (err) => done(err) ); } else { + auditLogin(data.email, true, 'ldap', req.ctx); done(null, data); return true; } diff --git a/src/server/server.js b/src/server/server.js index 7bbd9bc7..0c756c20 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -15,6 +15,7 @@ const hbs = require('koa-hbs'); const config = require('../config/config').globalConfig; const debug = require('../util/debug')('server'); +const initCouch = require('../initCouch'); const api = require('./routes/api'); const auth = require('./routes/auth'); @@ -145,10 +146,18 @@ app.use(api.routes()); module.exports.start = function () { if (_started) return _started; - _started = new Promise(function (resolve) { - http.createServer(app.callback()).listen(config.port, function () { - debug.warn(`running on localhost: ${config.port}`); - resolve(app); + _started = new Promise(function (resolve, reject) { + initCouch().then(() => { + http.createServer(app.callback()).listen(config.port, function () { + debug.warn(`running on localhost: ${config.port}`); + resolve(app); + }); + }, (e) => { + reject(e); + process.nextTick(() => { + debug.error('initialization failed'); + throw e; + }); }); }); return _started; diff --git a/src/util/load.js b/src/util/load.js index f843d8a2..9108ba10 100644 --- a/src/util/load.js +++ b/src/util/load.js @@ -11,8 +11,8 @@ const debug = require('./debug')('util:load'); var loaded = false; -module.exports = function () { - debug.trace('preload databases that have a confguration file'); +module.exports = function loadCouch() { + debug.trace('preload databases that have a configuration file'); const homeDir = config.globalConfig.homeDir; if (!homeDir) return; if (loaded) return;