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