diff --git a/doc/changelog.rst b/doc/changelog.rst index af7fca501..dd7d8417a 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -10,6 +10,8 @@ Version 3.7 adds support for MongoDB 4.0. Highlights include: - Support for the SCRAM-SHA-256 authentication mechanism. - Support for Python 3.7. - MD5 is now optional in GridFS. +- If not specified, the authSource for the PLAIN authentication mechanism + defaults to $external. - wtimeoutMS is once again supported as a URI option. - Deprecate the snapshot option of :meth:`~pymongo.collection.Collection.find` and :meth:`~pymongo.collection.Collection.find_one`. The option was diff --git a/pymongo/auth.py b/pymongo/auth.py index 954f337b4..e6701d31d 100644 --- a/pymongo/auth.py +++ b/pymongo/auth.py @@ -71,10 +71,15 @@ GSSAPIProperties = namedtuple('GSSAPIProperties', """Mechanism properties for GSSAPI authentication.""" -def _build_credentials_tuple(mech, source, user, passwd, extra): +def _build_credentials_tuple(mech, source, user, passwd, extra, database): """Build and return a mechanism specific credentials tuple. """ + if mech != 'MONGODB-X509' and user is None: + raise ConfigurationError("%s requires a username." % (mech,)) if mech == 'GSSAPI': + if source is not None and source != '$external': + raise ValueError( + "authentication source must be $external or None for GSSAPI") properties = extra.get('authmechanismproperties', {}) service_name = properties.get('SERVICE_NAME', 'mongodb') canonicalize = properties.get('CANONICALIZE_HOST_NAME', False) @@ -85,12 +90,23 @@ def _build_credentials_tuple(mech, source, user, passwd, extra): # Source is always $external. return MongoCredential(mech, '$external', user, passwd, props) elif mech == 'MONGODB-X509': + if passwd is not None: + raise ConfigurationError( + "Passwords are not supported by MONGODB-X509") + if source is not None and source != '$external': + raise ValueError( + "authentication source must be " + "$external or None for MONGODB-X509") # user can be None. return MongoCredential(mech, '$external', user, None, None) + elif mech == 'PLAIN': + source_database = source or database or '$external' + return MongoCredential(mech, source_database, user, passwd, None) else: + source_database = source or database or 'admin' if passwd is None: raise ConfigurationError("A password is required.") - return MongoCredential(mech, source, user, passwd, None) + return MongoCredential(mech, source_database, user, passwd, None) if PY3: diff --git a/pymongo/client_options.py b/pymongo/client_options.py index 263fb70da..8c5c275ba 100644 --- a/pymongo/client_options.py +++ b/pymongo/client_options.py @@ -30,12 +30,12 @@ from pymongo.write_concern import WriteConcern def _parse_credentials(username, password, database, options): """Parse authentication credentials.""" - mechanism = options.get('authmechanism', 'DEFAULT') - if username is None and mechanism != 'MONGODB-X509': - return None - source = options.get('authsource', database or 'admin') - return _build_credentials_tuple( - mechanism, source, username, password, options) + mechanism = options.get('authmechanism', 'DEFAULT' if username else None) + source = options.get('authsource') + if username or mechanism: + return _build_credentials_tuple( + mechanism, source, username, password, options, database) + return None def _parse_read_preference(options): diff --git a/pymongo/database.py b/pymongo/database.py index 08ff9f5ad..57733b957 100644 --- a/pymongo/database.py +++ b/pymongo/database.py @@ -1166,10 +1166,11 @@ class Database(common.BaseObject): credentials = auth._build_credentials_tuple( mechanism, - source or self.name, + source, name, password, - validated_options) + validated_options, + self.name) self.client._cache_credentials( self.name, diff --git a/test/auth/connection-string.json b/test/auth/connection-string.json new file mode 100644 index 000000000..820ad853c --- /dev/null +++ b/test/auth/connection-string.json @@ -0,0 +1,453 @@ +{ + "tests": [ + { + "description": "should use the default source and mechanism", + "uri": "mongodb://user:password@localhost", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "admin" + }, + "options": null + }, + { + "description": "should use the database when no authSource is specified", + "uri": "mongodb://user:password@localhost/foo", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "foo" + }, + "options": null + }, + { + "description": "should use the authSource when specified", + "uri": "mongodb://user:password@localhost/foo?authSource=bar", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "bar" + }, + "options": null + }, + { + "description": "should recognise the mechanism (GSSAPI)", + "uri": "mongodb://user%40DOMAIN.COM@localhost/?authMechanism=GSSAPI", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user@DOMAIN.COM", + "password": null, + "db": "$external" + }, + "options": { + "authmechanism": "GSSAPI" + } + }, + { + "description": "should ignore the database (GSSAPI)", + "uri": "mongodb://user%40DOMAIN.COM@localhost/foo?authMechanism=GSSAPI", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user@DOMAIN.COM", + "password": null, + "db": "$external" + }, + "options": { + "authmechanism": "GSSAPI" + } + }, + { + "description": "should accept valid authSource (GSSAPI)", + "uri": "mongodb://user%40DOMAIN.COM@localhost/?authMechanism=GSSAPI&authSource=$external", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user@DOMAIN.COM", + "password": null, + "db": "$external" + }, + "options": { + "authmechanism": "GSSAPI" + } + }, + { + "description": "should accept generic mechanism property (GSSAPI)", + "uri": "mongodb://user%40DOMAIN.COM@localhost/?authMechanism=GSSAPI&authMechanismProperties=SERVICE_NAME:other,CANONICALIZE_HOST_NAME:true", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user@DOMAIN.COM", + "password": null, + "db": "$external" + }, + "options": { + "authmechanism": "GSSAPI", + "authmechanismproperties": { + "SERVICE_NAME": "other", + "CANONICALIZE_HOST_NAME": true + } + } + }, + { + "description": "should accept the password (GSSAPI)", + "uri": "mongodb://user%40DOMAIN.COM:password@localhost/?authMechanism=GSSAPI&authSource=$external", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user@DOMAIN.COM", + "password": "password", + "db": "$external" + }, + "options": { + "authmechanism": "GSSAPI" + } + }, + { + "description": "may support deprecated gssapiServiceName option (GSSAPI)", + "uri": "mongodb://user%40DOMAIN.COM@localhost/?authMechanism=GSSAPI&gssapiServiceName=other", + "hosts": null, + "valid": true, + "warning": false, + "optional": true, + "auth": { + "username": "user@DOMAIN.COM", + "password": null, + "db": "$external" + }, + "options": { + "authmechanism": "GSSAPI", + "authmechanismproperties": { + "SERVICE_NAME": "other" + } + } + }, + { + "description": "should throw an exception if authSource is invalid (GSSAPI)", + "uri": "mongodb://user%40DOMAIN.COM@localhost/?authMechanism=GSSAPI&authSource=foo", + "hosts": null, + "valid": false, + "warning": false, + "auth": null, + "options": null + }, + { + "description": "should throw an exception if no username (GSSAPI)", + "uri": "mongodb://localhost/?authMechanism=GSSAPI", + "hosts": null, + "valid": false, + "warning": false, + "auth": null, + "options": null + }, + { + "description": "should recognize the mechanism (MONGODB-CR)", + "uri": "mongodb://user:password@localhost/?authMechanism=MONGODB-CR", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "admin" + }, + "options": { + "authmechanism": "MONGODB-CR" + } + }, + { + "description": "should use the database when no authSource is specified (MONGODB-CR)", + "uri": "mongodb://user:password@localhost/foo?authMechanism=MONGODB-CR", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "foo" + }, + "options": { + "authmechanism": "MONGODB-CR" + } + }, + { + "description": "should use the authSource when specified (MONGODB-CR)", + "uri": "mongodb://user:password@localhost/foo?authMechanism=MONGODB-CR&authSource=bar", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "bar" + }, + "options": { + "authmechanism": "MONGODB-CR" + } + }, + { + "description": "should throw an exception if no username is supplied (MONGODB-CR)", + "uri": "mongodb://localhost/?authMechanism=MONGODB-CR", + "hosts": null, + "valid": false, + "warning": false, + "auth": null, + "options": null + }, + { + "description": "should recognize the mechanism (MONGODB-X509)", + "uri": "mongodb://CN%3DmyName%2COU%3DmyOrgUnit%2CO%3DmyOrg%2CL%3DmyLocality%2CST%3DmyState%2CC%3DmyCountry@localhost/?authMechanism=MONGODB-X509", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "CN=myName,OU=myOrgUnit,O=myOrg,L=myLocality,ST=myState,C=myCountry", + "password": null, + "db": "$external" + }, + "options": { + "authmechanism": "MONGODB-X509" + } + }, + { + "description": "should ignore the database (MONGODB-X509)", + "uri": "mongodb://CN%3DmyName%2COU%3DmyOrgUnit%2CO%3DmyOrg%2CL%3DmyLocality%2CST%3DmyState%2CC%3DmyCountry@localhost/foo?authMechanism=MONGODB-X509", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "CN=myName,OU=myOrgUnit,O=myOrg,L=myLocality,ST=myState,C=myCountry", + "password": null, + "db": "$external" + }, + "options": { + "authmechanism": "MONGODB-X509" + } + }, + { + "description": "should accept valid authSource (MONGODB-X509)", + "uri": "mongodb://CN%3DmyName%2COU%3DmyOrgUnit%2CO%3DmyOrg%2CL%3DmyLocality%2CST%3DmyState%2CC%3DmyCountry@localhost/?authMechanism=MONGODB-X509&authSource=$external", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "CN=myName,OU=myOrgUnit,O=myOrg,L=myLocality,ST=myState,C=myCountry", + "password": null, + "db": "$external" + }, + "options": { + "authmechanism": "MONGODB-X509" + } + }, + { + "description": "should recognize the mechanism with no username (MONGODB-X509)", + "uri": "mongodb://localhost/?authMechanism=MONGODB-X509", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": null, + "password": null, + "db": "$external" + }, + "options": { + "authmechanism": "MONGODB-X509" + } + }, + { + "description": "should throw an exception if supplied a password (MONGODB-X509)", + "uri": "mongodb://user:password@localhost/?authMechanism=MONGODB-X509", + "hosts": null, + "valid": false, + "warning": false, + "auth": null, + "options": null + }, + { + "description": "should throw an exception if authSource is invalid (MONGODB-X509)", + "uri": "mongodb://CN%3DmyName%2COU%3DmyOrgUnit%2CO%3DmyOrg%2CL%3DmyLocality%2CST%3DmyState%2CC%3DmyCountry@localhost/foo?authMechanism=MONGODB-X509&authSource=bar", + "hosts": null, + "valid": false, + "warning": false, + "auth": null, + "options": null + }, + { + "description": "should recognize the mechanism (PLAIN)", + "uri": "mongodb://user:password@localhost/?authMechanism=PLAIN", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "$external" + }, + "options": { + "authmechanism": "PLAIN" + } + }, + { + "description": "should use the database when no authSource is specified (PLAIN)", + "uri": "mongodb://user:password@localhost/foo?authMechanism=PLAIN", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "foo" + }, + "options": { + "authmechanism": "PLAIN" + } + }, + { + "description": "should use the authSource when specified (PLAIN)", + "uri": "mongodb://user:password@localhost/foo?authMechanism=PLAIN&authSource=bar", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "bar" + }, + "options": { + "authmechanism": "PLAIN" + } + }, + { + "description": "should throw an exception if no username (PLAIN)", + "uri": "mongodb://localhost/?authMechanism=PLAIN", + "hosts": null, + "valid": false, + "warning": false, + "auth": null, + "options": null + }, + { + "description": "should recognize the mechanism (SCRAM-SHA-1)", + "uri": "mongodb://user:password@localhost/?authMechanism=SCRAM-SHA-1", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "admin" + }, + "options": { + "authmechanism": "SCRAM-SHA-1" + } + }, + { + "description": "should use the database when no authSource is specified (SCRAM-SHA-1)", + "uri": "mongodb://user:password@localhost/foo?authMechanism=SCRAM-SHA-1", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "foo" + }, + "options": { + "authmechanism": "SCRAM-SHA-1" + } + }, + { + "description": "should accept valid authSource (SCRAM-SHA-1)", + "uri": "mongodb://user:password@localhost/foo?authMechanism=SCRAM-SHA-1&authSource=bar", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "bar" + }, + "options": { + "authmechanism": "SCRAM-SHA-1" + } + }, + { + "description": "should throw an exception if no username (SCRAM-SHA-1)", + "uri": "mongodb://localhost/?authMechanism=SCRAM-SHA-1", + "hosts": null, + "valid": false, + "warning": false, + "auth": null, + "options": null + }, + { + "description": "should recognize the mechanism (SCRAM-SHA-256)", + "uri": "mongodb://user:password@localhost/?authMechanism=SCRAM-SHA-256", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "admin" + }, + "options": { + "authmechanism": "SCRAM-SHA-256" + } + }, + { + "description": "should use the database when no authSource is specified (SCRAM-SHA-256)", + "uri": "mongodb://user:password@localhost/foo?authMechanism=SCRAM-SHA-256", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "foo" + }, + "options": { + "authmechanism": "SCRAM-SHA-256" + } + }, + { + "description": "should accept valid authSource (SCRAM-SHA-256)", + "uri": "mongodb://user:password@localhost/foo?authMechanism=SCRAM-SHA-256&authSource=bar", + "hosts": null, + "valid": true, + "warning": false, + "auth": { + "username": "user", + "password": "password", + "db": "bar" + }, + "options": { + "authmechanism": "SCRAM-SHA-256" + } + }, + { + "description": "should throw an exception if no username (SCRAM-SHA-256)", + "uri": "mongodb://localhost/?authMechanism=SCRAM-SHA-256", + "hosts": null, + "valid": false, + "warning": false, + "auth": null, + "options": null + } + ] +} diff --git a/test/test_auth.py b/test/test_auth.py index ab0cbb71a..15a68b8ef 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -97,19 +97,19 @@ class TestGSSAPI(unittest.TestCase): def test_credentials_hashing(self): # GSSAPI credentials are properly hashed. creds0 = _build_credentials_tuple( - 'GSSAPI', '', 'user', 'pass', {}) + 'GSSAPI', None, 'user', 'pass', {}, None) creds1 = _build_credentials_tuple( - 'GSSAPI', '', 'user', 'pass', - {'authmechanismproperties': {'SERVICE_NAME': 'A'}}) + 'GSSAPI', None, 'user', 'pass', + {'authmechanismproperties': {'SERVICE_NAME': 'A'}}, None) creds2 = _build_credentials_tuple( - 'GSSAPI', '', 'user', 'pass', - {'authmechanismproperties': {'SERVICE_NAME': 'A'}}) + 'GSSAPI', None, 'user', 'pass', + {'authmechanismproperties': {'SERVICE_NAME': 'A'}}, None) creds3 = _build_credentials_tuple( - 'GSSAPI', '', 'user', 'pass', - {'authmechanismproperties': {'SERVICE_NAME': 'B'}}) + 'GSSAPI', None, 'user', 'pass', + {'authmechanismproperties': {'SERVICE_NAME': 'B'}}, None) self.assertEqual(1, len(set([creds1, creds2]))) self.assertEqual(3, len(set([creds0, creds1, creds2, creds3]))) diff --git a/test/test_auth_spec.py b/test/test_auth_spec.py new file mode 100644 index 000000000..947bdbb98 --- /dev/null +++ b/test/test_auth_spec.py @@ -0,0 +1,96 @@ +# Copyright 2018-present MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the auth spec tests.""" + +import glob +import json +import os +import sys + +sys.path[0:0] = [""] + +from pymongo import MongoClient +from test import unittest + + +_TEST_PATH = os.path.join( + os.path.dirname(os.path.realpath(__file__)), 'auth') + + +class TestAuthSpec(unittest.TestCase): + pass + + +def create_test(test_case): + + def run_test(self): + uri = test_case['uri'] + valid = test_case['valid'] + auth = test_case['auth'] + options = test_case['options'] + + if not valid: + self.assertRaises(Exception, MongoClient, uri, connect=False) + else: + client = MongoClient(uri, connect=False) + credentials = client._MongoClient__options.credentials + if auth is not None: + self.assertEqual(credentials.username, auth['username']) + self.assertEqual(credentials.password, auth['password']) + self.assertEqual(credentials.source, auth['db']) + if options is not None: + if 'authmechanism' in options: + self.assertEqual( + credentials.mechanism, options['authmechanism']) + else: + self.assertEqual(credentials.mechanism, 'DEFAULT') + if 'authmechanismproperties' in options: + expected = options['authmechanismproperties'] + actual = credentials.mechanism_properties + if 'SERVICE_NAME' in expected: + self.assertEqual( + actual.service_name, expected['SERVICE_NAME']) + if 'CANONICALIZE_HOST_NAME' in expected: + self.assertEqual( + actual.canonicalize_host_name, + expected['CANONICALIZE_HOST_NAME']) + if 'SERVICE_REALM' in expected: + self.assertEqual( + actual.service_realm, expected['SERVICE_REALM']) + + return run_test + + +def create_tests(): + for filename in glob.glob(os.path.join(_TEST_PATH, '*.json')): + test_suffix, _ = os.path.splitext(os.path.basename(filename)) + with open(filename) as auth_tests: + test_cases = json.load(auth_tests)['tests'] + for test_case in test_cases: + if test_case.get('optional', False): + continue + test_method = create_test(test_case) + name = str(test_case['description'].lower().replace(' ', '_')) + setattr( + TestAuthSpec, + 'test_%s_%s' % (test_suffix, name), + test_method) + + +create_tests() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_client.py b/test/test_client.py index 77fcc8168..e647a4788 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -1095,7 +1095,7 @@ class TestClient(IntegrationTest): # Simulate an authenticate() call on a different socket. credentials = auth._build_credentials_tuple( - 'DEFAULT', 'admin', db_user, db_pwd, {}) + 'DEFAULT', 'admin', db_user, db_pwd, {}, None) c._cache_credentials('test', credentials, connect=False)