diff --git a/doc/examples/authentication.rst b/doc/examples/authentication.rst index ecff67fb9..51a9c3e82 100644 --- a/doc/examples/authentication.rst +++ b/doc/examples/authentication.rst @@ -172,3 +172,21 @@ the SASL PLAIN mechanism:: ... ssl_ca_certs='/path/to/ca.pem') >>> +SCRAM-SHA-1 (RFC 5802) +---------------------- +.. versionadded:: 2.8 + +MongoDB Enterprise Edition 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 e057e3fd9..6433e3e35 100644 --- a/pymongo/auth.py +++ b/pymongo/auth.py @@ -17,6 +17,7 @@ from __future__ import unicode_literals import hmac +import random HAVE_KERBEROS = True try: @@ -24,16 +25,18 @@ try: except ImportError: HAVE_KERBEROS = False +from base64 import standard_b64decode, standard_b64encode from collections import namedtuple -from hashlib import md5 +from hashlib import md5, sha1 from bson.binary import Binary -from bson.py3compat import b, string_type, _unicode +from bson.py3compat import b, string_type, _unicode, 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.""" @@ -53,6 +56,88 @@ def _build_credentials_tuple(mech, source, user, passwd, extra): return MongoCredential(mech, source, user, passwd, None) +if PY3: + def _xor(fir, sec): + """XOR two byte strings together (python 3.x).""" + return b"".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 b"".join([chr(ord(x) ^ ord(y)) for x, y in zip(fir, sec)]) + + +def _hi(data, salt, iterations): + """A simple implementation of PBKDF2.""" + _ui = _u1 = hmac.HMAC(data, salt + b'\x00\x00\x00\x01', sha1).digest() + for _ in range(iterations - 1): + _u1 = hmac.HMAC(data, _u1, sha1).digest() + _ui = _xor(_ui, _u1) + return _ui + + +def _parse_scram_response(response): + """Split a scram response into key, value pairs.""" + return dict(item.split(b"=", 1) for item in response.split(b",")) + + +def _authenticate_scram_sha1(credentials, sock_info, cmd_func): + """Authenticate using SCRAM-SHA-1.""" + username = credentials.username + password = credentials.password + source = credentials.source + + user = username.encode("utf-8").replace(b"=", b"=3D").replace(b",", b"=2C") + nonce = standard_b64encode( + (("%s" % (random.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.HMAC(salted_pass, b"Client Key", sha1).digest() + stored_key = sha1(client_key).digest() + auth_msg = b",".join((first_bare, server_first, without_proof)) + client_sig = hmac.HMAC(stored_key, auth_msg, sha1).digest() + client_proof = b"p=" + standard_b64encode(_xor(client_key, client_sig)) + client_final = b",".join((without_proof, client_proof)) + + server_key = hmac.HMAC(salted_pass, b"Server Key", sha1).digest() + server_sig = standard_b64encode( + hmac.HMAC(server_key, auth_msg, sha1).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(b''))]) + 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. """ @@ -235,6 +320,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 a52565a7e..2c72cc691 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -157,7 +157,7 @@ class TestGSSAPI(unittest.TestCase): self.assertTrue(thread.success) -class TestSASL(unittest.TestCase): +class TestSASLPlain(unittest.TestCase): @classmethod def setUpClass(cls): @@ -228,6 +228,59 @@ class TestSASL(unittest.TestCase): auth_string(SASL_USER, 'not-pwd')) + +class TestSCRAMSHA1(unittest.TestCase): + + @client_context.require_auth + @client_context.require_version_min(2, 7, 2) + def setUp(self): + self.set_name = client_context.setname + + cmd_line = client_context.cmd_line + if 'SCRAM-SHA-1' not in cmd_line.get( + 'parsed', {}).get('setParameter', + {}).get('authenticationMechanisms', ''): + raise SkipTest('SCRAM-SHA-1 mechanism not enabled') + + client = client_context.client + if self.set_name: + client.pymongo_test.add_user('user', 'pass', + roles=['userAdmin', 'readWrite'], + writeConcern={'w': client_context.w}) + 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): + client_context.client.pymongo_test.remove_user('user') + + class TestAuthURIOptions(unittest.TestCase): @client_context.require_auth