Skip to content

Commit

Permalink
feat(auth): add support for SCRAM-SHA-256
Browse files Browse the repository at this point in the history
Adds support for SCRAM-SHA-256 and auth mechanism negotiation
Also implements prose tests for auth, and fixes a few bugs

Fixes NODE-1311
Fixes NODE-1303
Fixes NODE-1413
  • Loading branch information
daprahamian committed May 7, 2018
1 parent d6c3417 commit f53195d
Show file tree
Hide file tree
Showing 7 changed files with 359 additions and 8 deletions.
6 changes: 6 additions & 0 deletions lib/authenticate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 12 additions & 6 deletions lib/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
2 changes: 1 addition & 1 deletion lib/mongo_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
1 change: 1 addition & 0 deletions lib/url_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
94 changes: 94 additions & 0 deletions test/functional/saslprep_tests.js
Original file line number Diff line number Diff line change
@@ -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:[email protected]/admin
// mongodb://IX:I%C2%[email protected]/admin
// mongodb://%E2%85%A8:[email protected]/admin
// mongodb://%E2%85%A8:I%C2%[email protected]/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();
});
}
});
});
});
216 changes: 216 additions & 0 deletions test/functional/scram_sha_256_tests.js
Original file line number Diff line number Diff line change
@@ -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)]);
}
});
});
Loading

0 comments on commit f53195d

Please sign in to comment.