Permalink
Browse files

Support GSSAPI (Kerberos) authentication PYTHON-465

This only works with the subscriber addition of MongoDB
>= 2.4 and requires the python "kerberos" module, sometimes
referred to as pykerberos.

http://pypi.python.org/pypi/kerberos

This change also adds support for the authMechanism
and authSource URI options.
  • Loading branch information...
1 parent 1a8e51c commit 18d20ed181f9090d1f6c291fac5c42877415122b @behackett behackett committed Feb 9, 2013
Showing with 448 additions and 32 deletions.
  1. +115 −3 pymongo/auth.py
  2. +13 −0 pymongo/common.py
  3. +29 −9 pymongo/database.py
  4. +15 −8 pymongo/mongo_client.py
  5. +13 −5 pymongo/mongo_replica_set_client.py
  6. +3 −2 pymongo/pool.py
  7. +3 −3 pymongo/uri_parser.py
  8. +191 −0 test/test_auth.py
  9. +66 −2 test/test_uri_parser.py
View
@@ -21,7 +21,29 @@
import md5
_MD5 = md5.new
+HAVE_KERBEROS = True
+try:
+ import kerberos
+except ImportError:
+ HAVE_KERBEROS = False
+
from bson.son import SON
+from pymongo.errors import OperationFailure
+
+
+MECHANISMS = ['MONGO-CR', 'GSSAPI']
+
+
+class MongoAuthenticationMechanism:
+ """An enum that defines the authentication mechanisms supported by
+ this release of Pymongo.
+
+ * `MONGO_CR`: MongoDB Challenge Response protocol.
+ * `GSSAPI`: Generic Security Services Application Programming Interface
+ (Called SSPI on Microsoft Windows) - Kerberos authentication.
+ """
+ MONGO_CR = MECHANISMS.index('MONGO-CR')
+ GSSAPI = MECHANISMS.index('GSSAPI')
def _password_digest(username, password):
@@ -52,10 +74,86 @@ def _auth_key(nonce, username, password):
return unicode(md5hash.hexdigest())
-def authenticate(credentials, sock_info, cmd_func):
- """Authenticate sock_info using credentials.
+def _authenticate_gssapi(username, sock_info, cmd_func):
+ """Authenticate using GSSAPI.
+ """
+ try:
+ # Starting here and continuing through the while loop below - establish
+ # the security context. See RFC 4752, Section 3.1, first paragraph.
+ result, ctx = kerberos.authGSSClientInit('mongodb@' + sock_info.host,
+ kerberos.GSS_C_MUTUAL_FLAG)
+ if result != kerberos.AUTH_GSS_COMPLETE:
+ raise OperationFailure('Kerberos context failed to initialize.')
+
+ try:
+ # pykerberos uses a weird mix of exceptions and return values
+ # to indicate errors.
+ # 0 == continue, 1 == complete, -1 == error
+ # Only authGSSClientStep can return 0.
+ if kerberos.authGSSClientStep(ctx, '') != 0:
+ raise OperationFailure('Unknown kerberos failure in step function.')
+
+ # Start a SASL conversation with mongod/s
+ # Note: pykerberos deals with base64 encoded byte strings.
+ # Since mongo accepts base64 strings as the payload we don't
+ # have to use bson.binary.Binary.
+ payload = kerberos.authGSSClientResponse(ctx)
+ cmd = SON([('saslStart', 1),
+ ('mechanism', 'GSSAPI'),
+ ('payload', payload),
+ ('autoAuthorize', 1)])
+ response, _ = cmd_func(sock_info, '$external', cmd)
+
+ # Limit how many times we loop to catch protocol / library issues
+ for _ in xrange(10):
+ result = kerberos.authGSSClientStep(ctx,
+ str(response['payload']))
+ if result == -1:
+ raise OperationFailure('Unknown kerberos '
+ 'failure in step function.')
+
+ payload = kerberos.authGSSClientResponse(ctx) or ''
+
+ cmd = SON([('saslContinue', 1),
+ ('conversationId', response['conversationId']),
+ ('payload', payload)])
+ response, _ = cmd_func(sock_info, '$external', cmd)
+
+ if result == kerberos.AUTH_GSS_COMPLETE:
+ break
+ else:
+ raise OperationFailure('Kerberos '
+ 'authentication failed to complete.')
+
+ # Once the security context is established actually authenticate.
+ # See RFC 4752, Section 3.1, last two paragraphs.
+ if kerberos.authGSSClientUnwrap(ctx,
+ str(response['payload'])) != 1:
+ raise OperationFailure('Unknown kerberos '
+ 'failure during GSS_Unwrap step.')
+
+ if kerberos.authGSSClientWrap(ctx,
+ kerberos.authGSSClientResponse(ctx),
+ username) != 1:
+ raise OperationFailure('Unknown kerberos '
+ 'failure during GSS_Wrap step.')
+
+ payload = kerberos.authGSSClientResponse(ctx)
+ cmd = SON([('saslContinue', 1),
+ ('conversationId', response['conversationId']),
+ ('payload', payload)])
+ response, _ = cmd_func(sock_info, '$external', cmd)
+
+ finally:
+ kerberos.authGSSClientClean(ctx)
+
+ except kerberos.KrbError, exc:
+ raise OperationFailure(str(exc))
+
+
+def _authenticate_mongo_cr(username, password, source, sock_info, cmd_func):
+ """Authenticate using MONGO-CR.
"""
- source, username, password = credentials
# Get a nonce
response, _ = cmd_func(sock_info, source, {'getnonce': 1})
nonce = response['nonce']
@@ -68,3 +166,17 @@ def authenticate(credentials, sock_info, cmd_func):
('key', key)])
cmd_func(sock_info, source, query)
+
+def authenticate(credentials, sock_info, cmd_func):
+ """Authenticate sock_info.
+ """
+ source, username, password, mechanism = credentials
+ # Use a dict for this when we support more mechanisms.
+ if mechanism:
+ if not HAVE_KERBEROS:
+ raise OperationFailure('The "kerberos" module must be '
+ 'installed to use GSSAPI authentication.')
+ _authenticate_gssapi(username, sock_info, cmd_func)
+ else:
+ _authenticate_mongo_cr(username, password, source, sock_info, cmd_func)
+
View
@@ -17,6 +17,7 @@
import warnings
from pymongo import read_preferences
+from pymongo.auth import MECHANISMS
from pymongo.read_preferences import ReadPreference
from pymongo.errors import ConfigurationError
@@ -154,6 +155,16 @@ def validate_tag_sets(dummy, value):
return value
+def validate_auth_mechanism(dummy, value):
+ """Validate the authMechanism URI option.
+ """
+ try:
+ return MECHANISMS.index(value)
+ except ValueError:
+ raise ConfigurationError("%s is not a supported "
+ "value for authMechanism" % (value,))
+
+
# jounal is an alias for j,
# wtimeoutms is an alias for wtimeout
VALIDATORS = {
@@ -177,6 +188,8 @@ def validate_tag_sets(dummy, value):
'secondary_acceptable_latency_ms': validate_positive_float,
'auto_start_request': validate_boolean,
'use_greenlets': validate_boolean,
+ 'authmechanism': validate_auth_mechanism,
+ 'authsource': validate_basestring,
}
View
@@ -19,6 +19,7 @@
from bson.dbref import DBRef
from bson.son import SON
from pymongo import auth, common, helpers
+from pymongo.auth import MECHANISMS, MongoAuthenticationMechanism
from pymongo.collection import Collection
from pymongo.errors import (CollectionInvalid,
InvalidName,
@@ -33,10 +34,13 @@ def _check_name(name):
if not name:
raise InvalidName("database name cannot be the empty string")
- for invalid_char in [" ", ".", "$", "/", "\\", "\x00"]:
- if invalid_char in name:
- raise InvalidName("database names cannot contain the "
- "character %r" % invalid_char)
+ # '$' is an illegal character in database names but $external
+ # is required for GSSAPI authentication.
+ if name != '$external':
+ for invalid_char in [" ", ".", "$", "/", "\\", "\x00"]:
+ if invalid_char in name:
+ raise InvalidName("database names cannot contain the "
+ "character %r" % invalid_char)
class Database(common.BaseObject):
@@ -651,7 +655,8 @@ def remove_user(self, name):
"""
self.system.users.remove({"user": name}, **self._get_wc_override())
- def authenticate(self, name, password):
+ def authenticate(self, name, password=None, source=None,
+ mechanism=MongoAuthenticationMechanism.MONGO_CR):
"""Authenticate to use this database.
Raises :class:`TypeError` if either `name` or `password` is not
@@ -682,20 +687,35 @@ def authenticate(self, name, password):
sockets using :meth:`~pymongo.mongo_client.MongoClient.disconnect`.
:Parameters:
- - `name`: the name of the user to authenticate
- - `password`: the password of the user to authenticate
+ - `name`: the name of the user to authenticate.
+ - `password` (optional): the password of the user to authenticate.
+ Not used with GSSAPI authentication.
+ - `source` (optional): the database to authenticate on. If not
+ specified the current database is used.
+ - `mechanism` (optional): See
+ :class:`pymongo.auth.MongoAuthenticationMechanism` for options.
+ Defaults to MONGO-CR (Mongo Challenge Response protocol)
.. mongodoc:: authenticate
"""
if not isinstance(name, basestring):
raise TypeError("name must be an instance "
"of %s" % (basestring.__name__,))
- if not isinstance(password, basestring):
+ if password is not None and not isinstance(password, basestring):
raise TypeError("password must be an instance "
"of %s" % (basestring.__name__,))
+ if source is not None and not isinstance(source, basestring):
+ raise TypeError("source must be an instance "
+ "of %s" % (basestring.__name__,))
+ try:
+ MECHANISMS[mechanism]
+ except IndexError:
+ raise ValueError("mechanism must be a member of "
+ "MongoAuthenticationMechanism")
try:
- credentials = (self.name, unicode(name), unicode(password))
+ credentials = (source or self.name, unicode(name),
+ password and unicode(password) or None, mechanism)
self.connection._cache_credentials(self.name, credentials)
return True
except OperationFailure:
View
@@ -182,7 +182,7 @@ def __init__(self, host=None, port=None, max_pool_size=10,
seeds = set()
username = None
password = None
- db = None
+ db_name = None
opts = {}
for entity in host:
if "://" in entity:
@@ -191,7 +191,7 @@ def __init__(self, host=None, port=None, max_pool_size=10,
seeds.update(res["nodelist"])
username = res["username"] or username
password = res["password"] or password
- db = res["database"] or db
+ db_name = res["database"] or db_name
opts = res["options"]
else:
idx = entity.find("://")
@@ -268,13 +268,20 @@ def __init__(self, host=None, port=None, max_pool_size=10,
# ConnectionFailure makes more sense here than AutoReconnect
raise ConnectionFailure(str(e))
- if db and username is None:
- warnings.warn("database name in URI is being ignored. If you wish "
- "to authenticate to %s, you must provide a username "
- "and password." % (db,))
+ db_name = options.get('authsource', db_name)
+ if db_name and username is None:
+ warnings.warn("database name or authSource in URI is being "
+ "ignored. If you wish to authenticate to %s, you "
+ "must provide a username and password." % (db_name,))
if username:
- db = db or "admin"
- if not self[db].authenticate(username, password):
+ mechanism = options.get('authmechanism',
+ auth.MECHANISMS.index('MONGO-CR'))
+ if mechanism == auth.MECHANISMS.index('GSSAPI'):
+ source = '$external'
+ else:
+ source = db_name or 'admin'
+ if not self[source].authenticate(username,
+ password, source, mechanism):
raise ConfigurationError("authentication failed")
def _cached(self, dbname, coll, index):
@@ -458,15 +458,23 @@ def __init__(self, hosts_or_uri=None, max_pool_size=10,
# ConnectionFailure makes more sense here than AutoReconnect
raise ConnectionFailure(str(e))
+ db_name = options.get('authsource', db_name)
if db_name and username is None:
- warnings.warn("database name in URI is being ignored. If you wish "
- "to authenticate to %s, you must provide a username "
- "and password." % (db_name,))
+ warnings.warn("database name or authSource in URI is being "
+ "ignored. If you wish to authenticate to %s, you "
+ "must provide a username and password." % (db_name,))
if username:
- db_name = db_name or 'admin'
- if not self[db_name].authenticate(username, password):
+ mechanism = options.get('authmechanism',
+ auth.MECHANISMS.index('MONGO-CR'))
+ if mechanism == auth.MECHANISMS.index('GSSAPI'):
+ source = '$external'
+ else:
+ source = db_name or 'admin'
+ if not self[source].authenticate(username,
+ password, source, mechanism):
raise ConfigurationError("authentication failed")
+
# Start the monitor after we know the configuration is correct.
if monitor_class:
self.__monitor = monitor_class(self)
View
@@ -54,8 +54,9 @@ def _closed(sock):
class SocketInfo(object):
"""Store a socket with some metadata
"""
- def __init__(self, sock, pool_id):
+ def __init__(self, sock, pool_id, host=None):
self.sock = sock
+ self.host = host
self.authset = set()
self.closed = False
self.last_checkout = time.time()
@@ -220,7 +221,7 @@ def connect(self, pair):
"not be configured with SSL support.")
sock.settimeout(self.net_timeout)
- return SocketInfo(sock, self.pool_id)
+ return SocketInfo(sock, self.pool_id, pair and pair[0] or self.pair[0])
def get_socket(self, pair=None):
"""Get a socket from the pool.
View
@@ -78,9 +78,9 @@ def parse_userinfo(userinfo):
raise InvalidURI("':' or '@' characters in a username or password "
"must be escaped according to RFC 2396.")
user, _, passwd = _partition(userinfo, ":")
- if not user or not passwd:
- raise InvalidURI("An empty string is not a "
- "valid username or password.")
+ # No password is expected with GSSAPI authentication.
+ if not user:
+ raise InvalidURI("The empty string is not valid username.")
user = unquote_plus(user)
passwd = unquote_plus(passwd)
Oops, something went wrong.

0 comments on commit 18d20ed

Please sign in to comment.