diff --git a/lib/authenticate.js b/lib/authenticate.js index 5cbb93761e2..35c92b42674 100644 --- a/lib/authenticate.js +++ b/lib/authenticate.js @@ -51,6 +51,11 @@ var authenticate = function(client, username, password, options, callback) { if (err) return handleCallback(callback, err, false); _callback(null, true); }); + } else if (authMechanism === 'SCRAM-SHA-256') { + client.topology.auth('scram-sha-256', authdb, username, password, function(err) { + if (err) return handleCallback(callback, err, false); + _callback(null, true); + }); } else if (authMechanism === 'GSSAPI') { if (process.platform === 'win32') { client.topology.auth('sspi', authdb, username, password, options, function(err) { @@ -95,6 +100,7 @@ module.exports = function(self, username, password, options, callback) { options.authMechanism !== 'MONGODB-CR' && options.authMechanism !== 'MONGODB-X509' && options.authMechanism !== 'SCRAM-SHA-1' && + options.authMechanism !== 'SCRAM-SHA-256' && options.authMechanism !== 'PLAIN' ) { return handleCallback( diff --git a/lib/db.js b/lib/db.js index 937efcd6352..75763784cc8 100644 --- a/lib/db.js +++ b/lib/db.js @@ -1189,22 +1189,28 @@ var _executeAuthCreateUserCommand = function(self, username, password, options, roles = ['dbOwner']; } + const digestPassword = self.s.topology.lastIsMaster().maxWireVersion >= 7; + // Build the command to execute var command = { createUser: username, customData: customData, roles: roles, - digestPassword: false + digestPassword }; // Apply write concern to command command = applyWriteConcern(command, { db: self }, options); - // Use node md5 generator - var md5 = crypto.createHash('md5'); - // Generate keys used for authentication - md5.update(username + ':mongo:' + password); - var userPassword = md5.digest('hex'); + let userPassword = password; + + if (!digestPassword) { + // Use node md5 generator + let md5 = crypto.createHash('md5'); + // Generate keys used for authentication + md5.update(username + ':mongo:' + password); + userPassword = md5.digest('hex'); + } // No password if (typeof password === 'string') { diff --git a/lib/mongo_client.js b/lib/mongo_client.js index 547c7898001..a2a63dc6981 100644 --- a/lib/mongo_client.js +++ b/lib/mongo_client.js @@ -778,7 +778,7 @@ function createServer(self, options, callback) { var collectedEvents = collectEvents(self, servers[0]); // Connect to topology - servers[0].connect(function(err, topology) { + servers[0].connect(options, function(err, topology) { if (err) return callback(err); // Clear out all the collected event listeners clearAllEvents(servers[0]); diff --git a/lib/url_parser.js b/lib/url_parser.js index af8c1bc7cca..8ced4f11b12 100644 --- a/lib/url_parser.js +++ b/lib/url_parser.js @@ -484,6 +484,7 @@ function parseConnectionString(url, options) { value !== 'MONGODB-CR' && value !== 'DEFAULT' && value !== 'SCRAM-SHA-1' && + value !== 'SCRAM-SHA-256' && value !== 'PLAIN' ) throw new Error( diff --git a/test/functional/saslprep_tests.js b/test/functional/saslprep_tests.js new file mode 100644 index 00000000000..1bb21c86da9 --- /dev/null +++ b/test/functional/saslprep_tests.js @@ -0,0 +1,94 @@ +'use strict'; + +const setupDatabase = require('./shared').setupDatabase; +const withClient = require('./shared').withClient; + +describe('SASLPrep', function() { + // Step 4 + // To test SASLprep behavior, create two users: + // username: "IX", password "IX" + // username: "u2168" (ROMAN NUMERAL NINE), password "u2163" (ROMAN NUMERAL FOUR) + // To create the users, use the exact bytes for username and password without SASLprep or other normalization and specify SCRAM-SHA-256 credentials: + // db.runCommand({createUser: 'IX', pwd: 'IX', roles: ['root'], mechanisms: ['SCRAM-SHA-256']}) db.runCommand({createUser: 'u2168', pwd: 'u2163', roles: ['root'], mechanisms: ['SCRAM-SHA-256']}) + // For each user, verify that the driver can authenticate with the password in both SASLprep normalized and non-normalized forms: + // User "IX": use password forms "IX" and "Iu00ADX" + // User "u2168": use password forms "IV" and "Iu00ADV" + // As a URI, those have to be UTF-8 encoded and URL-escaped, e.g.: + // mongodb://IX:IX@mongodb.example.com/admin + // mongodb://IX:I%C2%ADX@mongodb.example.com/admin + // mongodb://%E2%85%A8:IV@mongodb.example.com/admin + // mongodb://%E2%85%A8:I%C2%ADV@mongodb.example.com/admin + + const users = [ + { + username: 'IX', + password: 'IX', + mechanisms: ['SCRAM-SHA-256'] + }, + { + username: '\u2168', + password: '\u2163', + mechanisms: ['SCRAM-SHA-256'] + } + ]; + + before(function() { + return setupDatabase(this.configuration); + }); + + before(function() { + return withClient(this.configuration.newClient(), client => { + const db = client.db('admin'); + + const createUserCommands = users.map(user => ({ + createUser: user.username, + pwd: user.password, + roles: ['root'], + mechanisms: user.mechanisms + })); + + return Promise.all(createUserCommands.map(cmd => db.command(cmd))); + }); + }); + + after(function() { + return withClient(this.configuration.newClient(), client => { + const db = client.db('admin'); + + return Promise.all(users.map(user => db.removeUser(user.username))); + }); + }); + + [ + { username: 'IX', password: 'IX' }, + { username: 'IX', password: 'I\u00ADX' }, + { username: 'IX', password: '\u2168' }, + { username: '\u2168', password: 'IV' }, + { username: '\u2168', password: 'I\u00ADV' }, + { username: '\u2168', password: '\u2163' } + ].forEach(user => { + const username = user.username; + const password = user.password; + + it(`should be able to login with username "${username}" and password "${password}"`, { + metadata: { + requires: { + mongodb: '>=3.7.3', + node: '>=6' + } + }, + test: function() { + const options = { + user: username, + password: password, + authSource: 'admin', + authMechanism: 'SCRAM-SHA-256' + }; + + return withClient(this.configuration.newClient(options), client => { + return client.db('admin').stats(); + }); + } + }); + }); +}); diff --git a/test/functional/scram_sha_256_tests.js b/test/functional/scram_sha_256_tests.js new file mode 100644 index 00000000000..02a2eb6c168 --- /dev/null +++ b/test/functional/scram_sha_256_tests.js @@ -0,0 +1,216 @@ +'use strict'; + +const expect = require('chai').expect; +const sinon = require('sinon'); +const ScramSHA256 = require('mongodb-core').ScramSHA256; +const MongoError = require('mongodb-core').MongoError; +const setupDatabase = require('./shared').setupDatabase; +const withClient = require('./shared').withClient; +const MongoClient = require('../../lib/mongo_client'); + +describe('SCRAM-SHA-256 auth', function() { + const test = {}; + const userMap = { + sha1: { + description: 'user with sha1 credentials', + username: 'sha1', + password: 'sha1', + mechanisms: ['SCRAM-SHA-1'] + }, + sha256: { + description: 'user with sha256 credentials', + username: 'sha256', + password: 'sha256', + mechanisms: ['SCRAM-SHA-256'] + }, + both: { + description: 'user with both credentials', + username: 'both', + password: 'both', + mechanisms: ['SCRAM-SHA-1', 'SCRAM-SHA-256'] + } + }; + + function makeConnectionString(config, username, password) { + return `mongodb://${username}:${password}@${config.host}:${config.port}/${config.db}?`; + } + + const users = Object.keys(userMap).map(name => userMap[name]); + + afterEach(() => test.sandbox.restore()); + + before(function() { + test.sandbox = sinon.sandbox.create(); + return setupDatabase(this.configuration); + }); + + before(function() { + return withClient(this.configuration.newClient(), client => { + test.oldDbName = this.configuration.db; + this.configuration.db = 'admin'; + const db = client.db(this.configuration.db); + + const createUserCommands = users.map(user => ({ + createUser: user.username, + pwd: user.password, + roles: ['root'], + mechanisms: user.mechanisms + })); + + return Promise.all(createUserCommands.map(cmd => db.command(cmd))); + }); + }); + + after(function() { + return withClient(this.configuration.newClient(), client => { + const db = client.db(this.configuration.db); + this.configuration.db = test.oldDbName; + + return Promise.all(users.map(user => db.removeUser(user.username))); + }); + }); + + // Step 2 + // For each test user, verify that you can connect and run a command requiring authentication for the following cases: + // Explicitly specifying each mechanism the user supports. + // Specifying no mechanism and relying on mechanism negotiation. + // For the example users above, the dbstats command could be used as a test command. + users.forEach(user => { + user.mechanisms.forEach(mechanism => { + it(`should auth ${user.description} when explicitly specifying ${mechanism}`, { + metadata: { requires: { mongodb: '>=3.7.3' } }, + test: function() { + const options = { + user: user.username, + password: user.password, + authMechanism: mechanism, + authSource: this.configuration.db + }; + + return withClient(this.configuration.newClient(options), client => { + return client.db(this.configuration.db).stats(); + }); + } + }); + + it(`should auth ${user.description} when explicitly specifying ${mechanism} in url`, { + metadata: { requires: { mongodb: '>=3.7.3' } }, + test: function() { + const username = encodeURIComponent(user.username); + const password = encodeURIComponent(user.password); + + const url = `${makeConnectionString( + this.configuration, + username, + password + )}authMechanism=${mechanism}`; + + const client = new MongoClient(url); + + return withClient(client, client => { + return client.db(this.configuration.db).stats(); + }); + } + }); + }); + + it(`should auth ${user.description} using mechanism negotiaton`, { + metadata: { requires: { mongodb: '>=3.7.3' } }, + test: function() { + const options = { + user: user.username, + password: user.password, + authSource: this.configuration.db + }; + + return withClient(this.configuration.newClient(options), client => { + return client.db(this.configuration.db).stats(); + }); + } + }); + + it(`should auth ${user.description} using mechanism negotiaton and url`, { + metadata: { requires: { mongodb: '>=3.7.3' } }, + test: function() { + const username = encodeURIComponent(user.username); + const password = encodeURIComponent(user.password); + const url = makeConnectionString(this.configuration, username, password); + + const client = new MongoClient(url); + + return withClient(client, client => { + return client.db(this.configuration.db).stats(); + }); + } + }); + }); + + // For a test user supporting both SCRAM-SHA-1 and SCRAM-SHA-256, + // drivers should verify that negotation selects SCRAM-SHA-256.. + it('should select SCRAM-SHA-256 for a user that supports both auth mechanisms', { + metadata: { requires: { mongodb: '>=3.7.3' } }, + test: function() { + const options = { + user: userMap.both.username, + password: userMap.both.password, + authSource: this.configuration.db + }; + + test.sandbox.spy(ScramSHA256.prototype, 'auth'); + + return withClient(this.configuration.newClient(options), () => { + expect(ScramSHA256.prototype.auth.calledOnce).to.equal(true); + }); + } + }); + + // Step 3 + // For test users that support only one mechanism, verify that explictly specifying the other mechanism fails. + it('should fail to connect if incorrect auth mechanism is explicitly specified', { + metadata: { requires: { mongodb: '>=3.7.3' } }, + test: function() { + const options = { + user: userMap.sha256.username, + password: userMap.sha256.password, + authSource: this.configuration.db, + authMechanism: 'SCRAM-SHA-1' + }; + + return withClient( + this.configuration.newClient(options), + () => Promise.reject('This request should have failed to authenticate'), + err => expect(err).to.not.be.null + ); + } + }); + + // For a non-existent username, verify that not specifying a mechanism when connecting fails with the same error + // type that would occur with a correct username but incorrect password or mechanism. (Because negotiation with + // a non-existent user name causes an isMaster error, we want to verify this is seen by users as similar to other + // authentication errors, not as a network or database command error.) + it('should fail for a nonexistent username with same error type as bad password', { + metadata: { requires: { mongodb: '>=3.7.3' } }, + test: function() { + const noUsernameOptions = { + user: 'roth', + password: 'pencil', + authSource: 'admin' + }; + + const badPasswordOptions = { + user: 'both', + password: 'pencil', + authSource: 'admin' + }; + + const getErrorMsg = options => + withClient( + this.configuration.newClient(options), + () => Promise.reject('This request should have failed to authenticate'), + err => expect(err).to.be.an.instanceof(MongoError) + ); + + return Promise.all([getErrorMsg(noUsernameOptions), getErrorMsg(badPasswordOptions)]); + } + }); +}); diff --git a/test/functional/shared.js b/test/functional/shared.js index f3f92d39122..ca21418279b 100644 --- a/test/functional/shared.js +++ b/test/functional/shared.js @@ -39,6 +39,33 @@ function setupDatabase(configuration, dbsToClean) { ); } +function makeCleanupFn(client) { + return function(err) { + return new Promise((resolve, reject) => { + try { + client.close(closeErr => { + const finalErr = err || closeErr; + if (finalErr) { + return reject(finalErr); + } + return resolve(); + }); + } catch (e) { + return reject(err || e); + } + }); + }; +} + +function withClient(client, operation, errorHandler) { + const cleanup = makeCleanupFn(client); + + return client + .connect() + .then(operation, errorHandler) + .then(() => cleanup(), cleanup); +} + var assert = { equal: function(a, b) { expect(a).to.equal(b); @@ -77,5 +104,6 @@ module.exports = { connectToDb: connectToDb, setupDatabase: setupDatabase, assert: assert, - delay: delay + delay: delay, + withClient };