diff --git a/doc/examples/authentication.rst b/doc/examples/authentication.rst index 7c381d16e..21f6cdd7a 100644 --- a/doc/examples/authentication.rst +++ b/doc/examples/authentication.rst @@ -178,3 +178,19 @@ the SASL PLAIN mechanism:: ... ssl_ca_certs='/path/to/ca.pem') >>> +SCRAM-SHA-1 (RFC 5802) +---------------------- +.. versionadded:: 2.8 + +MongoDB 2.7.2 and above support the SCRAM-SHA-1 mechanism. Authentication is +per-database and credentials can be specified through the MongoDB URI or passed +to the :meth:`~pymongo.database.Database.authenticate` method:: + + >>> from pymongo import MongoClient + >>> client = MongoClient('example.com') + >>> client.the_database.authenticate('user', 'password', mechanism='SCRAM-SHA-1') + True + >>> + >>> uri = "mongodb://user:password@example.com/the_database?authMechanism=SCRAM-SHA-1" + >>> client = MongoClient(uri) + >>> diff --git a/pymongo/auth.py b/pymongo/auth.py index f8d3513d9..f465dc4de 100644 --- a/pymongo/auth.py +++ b/pymongo/auth.py @@ -18,10 +18,14 @@ import hmac try: import hashlib _MD5 = hashlib.md5 + _SHA1 = hashlib.sha1 + _SHA1MOD = _SHA1 _DMOD = _MD5 except ImportError: # for Python < 2.5 - import md5 + import md5, sha _MD5 = md5.new + _SHA1 = sha.new + _SHA1MOD = sha _DMOD = md5 HAVE_KERBEROS = True @@ -30,13 +34,17 @@ try: except ImportError: HAVE_KERBEROS = False +from base64 import standard_b64decode, standard_b64encode +from random import SystemRandom + from bson.binary import Binary -from bson.py3compat import b +from bson.py3compat import b, PY3 from bson.son import SON from pymongo.errors import ConfigurationError, OperationFailure -MECHANISMS = frozenset(['GSSAPI', 'MONGODB-CR', 'MONGODB-X509', 'PLAIN']) +MECHANISMS = frozenset( + ['GSSAPI', 'MONGODB-CR', 'MONGODB-X509', 'PLAIN', 'SCRAM-SHA-1']) """The authentication mechanisms supported by PyMongo.""" @@ -52,6 +60,103 @@ def _build_credentials_tuple(mech, source, user, passwd, extra): return (mech, source, user, passwd) +if PY3: + def _xor(fir, sec): + """XOR two byte strings together (python 3.x).""" + return _EMPTY.join([bytes([x ^ y]) for x, y in zip(fir, sec)]) +else: + def _xor(fir, sec): + """XOR two byte strings together (python 2.x).""" + return _EMPTY.join([chr(ord(x) ^ ord(y)) for x, y in zip(fir, sec)]) + +_BIGONE = b('\x00\x00\x00\x01') + +def _hi(data, salt, iterations): + """A simple implementation of PBKDF2.""" + mac = hmac.HMAC(data, None, _SHA1MOD) + + def _digest(msg, mac=mac): + """Get a digest for msg.""" + _mac = mac.copy() + _mac.update(msg) + return _mac.digest() + + _ui = _u1 = _digest(salt + _BIGONE) + for _ in range(iterations - 1): + _u1 = _digest(_u1) + _ui = _xor(_ui, _u1) + return _ui + +_EMPTY = b("") +_COMMA = b(",") +_EQUAL = b("=") + +def _parse_scram_response(response): + """Split a scram response into key, value pairs.""" + return dict([item.split(_EQUAL, 1) for item in response.split(_COMMA)]) + +def _authenticate_scram_sha1(credentials, sock_info, cmd_func): + """Authenticate using SCRAM-SHA-1.""" + source, username, password = credentials + + # Make local + _hmac = hmac.HMAC + _sha1 = _SHA1 + _sha1mod = _SHA1MOD + + user = username.encode("utf-8").replace( + _EQUAL, b("=3D")).replace(_COMMA, b("=2C")) + nonce = standard_b64encode( + (("%s" % (SystemRandom().random(),))[2:]).encode("utf-8")) + first_bare = b("n=") + user + b(",r=") + nonce + + cmd = SON([('saslStart', 1), + ('mechanism', 'SCRAM-SHA-1'), + ('payload', Binary(b("n,,") + first_bare)), + ('autoAuthorize', 1)]) + res, _ = cmd_func(sock_info, source, cmd) + + server_first = res['payload'] + parsed = _parse_scram_response(server_first) + iterations = int(parsed[b('i')]) + salt = parsed[b('s')] + rnonce = parsed[b('r')] + assert rnonce.startswith(nonce) + + without_proof = b("c=biws,r=") + rnonce + salted_pass = _hi(_password_digest(username, password).encode("utf-8"), + standard_b64decode(salt), + iterations) + client_key = _hmac(salted_pass, b("Client Key"), _sha1mod).digest() + stored_key = _sha1(client_key).digest() + auth_msg = _COMMA.join((first_bare, server_first, without_proof)) + client_sig = _hmac(stored_key, auth_msg, _sha1mod).digest() + client_proof = b("p=") + standard_b64encode(_xor(client_key, client_sig)) + client_final = _COMMA.join((without_proof, client_proof)) + + server_key = _hmac(salted_pass, b("Server Key"), _sha1mod).digest() + server_sig = standard_b64encode( + _hmac(server_key, auth_msg, _SHA1MOD).digest()) + + cmd = SON([('saslContinue', 1), + ('conversationId', res['conversationId']), + ('payload', Binary(client_final))]) + res, _ = cmd_func(sock_info, source, cmd) + + parsed = _parse_scram_response(res['payload']) + assert parsed[b('v')] == server_sig + + # Depending on how it's configured, Cyrus SASL (which the server uses) + # requires a third empty challenge. + if not res['done']: + cmd = SON([('saslContinue', 1), + ('conversationId', res['conversationId']), + ('payload', Binary(_EMPTY))]) + res, _ = cmd_func(sock_info, source, cmd) + if not res['done']: + raise OperationFailure('SASL conversation failed to complete.') + + def _password_digest(username, password): """Get a password digest to use for authentication. """ @@ -228,6 +333,7 @@ _AUTH_MAP = { 'MONGODB-CR': _authenticate_mongo_cr, 'MONGODB-X509': _authenticate_x509, 'PLAIN': _authenticate_plain, + 'SCRAM-SHA-1': _authenticate_scram_sha1, } diff --git a/test/test_auth.py b/test/test_auth.py index 767b1c07e..0ccfd0766 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -48,7 +48,8 @@ from test.utils import (is_mongos, one, catch_warnings, TestRequestMixin, - joinall) + joinall, + get_command_line) # YOU MUST RUN KINIT BEFORE RUNNING GSSAPI TESTS. GSSAPI_HOST = os.environ.get('GSSAPI_HOST') @@ -188,7 +189,7 @@ class TestGSSAPI(unittest.TestCase): self.assertTrue(thread.success) -class TestSASL(unittest.TestCase): +class TestSASLPlain(unittest.TestCase): def setUp(self): if not SASL_HOST or not SASL_USER or not SASL_PASS: @@ -258,6 +259,59 @@ class TestSASL(unittest.TestCase): auth_string(SASL_USER, 'not-pwd')) +class TestSCRAMSHA1(unittest.TestCase): + + def setUp(self): + client = auth_context.client + if not version.at_least(client, (2, 7, 2)): + raise SkipTest("SCRAM-SHA-1 requires MongoDB >= 2.7.2") + ismaster = client.admin.command('ismaster') + self.set_name = ismaster.get('setName') + + cmd_line = get_command_line(client) + if 'SCRAM-SHA-1' not in cmd_line.get( + 'parsed', {}).get('setParameter', + {}).get('authenticationMechanisms', ''): + raise SkipTest('SCRAM-SHA-1 mechanism not enabled') + + if self.set_name: + client.pymongo_test.add_user('user', 'pass', + roles=['userAdmin', 'readWrite'], + writeConcern={'w': len(ismaster['hosts'])}) + else: + client.pymongo_test.add_user( + 'user', 'pass', roles=['userAdmin', 'readWrite']) + + + def test_scram_sha1(self): + client = MongoClient(host, port) + self.assertTrue(client.pymongo_test.authenticate( + 'user', 'pass', mechanism='SCRAM-SHA-1')) + client.pymongo_test.command('dbstats') + + client = MongoClient('mongodb://user:pass@%s:%d/pymongo_test' + '?authMechanism=SCRAM-SHA-1' % (host, port)) + client.pymongo_test.command('dbstats') + + if self.set_name: + client = MongoReplicaSetClient(host, port, + replicaSet='%s' % (self.set_name,)) + self.assertTrue(client.pymongo_test.authenticate( + 'user', 'pass', mechanism='SCRAM-SHA-1')) + client.pymongo_test.command('dbstats') + + uri = ('mongodb://user:pass' + '@%s:%d/pymongo_test?authMechanism=SCRAM-SHA-1' + '&replicaSet=%s' % (host, port, self.set_name)) + client = MongoReplicaSetClient(uri) + client.pymongo_test.command('dbstats') + client.read_preference = ReadPreference.SECONDARY + client.pymongo_test.command('dbstats') + + def tearDown(self): + auth_context.client.pymongo_test.remove_user('user') + + class TestAuthURIOptions(unittest.TestCase): def setUp(self):