diff --git a/src/externalldap.js b/src/externalldap.js index a527095ef..f209afd44 100644 --- a/src/externalldap.js +++ b/src/externalldap.js @@ -2,6 +2,7 @@ exports = module.exports = { verifyPassword, + verifyPasswordAndTotpToken, maybeCreateUser, testConfig, @@ -44,6 +45,7 @@ function translateUser(ldapConfig, ldapUser) { return { username: ldapUser[ldapConfig.usernameField].toLowerCase(), email: ldapUser.mail || ldapUser.mailPrimaryAddress, + twoFactorAuthenticationEnabled: !!ldapUser.twoFactorAuthenticationEnabled, displayName: ldapUser.displayName || ldapUser.cn // user.giveName + ' ' + user.sn }; } @@ -279,6 +281,32 @@ async function verifyPassword(user, password) { return translateUser(externalLdapConfig, ldapUsers[0]); } +async function verifyPasswordAndTotpToken(user, password, totpToken) { + assert.strictEqual(typeof user, 'object'); + assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof totpToken, 'string'); + + const externalLdapConfig = await settings.getExternalLdapConfig(); + if (externalLdapConfig.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled'); + + const ldapUsers = await ldapUserSearch(externalLdapConfig, { filter: `${externalLdapConfig.usernameField}=${user.username}` }); + if (ldapUsers.length === 0) throw new BoxError(BoxError.NOT_FOUND); + if (ldapUsers.length > 1) throw new BoxError(BoxError.CONFLICT); + + const client = await getClient(externalLdapConfig, { bind: false }); + + // inject totptoken into first attribute + const rdns = ldapUsers[0].dn.split(','); + const totpTokenDn = `${rdns[0]}+totptoken=${totpToken},` + rdns.slice(1).join(','); + + const [error] = await safe(util.promisify(client.bind.bind(client))(totpTokenDn, password)); + client.unbind(); + if (error instanceof ldap.InvalidCredentialsError) throw new BoxError(BoxError.INVALID_CREDENTIALS); + if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error); + + return translateUser(externalLdapConfig, ldapUsers[0]); +} + async function startSyncer() { const externalLdapConfig = await settings.getExternalLdapConfig(); if (externalLdapConfig.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled'); diff --git a/src/routes/accesscontrol.js b/src/routes/accesscontrol.js index 9b1839f95..d6bd20fe0 100644 --- a/src/routes/accesscontrol.js +++ b/src/routes/accesscontrol.js @@ -43,8 +43,13 @@ async function passwordAuth(req, res, next) { if (!user.ghost && !user.appPassword && user.twoFactorAuthenticationEnabled) { if (!totpToken) return next(new HttpError(401, 'A totpToken must be provided')); - const verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: totpToken, window: 2 }); - if (!verified) return next(new HttpError(401, 'Invalid totpToken')); + if (user.source === 'ldap') { + const [error] = await safe(externalLdap.verifyPasswordAndTotpToken(user, password, totpToken)); + if (error) return next(new HttpError(401, 'Invalid totpToken')); + } else { + const verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: totpToken, window: 2 }); + if (!verified) return next(new HttpError(401, 'Invalid totpToken')); + } } req.user = user; diff --git a/src/userdirectory.js b/src/userdirectory.js index 3e39e86fb..ae351aa0f 100644 --- a/src/userdirectory.js +++ b/src/userdirectory.js @@ -23,6 +23,7 @@ const assert = require('assert'), reverseproxy = require('./reverseproxy.js'), safe = require('safetydance'), settings = require('./settings.js'), + speakeasy = require('speakeasy'), shell = require('./shell.js'), users = require('./users.js'), util = require('util'), @@ -173,12 +174,13 @@ async function userSearch(req, res, next) { displayname: displayName, givenName: firstName, username: user.username, - twoFactorAuthenticationEnabled: user.twoFactorAuthenticationEnabled || undefined, samaccountname: user.username, // to support ActiveDirectory clients // memberof: user.groupIds.map(function (gid) { return `cn=${gid},ou=groups,dc=cloudron`; }) <- use cn=group.name instead of id } }; + if (user.twoFactorAuthenticationEnabled) obj.attributes.twoFactorAuthenticationEnabled = true; + // http://www.zytrax.com/books/ldap/ape/core-schema.html#sn has 'name' as SUP which is a DirectoryString // which is required to have atleast one character if present if (lastName.length !== 0) obj.attributes.sn = lastName; @@ -235,12 +237,15 @@ async function groupSearch(req, res, next) { // Will attach req.user if successful async function userAuth(req, res, next) { // extract the common name which might have different attribute names - const attributeName = Object.keys(req.dn.rdns[0].attrs)[0]; - const commonName = req.dn.rdns[0].attrs[attributeName].value; + const cnAttributeName = Object.keys(req.dn.rdns[0].attrs)[0]; + const commonName = req.dn.rdns[0].attrs[cnAttributeName].value; if (!commonName) return next(new ldap.NoSuchObjectError(req.dn.toString())); + const TOTPTOKEN_ATTRIBUTE_NAME = 'totptoken'; // This has to be in-sync with externalldap.js + const totpToken = req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME] ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null; + let verifyFunc; - if (attributeName === 'mail') { + if (cnAttributeName === 'mail') { verifyFunc = users.verifyWithEmail; } else if (commonName.indexOf('@') !== -1) { // if mail is specified, enforce mail check verifyFunc = users.verifyWithEmail; @@ -255,6 +260,12 @@ async function userAuth(req, res, next) { if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); + // currently this is only optional if totpToken is provided and user has 2fa enabled + if (totpToken && user.twoFactorAuthenticationEnabled) { + const verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: totpToken, window: 2 }); + if (!verified) return next(new ldap.InvalidCredentialsError(req.dn.toString())); + } + req.user = user; next(); diff --git a/src/users.js b/src/users.js index 2c8ccda77..186cb5de1 100644 --- a/src/users.js +++ b/src/users.js @@ -356,7 +356,9 @@ async function verify(userId, password, identifier) { } if (user.source === 'ldap') { - await externalLdap.verifyPassword(user, password); + const ldapUser = await externalLdap.verifyPassword(user, password); + // currently we store twoFactorAuthenticationEnabled in the db as local so amend it to user object + user.twoFactorAuthenticationEnabled = !!ldapUser.twoFactorAuthenticationEnabled; } else { const saltBinary = Buffer.from(user.salt, 'hex'); const [error, derivedKey] = await safe(pbkdf2Async(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));