PYTHON-706 - SCRAM-SHA-1 support for PyMongo 2.x
This commit is contained in:
parent
793438e681
commit
93e7db4ec3
@ -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)
|
||||
>>>
|
||||
|
||||
112
pymongo/auth.py
112
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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user