1# Copyright 2013-present MongoDB, Inc.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Authentication helpers."""
16
17import functools
18import hashlib
19import hmac
20import os
21import socket
22
23try:
24    from urllib import quote
25except ImportError:
26    from urllib.parse import quote
27
28HAVE_KERBEROS = True
29_USE_PRINCIPAL = False
30try:
31    import winkerberos as kerberos
32    if tuple(map(int, kerberos.__version__.split('.')[:2])) >= (0, 5):
33        _USE_PRINCIPAL = True
34except ImportError:
35    try:
36        import kerberos
37    except ImportError:
38        HAVE_KERBEROS = False
39
40from base64 import standard_b64decode, standard_b64encode
41from collections import namedtuple
42
43from bson.binary import Binary
44from bson.py3compat import string_type, _unicode, PY3
45from bson.son import SON
46from pymongo.auth_aws import _authenticate_aws
47from pymongo.errors import ConfigurationError, OperationFailure
48from pymongo.saslprep import saslprep
49
50
51MECHANISMS = frozenset(
52    ['GSSAPI',
53     'MONGODB-CR',
54     'MONGODB-X509',
55     'MONGODB-AWS',
56     'PLAIN',
57     'SCRAM-SHA-1',
58     'SCRAM-SHA-256',
59     'DEFAULT'])
60"""The authentication mechanisms supported by PyMongo."""
61
62
63class _Cache(object):
64    __slots__ = ("data",)
65
66    _hash_val = hash('_Cache')
67
68    def __init__(self):
69        self.data = None
70
71    def __eq__(self, other):
72        # Two instances must always compare equal.
73        if isinstance(other, _Cache):
74            return True
75        return NotImplemented
76
77    def __ne__(self, other):
78        if isinstance(other, _Cache):
79            return False
80        return NotImplemented
81
82    def __hash__(self):
83        return self._hash_val
84
85
86
87MongoCredential = namedtuple(
88    'MongoCredential',
89    ['mechanism',
90     'source',
91     'username',
92     'password',
93     'mechanism_properties',
94     'cache'])
95"""A hashable namedtuple of values used for authentication."""
96
97
98GSSAPIProperties = namedtuple('GSSAPIProperties',
99                              ['service_name',
100                               'canonicalize_host_name',
101                               'service_realm'])
102"""Mechanism properties for GSSAPI authentication."""
103
104
105_AWSProperties = namedtuple('AWSProperties', ['aws_session_token'])
106"""Mechanism properties for MONGODB-AWS authentication."""
107
108
109def _build_credentials_tuple(mech, source, user, passwd, extra, database):
110    """Build and return a mechanism specific credentials tuple.
111    """
112    if mech not in ('MONGODB-X509', 'MONGODB-AWS') and user is None:
113        raise ConfigurationError("%s requires a username." % (mech,))
114    if mech == 'GSSAPI':
115        if source is not None and source != '$external':
116            raise ValueError(
117                "authentication source must be $external or None for GSSAPI")
118        properties = extra.get('authmechanismproperties', {})
119        service_name = properties.get('SERVICE_NAME', 'mongodb')
120        canonicalize = properties.get('CANONICALIZE_HOST_NAME', False)
121        service_realm = properties.get('SERVICE_REALM')
122        props = GSSAPIProperties(service_name=service_name,
123                                 canonicalize_host_name=canonicalize,
124                                 service_realm=service_realm)
125        # Source is always $external.
126        return MongoCredential(mech, '$external', user, passwd, props, None)
127    elif mech == 'MONGODB-X509':
128        if passwd is not None:
129            raise ConfigurationError(
130                "Passwords are not supported by MONGODB-X509")
131        if source is not None and source != '$external':
132            raise ValueError(
133                "authentication source must be "
134                "$external or None for MONGODB-X509")
135        # Source is always $external, user can be None.
136        return MongoCredential(mech, '$external', user, None, None, None)
137    elif mech == 'MONGODB-AWS':
138        if user is not None and passwd is None:
139            raise ConfigurationError(
140                "username without a password is not supported by MONGODB-AWS")
141        if source is not None and source != '$external':
142            raise ConfigurationError(
143                "authentication source must be "
144                "$external or None for MONGODB-AWS")
145
146        properties = extra.get('authmechanismproperties', {})
147        aws_session_token = properties.get('AWS_SESSION_TOKEN')
148        props = _AWSProperties(aws_session_token=aws_session_token)
149        # user can be None for temporary link-local EC2 credentials.
150        return MongoCredential(mech, '$external', user, passwd, props, None)
151    elif mech == 'PLAIN':
152        source_database = source or database or '$external'
153        return MongoCredential(mech, source_database, user, passwd, None, None)
154    else:
155        source_database = source or database or 'admin'
156        if passwd is None:
157            raise ConfigurationError("A password is required.")
158        return MongoCredential(
159            mech, source_database, user, passwd, None, _Cache())
160
161
162if PY3:
163    def _xor(fir, sec):
164        """XOR two byte strings together (python 3.x)."""
165        return b"".join([bytes([x ^ y]) for x, y in zip(fir, sec)])
166
167
168    _from_bytes = int.from_bytes
169    _to_bytes = int.to_bytes
170else:
171    from binascii import (hexlify as _hexlify,
172                          unhexlify as _unhexlify)
173
174
175    def _xor(fir, sec):
176        """XOR two byte strings together (python 2.x)."""
177        return b"".join([chr(ord(x) ^ ord(y)) for x, y in zip(fir, sec)])
178
179
180    def _from_bytes(value, dummy, _int=int, _hexlify=_hexlify):
181        """An implementation of int.from_bytes for python 2.x."""
182        return _int(_hexlify(value), 16)
183
184
185    def _to_bytes(value, length, dummy, _unhexlify=_unhexlify):
186        """An implementation of int.to_bytes for python 2.x."""
187        fmt = '%%0%dx' % (2 * length,)
188        return _unhexlify(fmt % value)
189
190
191try:
192    # The fastest option, if it's been compiled to use OpenSSL's HMAC.
193    from backports.pbkdf2 import pbkdf2_hmac as _hi
194except ImportError:
195    try:
196        # Python 2.7.8+, or Python 3.4+.
197        from hashlib import pbkdf2_hmac as _hi
198    except ImportError:
199
200        def _hi(hash_name, data, salt, iterations):
201            """A simple implementation of PBKDF2-HMAC."""
202            mac = hmac.HMAC(data, None, getattr(hashlib, hash_name))
203
204            def _digest(msg, mac=mac):
205                """Get a digest for msg."""
206                _mac = mac.copy()
207                _mac.update(msg)
208                return _mac.digest()
209
210            from_bytes = _from_bytes
211            to_bytes = _to_bytes
212
213            _u1 = _digest(salt + b'\x00\x00\x00\x01')
214            _ui = from_bytes(_u1, 'big')
215            for _ in range(iterations - 1):
216                _u1 = _digest(_u1)
217                _ui ^= from_bytes(_u1, 'big')
218            return to_bytes(_ui, mac.digest_size, 'big')
219
220try:
221    from hmac import compare_digest
222except ImportError:
223    if PY3:
224        def _xor_bytes(a, b):
225            return a ^ b
226    else:
227        def _xor_bytes(a, b, _ord=ord):
228            return _ord(a) ^ _ord(b)
229
230    # Python 2.x < 2.7.7
231    # Note: This method is intentionally obtuse to prevent timing attacks. Do
232    # not refactor it!
233    # References:
234    #  - http://bugs.python.org/issue14532
235    #  - http://bugs.python.org/issue14955
236    #  - http://bugs.python.org/issue15061
237    def compare_digest(a, b, _xor_bytes=_xor_bytes):
238        left = None
239        right = b
240        if len(a) == len(b):
241            left = a
242            result = 0
243        if len(a) != len(b):
244            left = b
245            result = 1
246
247        for x, y in zip(left, right):
248            result |= _xor_bytes(x, y)
249        return result == 0
250
251
252def _parse_scram_response(response):
253    """Split a scram response into key, value pairs."""
254    return dict(item.split(b"=", 1) for item in response.split(b","))
255
256
257def _authenticate_scram_start(credentials, mechanism):
258    username = credentials.username
259    user = username.encode("utf-8").replace(b"=", b"=3D").replace(b",", b"=2C")
260    nonce = standard_b64encode(os.urandom(32))
261    first_bare = b"n=" + user + b",r=" + nonce
262
263    cmd = SON([('saslStart', 1),
264               ('mechanism', mechanism),
265               ('payload', Binary(b"n,," + first_bare)),
266               ('autoAuthorize', 1),
267               ('options', {'skipEmptyExchange': True})])
268    return nonce, first_bare, cmd
269
270
271def _authenticate_scram(credentials, sock_info, mechanism):
272    """Authenticate using SCRAM."""
273    username = credentials.username
274    if mechanism == 'SCRAM-SHA-256':
275        digest = "sha256"
276        digestmod = hashlib.sha256
277        data = saslprep(credentials.password).encode("utf-8")
278    else:
279        digest = "sha1"
280        digestmod = hashlib.sha1
281        data = _password_digest(username, credentials.password).encode("utf-8")
282    source = credentials.source
283    cache = credentials.cache
284
285    # Make local
286    _hmac = hmac.HMAC
287
288    ctx = sock_info.auth_ctx.get(credentials)
289    if ctx and ctx.speculate_succeeded():
290        nonce, first_bare = ctx.scram_data
291        res = ctx.speculative_authenticate
292    else:
293        nonce, first_bare, cmd = _authenticate_scram_start(
294            credentials, mechanism)
295        res = sock_info.command(source, cmd)
296
297    server_first = res['payload']
298    parsed = _parse_scram_response(server_first)
299    iterations = int(parsed[b'i'])
300    if iterations < 4096:
301        raise OperationFailure("Server returned an invalid iteration count.")
302    salt = parsed[b's']
303    rnonce = parsed[b'r']
304    if not rnonce.startswith(nonce):
305        raise OperationFailure("Server returned an invalid nonce.")
306
307    without_proof = b"c=biws,r=" + rnonce
308    if cache.data:
309        client_key, server_key, csalt, citerations = cache.data
310    else:
311        client_key, server_key, csalt, citerations = None, None, None, None
312
313    # Salt and / or iterations could change for a number of different
314    # reasons. Either changing invalidates the cache.
315    if not client_key or salt != csalt or iterations != citerations:
316        salted_pass = _hi(
317            digest, data, standard_b64decode(salt), iterations)
318        client_key = _hmac(salted_pass, b"Client Key", digestmod).digest()
319        server_key = _hmac(salted_pass, b"Server Key", digestmod).digest()
320        cache.data = (client_key, server_key, salt, iterations)
321    stored_key = digestmod(client_key).digest()
322    auth_msg = b",".join((first_bare, server_first, without_proof))
323    client_sig = _hmac(stored_key, auth_msg, digestmod).digest()
324    client_proof = b"p=" + standard_b64encode(_xor(client_key, client_sig))
325    client_final = b",".join((without_proof, client_proof))
326
327    server_sig = standard_b64encode(
328        _hmac(server_key, auth_msg, digestmod).digest())
329
330    cmd = SON([('saslContinue', 1),
331               ('conversationId', res['conversationId']),
332               ('payload', Binary(client_final))])
333    res = sock_info.command(source, cmd)
334
335    parsed = _parse_scram_response(res['payload'])
336    if not compare_digest(parsed[b'v'], server_sig):
337        raise OperationFailure("Server returned an invalid signature.")
338
339    # A third empty challenge may be required if the server does not support
340    # skipEmptyExchange: SERVER-44857.
341    if not res['done']:
342        cmd = SON([('saslContinue', 1),
343                   ('conversationId', res['conversationId']),
344                   ('payload', Binary(b''))])
345        res = sock_info.command(source, cmd)
346        if not res['done']:
347            raise OperationFailure('SASL conversation failed to complete.')
348
349
350def _password_digest(username, password):
351    """Get a password digest to use for authentication.
352    """
353    if not isinstance(password, string_type):
354        raise TypeError("password must be an "
355                        "instance of %s" % (string_type.__name__,))
356    if len(password) == 0:
357        raise ValueError("password can't be empty")
358    if not isinstance(username, string_type):
359        raise TypeError("password must be an "
360                        "instance of  %s" % (string_type.__name__,))
361
362    md5hash = hashlib.md5()
363    data = "%s:mongo:%s" % (username, password)
364    md5hash.update(data.encode('utf-8'))
365    return _unicode(md5hash.hexdigest())
366
367
368def _auth_key(nonce, username, password):
369    """Get an auth key to use for authentication.
370    """
371    digest = _password_digest(username, password)
372    md5hash = hashlib.md5()
373    data = "%s%s%s" % (nonce, username, digest)
374    md5hash.update(data.encode('utf-8'))
375    return _unicode(md5hash.hexdigest())
376
377
378def _canonicalize_hostname(hostname):
379    """Canonicalize hostname following MIT-krb5 behavior."""
380    # https://github.com/krb5/krb5/blob/d406afa363554097ac48646a29249c04f498c88e/src/util/k5test.py#L505-L520
381    af, socktype, proto, canonname, sockaddr = socket.getaddrinfo(
382        hostname, None, 0, 0, socket.IPPROTO_TCP, socket.AI_CANONNAME)[0]
383
384    try:
385        name = socket.getnameinfo(sockaddr, socket.NI_NAMEREQD)
386    except socket.gaierror:
387        return canonname.lower()
388
389    return name[0].lower()
390
391
392def _authenticate_gssapi(credentials, sock_info):
393    """Authenticate using GSSAPI.
394    """
395    if not HAVE_KERBEROS:
396        raise ConfigurationError('The "kerberos" module must be '
397                                 'installed to use GSSAPI authentication.')
398
399    try:
400        username = credentials.username
401        password = credentials.password
402        props = credentials.mechanism_properties
403        # Starting here and continuing through the while loop below - establish
404        # the security context. See RFC 4752, Section 3.1, first paragraph.
405        host = sock_info.address[0]
406        if props.canonicalize_host_name:
407            host = _canonicalize_hostname(host)
408        service = props.service_name + '@' + host
409        if props.service_realm is not None:
410            service = service + '@' + props.service_realm
411
412        if password is not None:
413            if _USE_PRINCIPAL:
414                # Note that, though we use unquote_plus for unquoting URI
415                # options, we use quote here. Microsoft's UrlUnescape (used
416                # by WinKerberos) doesn't support +.
417                principal = ":".join((quote(username), quote(password)))
418                result, ctx = kerberos.authGSSClientInit(
419                    service, principal, gssflags=kerberos.GSS_C_MUTUAL_FLAG)
420            else:
421                if '@' in username:
422                    user, domain = username.split('@', 1)
423                else:
424                    user, domain = username, None
425                result, ctx = kerberos.authGSSClientInit(
426                    service, gssflags=kerberos.GSS_C_MUTUAL_FLAG,
427                    user=user, domain=domain, password=password)
428        else:
429            result, ctx = kerberos.authGSSClientInit(
430                service, gssflags=kerberos.GSS_C_MUTUAL_FLAG)
431
432        if result != kerberos.AUTH_GSS_COMPLETE:
433            raise OperationFailure('Kerberos context failed to initialize.')
434
435        try:
436            # pykerberos uses a weird mix of exceptions and return values
437            # to indicate errors.
438            # 0 == continue, 1 == complete, -1 == error
439            # Only authGSSClientStep can return 0.
440            if kerberos.authGSSClientStep(ctx, '') != 0:
441                raise OperationFailure('Unknown kerberos '
442                                       'failure in step function.')
443
444            # Start a SASL conversation with mongod/s
445            # Note: pykerberos deals with base64 encoded byte strings.
446            # Since mongo accepts base64 strings as the payload we don't
447            # have to use bson.binary.Binary.
448            payload = kerberos.authGSSClientResponse(ctx)
449            cmd = SON([('saslStart', 1),
450                       ('mechanism', 'GSSAPI'),
451                       ('payload', payload),
452                       ('autoAuthorize', 1)])
453            response = sock_info.command('$external', cmd)
454
455            # Limit how many times we loop to catch protocol / library issues
456            for _ in range(10):
457                result = kerberos.authGSSClientStep(ctx,
458                                                    str(response['payload']))
459                if result == -1:
460                    raise OperationFailure('Unknown kerberos '
461                                           'failure in step function.')
462
463                payload = kerberos.authGSSClientResponse(ctx) or ''
464
465                cmd = SON([('saslContinue', 1),
466                           ('conversationId', response['conversationId']),
467                           ('payload', payload)])
468                response = sock_info.command('$external', cmd)
469
470                if result == kerberos.AUTH_GSS_COMPLETE:
471                    break
472            else:
473                raise OperationFailure('Kerberos '
474                                       'authentication failed to complete.')
475
476            # Once the security context is established actually authenticate.
477            # See RFC 4752, Section 3.1, last two paragraphs.
478            if kerberos.authGSSClientUnwrap(ctx,
479                                            str(response['payload'])) != 1:
480                raise OperationFailure('Unknown kerberos '
481                                       'failure during GSS_Unwrap step.')
482
483            if kerberos.authGSSClientWrap(ctx,
484                                          kerberos.authGSSClientResponse(ctx),
485                                          username) != 1:
486                raise OperationFailure('Unknown kerberos '
487                                       'failure during GSS_Wrap step.')
488
489            payload = kerberos.authGSSClientResponse(ctx)
490            cmd = SON([('saslContinue', 1),
491                       ('conversationId', response['conversationId']),
492                       ('payload', payload)])
493            sock_info.command('$external', cmd)
494
495        finally:
496            kerberos.authGSSClientClean(ctx)
497
498    except kerberos.KrbError as exc:
499        raise OperationFailure(str(exc))
500
501
502def _authenticate_plain(credentials, sock_info):
503    """Authenticate using SASL PLAIN (RFC 4616)
504    """
505    source = credentials.source
506    username = credentials.username
507    password = credentials.password
508    payload = ('\x00%s\x00%s' % (username, password)).encode('utf-8')
509    cmd = SON([('saslStart', 1),
510               ('mechanism', 'PLAIN'),
511               ('payload', Binary(payload)),
512               ('autoAuthorize', 1)])
513    sock_info.command(source, cmd)
514
515
516def _authenticate_cram_md5(credentials, sock_info):
517    """Authenticate using CRAM-MD5 (RFC 2195)
518    """
519    source = credentials.source
520    username = credentials.username
521    password = credentials.password
522    # The password used as the mac key is the
523    # same as what we use for MONGODB-CR
524    passwd = _password_digest(username, password)
525    cmd = SON([('saslStart', 1),
526               ('mechanism', 'CRAM-MD5'),
527               ('payload', Binary(b'')),
528               ('autoAuthorize', 1)])
529    response = sock_info.command(source, cmd)
530    # MD5 as implicit default digest for digestmod is deprecated
531    # in python 3.4
532    mac = hmac.HMAC(key=passwd.encode('utf-8'), digestmod=hashlib.md5)
533    mac.update(response['payload'])
534    challenge = username.encode('utf-8') + b' ' + mac.hexdigest().encode('utf-8')
535    cmd = SON([('saslContinue', 1),
536               ('conversationId', response['conversationId']),
537               ('payload', Binary(challenge))])
538    sock_info.command(source, cmd)
539
540
541def _authenticate_x509(credentials, sock_info):
542    """Authenticate using MONGODB-X509.
543    """
544    ctx = sock_info.auth_ctx.get(credentials)
545    if ctx and ctx.speculate_succeeded():
546        # MONGODB-X509 is done after the speculative auth step.
547        return
548
549    cmd = _X509Context(credentials).speculate_command()
550    if credentials.username is None and sock_info.max_wire_version < 5:
551        raise ConfigurationError(
552            "A username is required for MONGODB-X509 authentication "
553            "when connected to MongoDB versions older than 3.4.")
554    sock_info.command('$external', cmd)
555
556
557def _authenticate_mongo_cr(credentials, sock_info):
558    """Authenticate using MONGODB-CR.
559    """
560    source = credentials.source
561    username = credentials.username
562    password = credentials.password
563    # Get a nonce
564    response = sock_info.command(source, {'getnonce': 1})
565    nonce = response['nonce']
566    key = _auth_key(nonce, username, password)
567
568    # Actually authenticate
569    query = SON([('authenticate', 1),
570                 ('user', username),
571                 ('nonce', nonce),
572                 ('key', key)])
573    sock_info.command(source, query)
574
575
576def _authenticate_default(credentials, sock_info):
577    if sock_info.max_wire_version >= 7:
578        if credentials in sock_info.negotiated_mechanisms:
579            mechs = sock_info.negotiated_mechanisms[credentials]
580        else:
581            source = credentials.source
582            cmd = sock_info.hello_cmd()
583            cmd['saslSupportedMechs'] = source + '.' + credentials.username
584            mechs = sock_info.command(
585                source, cmd, publish_events=False).get(
586                'saslSupportedMechs', [])
587        if 'SCRAM-SHA-256' in mechs:
588            return _authenticate_scram(credentials, sock_info, 'SCRAM-SHA-256')
589        else:
590            return _authenticate_scram(credentials, sock_info, 'SCRAM-SHA-1')
591    elif sock_info.max_wire_version >= 3:
592        return _authenticate_scram(credentials, sock_info, 'SCRAM-SHA-1')
593    else:
594        return _authenticate_mongo_cr(credentials, sock_info)
595
596
597_AUTH_MAP = {
598    'CRAM-MD5': _authenticate_cram_md5,
599    'GSSAPI': _authenticate_gssapi,
600    'MONGODB-CR': _authenticate_mongo_cr,
601    'MONGODB-X509': _authenticate_x509,
602    'MONGODB-AWS': _authenticate_aws,
603    'PLAIN': _authenticate_plain,
604    'SCRAM-SHA-1': functools.partial(
605        _authenticate_scram, mechanism='SCRAM-SHA-1'),
606    'SCRAM-SHA-256': functools.partial(
607        _authenticate_scram, mechanism='SCRAM-SHA-256'),
608    'DEFAULT': _authenticate_default,
609}
610
611
612class _AuthContext(object):
613    def __init__(self, credentials):
614        self.credentials = credentials
615        self.speculative_authenticate = None
616
617    @staticmethod
618    def from_credentials(creds):
619        spec_cls = _SPECULATIVE_AUTH_MAP.get(creds.mechanism)
620        if spec_cls:
621            return spec_cls(creds)
622        return None
623
624    def speculate_command(self):
625        raise NotImplementedError
626
627    def parse_response(self, hello):
628        self.speculative_authenticate = hello.speculative_authenticate
629
630    def speculate_succeeded(self):
631        return bool(self.speculative_authenticate)
632
633
634class _ScramContext(_AuthContext):
635    def __init__(self, credentials, mechanism):
636        super(_ScramContext, self).__init__(credentials)
637        self.scram_data = None
638        self.mechanism = mechanism
639
640    def speculate_command(self):
641        nonce, first_bare, cmd = _authenticate_scram_start(
642            self.credentials, self.mechanism)
643        # The 'db' field is included only on the speculative command.
644        cmd['db'] = self.credentials.source
645        # Save for later use.
646        self.scram_data = (nonce, first_bare)
647        return cmd
648
649
650class _X509Context(_AuthContext):
651    def speculate_command(self):
652        cmd = SON([('authenticate', 1),
653                   ('mechanism', 'MONGODB-X509')])
654        if self.credentials.username is not None:
655            cmd['user'] = self.credentials.username
656        return cmd
657
658
659_SPECULATIVE_AUTH_MAP = {
660    'MONGODB-X509': _X509Context,
661    'SCRAM-SHA-1': functools.partial(_ScramContext, mechanism='SCRAM-SHA-1'),
662    'SCRAM-SHA-256': functools.partial(_ScramContext,
663                                       mechanism='SCRAM-SHA-256'),
664    'DEFAULT': functools.partial(_ScramContext, mechanism='SCRAM-SHA-256'),
665}
666
667
668def authenticate(credentials, sock_info):
669    """Authenticate sock_info."""
670    mechanism = credentials.mechanism
671    auth_func = _AUTH_MAP.get(mechanism)
672    auth_func(credentials, sock_info)
673
674
675def logout(source, sock_info):
676    """Log out from a database."""
677    sock_info.command(source, {'logout': 1})
678