diff --git a/CHANGES b/CHANGES index 212262aba..4919574d1 100644 --- a/CHANGES +++ b/CHANGES @@ -1778,4 +1778,5 @@ [4.5.0] * Show backup disk usage in graphs +* Add per-user app passwords diff --git a/migrations/20200131232251-appPasswords-create-table.js b/migrations/20200131232251-appPasswords-create-table.js new file mode 100644 index 000000000..f4ff7c560 --- /dev/null +++ b/migrations/20200131232251-appPasswords-create-table.js @@ -0,0 +1,26 @@ +'use strict'; + +exports.up = function(db, callback) { + var cmd = 'CREATE TABLE appPasswords(' + + 'id VARCHAR(128) NOT NULL UNIQUE,' + + 'name VARCHAR(128) NOT NULL,' + + 'userId VARCHAR(128) NOT NULL,' + + 'identifier VARCHAR(128) NOT NULL,' + + 'hashedPassword VARCHAR(1024) NOT NULL,' + + 'creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,' + + 'FOREIGN KEY(userId) REFERENCES users(id),' + + 'UNIQUE (name, userId),' + + 'PRIMARY KEY (id)) CHARACTER SET utf8 COLLATE utf8_bin'; + + db.runSql(cmd, function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('DROP TABLE appPasswords', function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index 3d791ff21..edd1cecc2 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -232,4 +232,16 @@ CREATE TABLE IF NOT EXISTS notifications( PRIMARY KEY (id) ); +CREATE TABLE IF NOT EXISTS appPasswords( + id VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + userId VARCHAR(128) NOT NULL, + identifier VARCHAR(128) NOT NULL, // resourceId: app id or mail or webadmin + hashedPassword VARCHAR(1024) NOT NULL, + creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(userId) REFERENCES users(id), + UNIQUE (name, userId), + PRIMARY KEY (id) +); + CHARACTER SET utf8 COLLATE utf8_bin; diff --git a/src/appdb.js b/src/appdb.js index 6ff36b018..a741af9e2 100644 --- a/src/appdb.js +++ b/src/appdb.js @@ -349,12 +349,13 @@ function del(id, callback) { { query: 'DELETE FROM subdomains WHERE appId = ?', args: [ id ] }, { query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] }, { query: 'DELETE FROM appEnvVars WHERE appId = ?', args: [ id ] }, + { query: 'DELETE FROM appPasswords WHERE identifier = ?', args: [ id ] }, { query: 'DELETE FROM apps WHERE id = ?', args: [ id ] } ]; database.transaction(queries, function (error, results) { if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); - if (results[3].affectedRows !== 1) return callback(new BoxError(BoxError.NOT_FOUND, 'App not found')); + if (results[4].affectedRows !== 1) return callback(new BoxError(BoxError.NOT_FOUND, 'App not found')); callback(null); }); diff --git a/src/ldap.js b/src/ldap.js index f6a1c7eb9..99a988c94 100644 --- a/src/ldap.js +++ b/src/ldap.js @@ -420,7 +420,7 @@ function authenticateUser(req, res, next) { api = users.verifyWithUsername; } - api(commonName, req.credentials || '', function (error, user) { + api(commonName, req.credentials || '', req.app.id, function (error, user) { if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); @@ -466,7 +466,7 @@ function authenticateUserMailbox(req, res, next) { if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); - users.verify(mailbox.ownerId, req.credentials || '', function (error, result) { + users.verify(mailbox.ownerId, req.credentials || '', users.AP_MAIL, function (error, result) { if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); @@ -488,7 +488,7 @@ function authenticateSftp(req, res, next) { if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString())); // actual user bind - users.verifyWithUsername(parts[0], req.credentials, function (error) { + users.verifyWithUsername(parts[0], req.credentials, users.AP_SFTP, function (error) { if (error) return next(new ldap.InvalidCredentialsError(req.dn.toString())); debug('sftp auth: success'); @@ -577,7 +577,7 @@ function authenticateMailAddon(req, res, next) { if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); - users.verify(mailbox.ownerId, req.credentials || '', function (error, result) { + users.verify(mailbox.ownerId, req.credentials || '', users.AP_MAIL, function (error, result) { if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); diff --git a/src/routes/accesscontrol.js b/src/routes/accesscontrol.js index dc35d2b3d..db69d6522 100644 --- a/src/routes/accesscontrol.js +++ b/src/routes/accesscontrol.js @@ -61,7 +61,7 @@ function initialize(callback) { } if (username.indexOf('@') === -1) { - users.verifyWithUsername(username, password, function (error, result) { + users.verifyWithUsername(username, password, users.AP_WEBADMIN, function (error, result) { if (error && error.reason === BoxError.NOT_FOUND) return createAndVerifyUserIfNotExist(username, password, callback); if (error && error.reason === BoxError.INVALID_CREDENTIALS) return callback(null, false); if (error) return callback(error); @@ -69,7 +69,7 @@ function initialize(callback) { callback(null, result); }); } else { - users.verifyWithEmail(username, password, function (error, result) { + users.verifyWithEmail(username, password, users.AP_WEBADMIN, function (error, result) { if (error && error.reason === BoxError.NOT_FOUND) return createAndVerifyUserIfNotExist(username, password, callback); if (error && error.reason === BoxError.INVALID_CREDENTIALS) return callback(null, false); if (error) return callback(error); diff --git a/src/routes/apppasswords.js b/src/routes/apppasswords.js new file mode 100644 index 000000000..75bd8b24e --- /dev/null +++ b/src/routes/apppasswords.js @@ -0,0 +1,60 @@ +'use strict'; + +exports = module.exports = { + list: list, + get: get, + del: del, + add: add +}; + +var assert = require('assert'), + BoxError = require('../boxerror.js'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + users = require('../users.js'); + +function get(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + users.getAppPassword(req.params.id, function (error, result) { + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, result)); + }); +} + +function add(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.name !== 'string') return next(new HttpError(400, 'name must be string')); + if (typeof req.body.identifier !== 'string') return next(new HttpError(400, 'identifier must be string')); + + users.addAppPassword(req.user.id, req.body.identifier, req.body.name, function (error, result) { + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, result)); + }); +} + +function list(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + + users.getAppPasswords(req.user.id, function (error, result) { + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, result)); + }); +} + +function del(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + users.delAppPassword(req.params.id, function (error) { + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(204, {})); + }); +} diff --git a/src/routes/developer.js b/src/routes/developer.js index 7909710d8..724c5a229 100644 --- a/src/routes/developer.js +++ b/src/routes/developer.js @@ -17,7 +17,7 @@ function login(req, res, next) { var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || null; - if (!user.ghost && user.twoFactorAuthenticationEnabled) { + if (!user.ghost && !user.appPassword && user.twoFactorAuthenticationEnabled) { if (!req.body.totpToken) return next(new HttpError(401, 'A totpToken must be provided')); let verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: req.body.totpToken, window: 2 }); diff --git a/src/routes/index.js b/src/routes/index.js index 0aaa8fc73..71811e81c 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -2,6 +2,7 @@ exports = module.exports = { accesscontrol: require('./accesscontrol.js'), + appPasswords: require('./apppasswords.js'), apps: require('./apps.js'), appstore: require('./appstore.js'), backups: require('./backups.js'), diff --git a/src/routes/oauth2.js b/src/routes/oauth2.js index 7908578a6..1dc3c8349 100644 --- a/src/routes/oauth2.js +++ b/src/routes/oauth2.js @@ -257,7 +257,7 @@ function login(req, res) { })(req, res, function (error) { if (error) return res.redirect('/api/v1/session/login?' + failureQuery); // on some exception in the handlers - if (!req.user.ghost && req.user.twoFactorAuthenticationEnabled) { + if (!req.user.ghost && !req.user.appPassword && req.user.twoFactorAuthenticationEnabled) { if (!req.body.totpToken) { let failureQuery = querystring.stringify({ error: 'A 2FA token is required', returnTo: returnTo }); return res.redirect('/api/v1/session/login?' + failureQuery); diff --git a/src/routes/test/users-test.js b/src/routes/test/users-test.js index bbdbbec51..212320210 100644 --- a/src/routes/test/users-test.js +++ b/src/routes/test/users-test.js @@ -723,7 +723,7 @@ describe('Users API', function () { }); it('did change the user password', function (done) { - users.verify(user_0.id, 'bigenough', function (error) { + users.verify(user_0.id, 'bigenough', users.AP_WEBADMIN, function (error) { expect(error).to.be(null); done(); }); diff --git a/src/routes/users.js b/src/routes/users.js index 1766be104..2ab54380c 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -112,7 +112,7 @@ function verifyPassword(req, res, next) { if (typeof req.body.password !== 'string') return next(new HttpError(400, 'API call requires user password')); - users.verifyWithUsername(req.user.username, req.body.password, function (error) { + users.verifyWithUsername(req.user.username, req.body.password, users.AP_WEBADMIN, function (error) { if (error) return next(BoxError.toHttpError(error)); req.body.password = ''; // this will prevent logs from displaying plain text password diff --git a/src/server.js b/src/server.js index b6e2a1817..f0e0f6743 100644 --- a/src/server.js +++ b/src/server.js @@ -192,6 +192,11 @@ function initializeExpressSync() { router.post('/api/v1/profile/twofactorauthentication/enable', profileScope, routes.profile.enableTwoFactorAuthentication); router.post('/api/v1/profile/twofactorauthentication/disable', profileScope, routes.users.verifyPassword, routes.profile.disableTwoFactorAuthentication); + router.get ('/api/v1/app_passwords', profileScope, routes.appPasswords.list); + router.post('/api/v1/app_passwords', profileScope, routes.appPasswords.add); + router.get ('/api/v1/app_passwords/:id', profileScope, routes.appPasswords.get); + router.del ('/api/v1/app_passwords/:id', profileScope, routes.appPasswords.del); + // user routes router.get ('/api/v1/users', usersReadScope, routes.users.list); router.post('/api/v1/users', usersManageScope, routes.users.create); diff --git a/src/test/database-test.js b/src/test/database-test.js index ec9a47315..54d7504e0 100644 --- a/src/test/database-test.js +++ b/src/test/database-test.js @@ -678,6 +678,44 @@ describe('database', function () { }); }); + describe('appPasswords', function () { + before(function (done) { + userdb.add(USER_0.id, USER_0, done); + }); + after(function (done) { + userdb.del(USER_0.id, done); + }); + + it('can add app password', function (done) { + userdb.addAppPassword('someid', { userId: USER_0.id, hashedPassword: 'hash', name: 'spark', identifier: 'appid' }, done); + }); + + it('can get app password', function (done) { + userdb.getAppPassword('someid', function (error, result) { + if (error) return done(error); + expect(result.hashedPassword).to.be('hash'); + expect(result.name).to.be('spark'); + expect(result.identifier).to.be('appid'); + done(); + }); + }); + + it('can get app passwords', function (done) { + userdb.getAppPasswords(USER_0.id, function (error, results) { + if (error) return done(error); + expect(results.length).to.be(1); + expect(results[0].hashedPassword).to.be('hash'); + expect(results[0].name).to.be('spark'); + expect(results[0].identifier).to.be('appid'); + done(); + }); + }); + + it('can del app password', function (done) { + userdb.delAppPassword('someid', done); + }); + }); + describe('authcode', function () { var AUTHCODE_0 = { authCode: 'authcode-0', diff --git a/src/test/users-test.js b/src/test/users-test.js index e0b1464d4..add358d23 100644 --- a/src/test/users-test.js +++ b/src/test/users-test.js @@ -233,7 +233,7 @@ describe('User', function () { after(cleanupUsers); it('fails due to non existing user', function (done) { - users.verify('somerandomid', PASSWORD, function (error, result) { + users.verify('somerandomid', PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.be.ok(); expect(result).to.not.be.ok(); expect(error.reason).to.equal(BoxError.NOT_FOUND); @@ -243,7 +243,7 @@ describe('User', function () { }); it('fails due to empty password', function (done) { - users.verify(userObject.id, '', function (error, result) { + users.verify(userObject.id, '', users.AP_WEBADMIN, function (error, result) { expect(error).to.be.ok(); expect(result).to.not.be.ok(); expect(error.reason).to.equal(BoxError.INVALID_CREDENTIALS); @@ -253,7 +253,7 @@ describe('User', function () { }); it('fails due to wrong password', function (done) { - users.verify(userObject.id, PASSWORD+PASSWORD, function (error, result) { + users.verify(userObject.id, PASSWORD+PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.be.ok(); expect(result).to.not.be.ok(); expect(error.reason).to.equal(BoxError.INVALID_CREDENTIALS); @@ -263,7 +263,7 @@ describe('User', function () { }); it('succeeds', function (done) { - users.verify(userObject.id, PASSWORD, function (error, result) { + users.verify(userObject.id, PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.not.be.ok(); expect(result).to.be.ok(); @@ -272,7 +272,7 @@ describe('User', function () { }); it('fails for ghost if not enabled', function (done) { - users.verify(userObject.id, 'foobar', function (error) { + users.verify(userObject.id, 'foobar', users.AP_WEBADMIN, function (error) { expect(error).to.be.a(BoxError); expect(error.reason).to.equal(BoxError.INVALID_CREDENTIALS); done(); @@ -284,7 +284,7 @@ describe('User', function () { ghost[userObject.username] = 'testpassword'; fs.writeFileSync(constants.GHOST_USER_FILE, JSON.stringify(ghost), 'utf8'); - users.verify(userObject.id, 'foobar', function (error) { + users.verify(userObject.id, 'foobar', users.AP_WEBADMIN, function (error) { fs.unlinkSync(constants.GHOST_USER_FILE); expect(error).to.be.a(BoxError); @@ -298,7 +298,7 @@ describe('User', function () { ghost[userObject.username] = 'testpassword'; fs.writeFileSync(constants.GHOST_USER_FILE, JSON.stringify(ghost), 'utf8'); - users.verify(userObject.id, 'testpassword', function (error, result) { + users.verify(userObject.id, 'testpassword', users.AP_WEBADMIN, function (error, result) { fs.unlinkSync(constants.GHOST_USER_FILE); expect(error).to.equal(null); @@ -316,7 +316,7 @@ describe('User', function () { ghost[userObject.username] = 'testpassword'; fs.writeFileSync(constants.GHOST_USER_FILE, JSON.stringify(ghost), 'utf8'); - users.verify(userObject.id, PASSWORD, function (error, result) { + users.verify(userObject.id, PASSWORD, users.AP_WEBADMIN, function (error, result) { fs.unlinkSync(constants.GHOST_USER_FILE); expect(error).to.not.be.ok(); @@ -333,7 +333,7 @@ describe('User', function () { after(cleanupUsers); it('fails due to non existing username', function (done) { - users.verifyWithUsername(USERNAME+USERNAME, PASSWORD, function (error, result) { + users.verifyWithUsername(USERNAME+USERNAME, PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.be.ok(); expect(result).to.not.be.ok(); expect(error.reason).to.equal(BoxError.NOT_FOUND); @@ -343,7 +343,7 @@ describe('User', function () { }); it('fails due to empty password', function (done) { - users.verifyWithUsername(USERNAME, '', function (error, result) { + users.verifyWithUsername(USERNAME, '', users.AP_WEBADMIN, function (error, result) { expect(error).to.be.ok(); expect(result).to.not.be.ok(); expect(error.reason).to.equal(BoxError.INVALID_CREDENTIALS); @@ -353,7 +353,7 @@ describe('User', function () { }); it('fails due to wrong password', function (done) { - users.verifyWithUsername(USERNAME, PASSWORD+PASSWORD, function (error, result) { + users.verifyWithUsername(USERNAME, PASSWORD+PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.be.ok(); expect(result).to.not.be.ok(); expect(error.reason).to.equal(BoxError.INVALID_CREDENTIALS); @@ -363,7 +363,7 @@ describe('User', function () { }); it('succeeds', function (done) { - users.verifyWithUsername(USERNAME, PASSWORD, function (error, result) { + users.verifyWithUsername(USERNAME, PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.not.be.ok(); expect(result).to.be.ok(); @@ -372,7 +372,7 @@ describe('User', function () { }); it('succeeds for different username case', function (done) { - users.verifyWithUsername(USERNAME.toUpperCase(), PASSWORD, function (error, result) { + users.verifyWithUsername(USERNAME.toUpperCase(), PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.not.be.ok(); expect(result).to.be.ok(); @@ -386,7 +386,7 @@ describe('User', function () { fs.writeFileSync(constants.GHOST_USER_FILE, JSON.stringify(ghost), 'utf8'); - users.verifyWithUsername(USERNAME, 'foobar', function (error) { + users.verifyWithUsername(USERNAME, 'foobar', users.AP_WEBADMIN, function (error) { fs.unlinkSync(constants.GHOST_USER_FILE); expect(error).to.be.a(BoxError); @@ -401,7 +401,7 @@ describe('User', function () { fs.writeFileSync(constants.GHOST_USER_FILE, JSON.stringify(ghost), 'utf8'); - users.verifyWithUsername(USERNAME, 'testpassword', function (error, result) { + users.verifyWithUsername(USERNAME, 'testpassword', users.AP_WEBADMIN, function (error, result) { fs.unlinkSync(constants.GHOST_USER_FILE); expect(error).to.equal(null); @@ -420,7 +420,7 @@ describe('User', function () { after(cleanupUsers); it('fails due to non existing user', function (done) { - users.verifyWithEmail(EMAIL+EMAIL, PASSWORD, function (error, result) { + users.verifyWithEmail(EMAIL+EMAIL, PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.be.ok(); expect(result).to.not.be.ok(); expect(error.reason).to.equal(BoxError.NOT_FOUND); @@ -430,7 +430,7 @@ describe('User', function () { }); it('fails due to empty password', function (done) { - users.verifyWithEmail(EMAIL, '', function (error, result) { + users.verifyWithEmail(EMAIL, '', users.AP_WEBADMIN, function (error, result) { expect(error).to.be.ok(); expect(result).to.not.be.ok(); expect(error.reason).to.equal(BoxError.INVALID_CREDENTIALS); @@ -440,7 +440,7 @@ describe('User', function () { }); it('fails due to wrong password', function (done) { - users.verifyWithEmail(EMAIL, PASSWORD+PASSWORD, function (error, result) { + users.verifyWithEmail(EMAIL, PASSWORD+PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.be.ok(); expect(result).to.not.be.ok(); expect(error.reason).to.equal(BoxError.INVALID_CREDENTIALS); @@ -450,7 +450,7 @@ describe('User', function () { }); it('succeeds', function (done) { - users.verifyWithEmail(EMAIL, PASSWORD, function (error, result) { + users.verifyWithEmail(EMAIL, PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.not.be.ok(); expect(result).to.be.ok(); @@ -459,7 +459,7 @@ describe('User', function () { }); it('succeeds for different email case', function (done) { - users.verifyWithEmail(EMAIL.toUpperCase(), PASSWORD, function (error, result) { + users.verifyWithEmail(EMAIL.toUpperCase(), PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.not.be.ok(); expect(result).to.be.ok(); @@ -473,7 +473,7 @@ describe('User', function () { fs.writeFileSync(constants.GHOST_USER_FILE, JSON.stringify(ghost), 'utf8'); - users.verifyWithEmail(EMAIL, 'foobar', function (error) { + users.verifyWithEmail(EMAIL, 'foobar', users.AP_WEBADMIN, function (error) { fs.unlinkSync(constants.GHOST_USER_FILE); expect(error).to.be.a(BoxError); @@ -488,7 +488,7 @@ describe('User', function () { fs.writeFileSync(constants.GHOST_USER_FILE, JSON.stringify(ghost), 'utf8'); - users.verifyWithEmail(EMAIL, 'testpassword', function (error, result) { + users.verifyWithEmail(EMAIL, 'testpassword', users.AP_WEBADMIN, function (error, result) { fs.unlinkSync(constants.GHOST_USER_FILE); expect(error).to.equal(null); @@ -510,7 +510,7 @@ describe('User', function () { users.update(userObject.id, { active: false }, AUDIT_SOURCE, function (error) { expect(error).to.not.be.ok(); - users.verify(userObject.id, PASSWORD, function (error) { + users.verify(userObject.id, PASSWORD, users.AP_WEBADMIN, function (error) { expect(error).to.be.ok(); expect(error.reason).to.equal(BoxError.NOT_FOUND); @@ -523,7 +523,7 @@ describe('User', function () { users.update(userObject.id, { active: true }, AUDIT_SOURCE, function (error) { expect(error).to.not.be.ok(); - users.verify(userObject.id, PASSWORD, function (error) { + users.verify(userObject.id, PASSWORD, users.AP_WEBADMIN, function (error) { expect(error).to.not.be.ok(); done(); @@ -532,6 +532,94 @@ describe('User', function () { }); }); + describe('appPasswords', function () { + before(createOwner); + after(cleanupUsers); + let pwd; + + it('can add app password', function (done) { + users.addAppPassword(userObject.id, 'appid', 'rpi', function (error, result) { + expect(error).to.be(null); + pwd = result; + done(); + }); + }); + + it('can get app passwords', function (done) { + users.getAppPasswords(userObject.id, function (error, result) { + expect(error).to.be(null); + expect(result.length).to.be(1); + expect(result[0].name).to.be('rpi'); + expect(result[0].identifier).to.be('appid'); + expect(result[0].hashedPassword).to.be(undefined); + done(); + }); + }); + + it('can get app password', function (done) { + users.getAppPassword(pwd.id, function (error, result) { + expect(error).to.be(null); + expect(result.name).to.be('rpi'); + expect(result.identifier).to.be('appid'); + expect(result.hashedPassword).to.be(undefined); + done(); + }); + }); + + it('can verify app password', function (done) { + users.verify(userObject.id, pwd.password, 'appid', function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(result.appPassword).to.be(true); + + done(); + }); + }); + + it('can verify non-app password', function (done) { + users.verify(userObject.id, PASSWORD, 'appid', function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(result.appPassword).to.be(undefined); + + done(); + }); + }); + + it('cannot verify bad password', function (done) { + users.verify(userObject.id, 'bad', 'appid', function (error, result) { + expect(error).to.be.ok(); + expect(result).to.not.be.ok(); + expect(error.reason).to.be(BoxError.INVALID_CREDENTIALS); + + done(); + }); + }); + + it('cannot verify password for another app', function (done) { + users.verify(userObject.id, pwd.password, 'appid2', function (error, result) { + expect(error).to.be.ok(); + expect(result).to.not.be.ok(); + expect(error.reason).to.be(BoxError.INVALID_CREDENTIALS); + + done(); + }); + }); + + it('can del app password', function (done) { + users.delAppPassword(pwd.id, function (error) { + if (error) return done(error); + + // cannot verify anymore + users.verify(userObject.id, pwd.password, 'appid', function (error) { + expect(error).to.be.ok(); + + done(); + }); + }); + }); + }); + describe('retrieving', function () { before(createOwner); after(cleanupUsers); @@ -801,7 +889,7 @@ describe('User', function () { }); it('actually changed the password (unable to login with old pasword)', function (done) { - users.verify(userObject.id, PASSWORD, function (error, result) { + users.verify(userObject.id, PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.be.ok(); expect(result).to.not.be.ok(); expect(error.reason).to.equal(BoxError.INVALID_CREDENTIALS); @@ -810,7 +898,7 @@ describe('User', function () { }); it('actually changed the password (login with new password)', function (done) { - users.verify(userObject.id, NEW_PASSWORD, function (error, result) { + users.verify(userObject.id, NEW_PASSWORD, users.AP_WEBADMIN, function (error, result) { expect(error).to.not.be.ok(); expect(result).to.be.ok(); done(); diff --git a/src/userdb.js b/src/userdb.js index 8cec7cc87..120ec7f44 100644 --- a/src/userdb.js +++ b/src/userdb.js @@ -15,6 +15,11 @@ exports = module.exports = { update: update, count: count, + addAppPassword: addAppPassword, + getAppPasswords: getAppPasswords, + getAppPassword: getAppPassword, + delAppPassword: delAppPassword, + _clear: clear }; @@ -27,6 +32,8 @@ var assert = require('assert'), var USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'createdAt', 'modifiedAt', 'resetToken', 'displayName', 'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'admin', 'active', 'source' ].join(','); +var APP_PASSWORD_FIELDS = [ 'id', 'name', 'userId', 'identifier', 'hashedPassword', 'creationTime' ].join(','); + function postProcess(result) { assert.strictEqual(typeof result, 'object'); @@ -197,12 +204,13 @@ function del(userId, callback) { var queries = []; queries.push({ query: 'DELETE FROM groupMembers WHERE userId = ?', args: [ userId ] }); queries.push({ query: 'DELETE FROM tokens WHERE identifier = ?', args: [ userId ] }); + queries.push({ query: 'DELETE FROM appPasswords WHERE userId = ?', args: [ userId ] }); queries.push({ query: 'DELETE FROM users WHERE id = ?', args: [ userId ] }); database.transaction(queries, function (error, result) { if (error && error.code === 'ER_NO_REFERENCED_ROW_2') return callback(new BoxError(BoxError.NOT_FOUND, error)); if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); - if (result[2].affectedRows !== 1) return callback(new BoxError(BoxError.NOT_FOUND, 'User not found')); + if (result[3].affectedRows !== 1) return callback(new BoxError(BoxError.NOT_FOUND, 'User not found')); callback(error); }); @@ -273,3 +281,53 @@ function count(callback) { return callback(null, result[0].total); }); } + +function getAppPasswords(userId, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + APP_PASSWORD_FIELDS + ' FROM appPasswords WHERE userId = ?', [ userId ], function (error, results) { + if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); + + callback(null, results); + }); +} + +function getAppPassword(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + APP_PASSWORD_FIELDS + ' FROM appPasswords WHERE id = ?', [ id ], function (error, results) { + if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); + + callback(null, results[0]); + }); +} + +function addAppPassword(id, appPassword, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof appPassword, 'object'); + assert.strictEqual(typeof callback, 'function'); + + const query = 'INSERT INTO appPasswords (id, userId, identifier, name, hashedPassword) VALUES (?, ?, ?, ?, ?)'; + const args = [ id, appPassword.userId, appPassword.identifier, appPassword.name, appPassword.hashedPassword ]; + + database.query(query, args, function (error) { + if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); + + callback(null); + }); +} + +function delAppPassword(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM appPasswords WHERE id = ?', [ id ], function (error, result) { + if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); + if (result.affectedRows !== 1) return callback(new BoxError(BoxError.NOT_FOUND, 'password not found')); + + return callback(null); + }); +} + diff --git a/src/users.js b/src/users.js index 39db93576..2ac569db3 100644 --- a/src/users.js +++ b/src/users.js @@ -28,7 +28,16 @@ exports = module.exports = { enableTwoFactorAuthentication: enableTwoFactorAuthentication, disableTwoFactorAuthentication: disableTwoFactorAuthentication, - count: count + count: count, + + AP_MAIL: 'mail', + AP_SFTP: 'sftp', + AP_WEBADMIN: 'webadmin', + + getAppPasswords: getAppPasswords, + getAppPassword: getAppPassword, + addAppPassword: addAppPassword, + delAppPassword: delAppPassword }; let assert = require('assert'), @@ -204,9 +213,28 @@ function verifyGhost(username, password) { return false; } -function verify(userId, password, callback) { +function verifyAppPassword(userId, password, identifier, callback) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof identifier, 'string'); + assert.strictEqual(typeof callback, 'function'); + + userdb.getAppPasswords(userId, function (error, results) { + if (error) return callback(error); + + const hashedPasswords = results.filter(r => r.identifier === identifier).map(r => r.hashedPassword); + let hash = crypto.createHash('sha256').update(password).digest('base64'); + + if (hashedPasswords.includes(hash)) return callback(null); + + return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); + }); +} + +function verify(userId, password, identifier, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof callback, 'function'); get(userId, function (error, user) { @@ -220,47 +248,56 @@ function verify(userId, password, callback) { return callback(null, user); } - if (user.source === 'ldap') { - externalLdap.verifyPassword(user, password, function (error) { - if (error) return callback(error); + verifyAppPassword(user.id, password, identifier, function (error) { + if (!error) { + user.appPassword = true; + return callback(null, user); + } - callback(null, user); - }); - } else { - var saltBinary = Buffer.from(user.salt, 'hex'); - crypto.pbkdf2(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST, function (error, derivedKey) { - if (error) return callback(new BoxError(BoxError.CRYPTO_ERROR, error)); + if (user.source === 'ldap') { + externalLdap.verifyPassword(user, password, function (error) { + if (error) return callback(error); - var derivedKeyHex = Buffer.from(derivedKey, 'binary').toString('hex'); - if (derivedKeyHex !== user.password) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); + callback(null, user); + }); + } else { + var saltBinary = Buffer.from(user.salt, 'hex'); + crypto.pbkdf2(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST, function (error, derivedKey) { + if (error) return callback(new BoxError(BoxError.CRYPTO_ERROR, error)); - callback(null, user); - }); - } + var derivedKeyHex = Buffer.from(derivedKey, 'binary').toString('hex'); + if (derivedKeyHex !== user.password) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); + + callback(null, user); + }); + } + }); }); } -function verifyWithUsername(username, password, callback) { +function verifyWithUsername(username, password, identifier, callback) { assert.strictEqual(typeof username, 'string'); assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.getByUsername(username.toLowerCase(), function (error, user) { if (error) return callback(error); - verify(user.id, password, callback); + verify(user.id, password, identifier, callback); }); } -function verifyWithEmail(email, password, callback) { +function verifyWithEmail(email, password, identifier, callback) { assert.strictEqual(typeof email, 'string'); assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.getByEmail(email.toLowerCase(), function (error, user) { if (error) return callback(error); - verify(user.id, password, callback); + verify(user.id, password, identifier, callback); }); } @@ -619,3 +656,77 @@ function disableTwoFactorAuthentication(userId, callback) { callback(null); }); } + +function validateAppPasswordName(name) { + assert.strictEqual(typeof name, 'string'); + + if (name.length < 1) return new BoxError(BoxError.BAD_FIELD, 'name must be atleast 1 char'); + if (name.length >= 200) return new BoxError(BoxError.BAD_FIELD, 'name too long'); + + return null; +} + +function getAppPassword(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + userdb.getAppPassword(id, function (error, result) { + if (error) return callback(error); + + callback(null, _.omit(result, 'hashedPassword')); + }); +} + +function addAppPassword(userId, identifier, name, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof identifier, 'string'); + assert.strictEqual(typeof name, 'string'); + assert.strictEqual(typeof callback, 'function'); + + let error = validateAppPasswordName(name); + if (error) return callback(error); + + if (identifier.length < 1) return new BoxError(BoxError.BAD_FIELD, 'identifier must be atleast 1 char'); + + const password = hat(8 * 4); + const hashedPassword = crypto.createHash('sha256').update(password).digest('base64'); + + var appPassword = { + id: 'uid-' + uuid.v4(), + name, + userId, + identifier, + password, + hashedPassword + }; + + userdb.addAppPassword(appPassword.id, appPassword, function (error) { + if (error) return callback(error); + + callback(null, _.omit(appPassword, 'hashedPassword')); + }); +} + +function getAppPasswords(userId, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + userdb.getAppPasswords(userId, function (error, results) { + if (error) return callback(error); + + results.map(r => delete r.hashedPassword); + + callback(null, results); + }); +} + +function delAppPassword(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + userdb.delAppPassword(id, function (error) { + if (error) return callback(error); + + callback(null); + }); +}