PYTHON-706 - SCRAM-SHA-1

This commit is contained in:
Bernie Hackett 2014-08-13 15:02:28 -07:00
parent 17fb3a2a02
commit bad4a109b4
3 changed files with 161 additions and 4 deletions

View File

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

View File

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

View File

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