PYTHON-706 - SCRAM-SHA-1 support for PyMongo 2.x

This commit is contained in:
Bernie Hackett 2014-09-05 06:48:59 -07:00
parent 793438e681
commit 93e7db4ec3
3 changed files with 181 additions and 5 deletions

View File

@ -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)
>>>

View File

@ -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,
}

View File

@ -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):