auth.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. # Copyright 2013-2015 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. """Authentication helpers."""
  15. import hmac
  16. HAVE_KERBEROS = True
  17. try:
  18. import kerberos
  19. except ImportError:
  20. HAVE_KERBEROS = False
  21. from base64 import standard_b64decode, standard_b64encode
  22. from collections import namedtuple
  23. from hashlib import md5, sha1
  24. from random import SystemRandom
  25. from bson.binary import Binary
  26. from bson.py3compat import b, string_type, _unicode, PY3
  27. from bson.son import SON
  28. from pymongo.errors import ConfigurationError, OperationFailure
  29. MECHANISMS = frozenset(
  30. ['GSSAPI', 'MONGODB-CR', 'MONGODB-X509', 'PLAIN', 'SCRAM-SHA-1', 'DEFAULT'])
  31. """The authentication mechanisms supported by PyMongo."""
  32. MongoCredential = namedtuple(
  33. 'MongoCredential',
  34. ['mechanism', 'source', 'username', 'password', 'mechanism_properties'])
  35. """A hashable namedtuple of values used for authentication."""
  36. GSSAPIProperties = namedtuple('GSSAPIProperties', ['service_name'])
  37. """Mechanism properties for GSSAPI authentication."""
  38. def _build_credentials_tuple(mech, source, user, passwd, extra):
  39. """Build and return a mechanism specific credentials tuple.
  40. """
  41. user = _unicode(user)
  42. if mech == 'GSSAPI':
  43. properties = extra.get('authmechanismproperties', {})
  44. service_name = properties.get('SERVICE_NAME', 'mongodb')
  45. props = GSSAPIProperties(service_name=service_name)
  46. # No password, source is always $external.
  47. return MongoCredential(mech, '$external', user, None, props)
  48. elif mech == 'MONGODB-X509':
  49. return MongoCredential(mech, '$external', user, None, None)
  50. else:
  51. if passwd is None:
  52. raise ConfigurationError("A password is required.")
  53. return MongoCredential(mech, source, user, _unicode(passwd), None)
  54. if PY3:
  55. def _xor(fir, sec):
  56. """XOR two byte strings together (python 3.x)."""
  57. return b"".join([bytes([x ^ y]) for x, y in zip(fir, sec)])
  58. _from_bytes = int.from_bytes
  59. _to_bytes = int.to_bytes
  60. else:
  61. from binascii import (hexlify as _hexlify,
  62. unhexlify as _unhexlify)
  63. def _xor(fir, sec):
  64. """XOR two byte strings together (python 2.x)."""
  65. return b"".join([chr(ord(x) ^ ord(y)) for x, y in zip(fir, sec)])
  66. def _from_bytes(value, dummy, int=int, _hexlify=_hexlify):
  67. """An implementation of int.from_bytes for python 2.x."""
  68. return int(_hexlify(value), 16)
  69. def _to_bytes(value, dummy0, dummy1, _unhexlify=_unhexlify):
  70. """An implementation of int.to_bytes for python 2.x."""
  71. return _unhexlify('%040x' % value)
  72. try:
  73. # The fastest option, if it's been compiled to use OpenSSL's HMAC.
  74. from backports.pbkdf2 import pbkdf2_hmac
  75. def _hi(data, salt, iterations):
  76. return pbkdf2_hmac('sha1', data, salt, iterations)
  77. except ImportError:
  78. try:
  79. # Python 2.7.8+, or Python 3.4+.
  80. from hashlib import pbkdf2_hmac
  81. def _hi(data, salt, iterations):
  82. return pbkdf2_hmac('sha1', data, salt, iterations)
  83. except ImportError:
  84. def _hi(data, salt, iterations):
  85. """A simple implementation of PBKDF2."""
  86. mac = hmac.HMAC(data, None, sha1)
  87. def _digest(msg, mac=mac):
  88. """Get a digest for msg."""
  89. _mac = mac.copy()
  90. _mac.update(msg)
  91. return _mac.digest()
  92. from_bytes = _from_bytes
  93. to_bytes = _to_bytes
  94. _u1 = _digest(salt + b'\x00\x00\x00\x01')
  95. _ui = from_bytes(_u1, 'big')
  96. for _ in range(iterations - 1):
  97. _u1 = _digest(_u1)
  98. _ui ^= from_bytes(_u1, 'big')
  99. return to_bytes(_ui, 20, 'big')
  100. def _parse_scram_response(response):
  101. """Split a scram response into key, value pairs."""
  102. return dict(item.split(b"=", 1) for item in response.split(b","))
  103. def _authenticate_scram_sha1(credentials, sock_info):
  104. """Authenticate using SCRAM-SHA-1."""
  105. username = credentials.username
  106. password = credentials.password
  107. source = credentials.source
  108. # Make local
  109. _hmac = hmac.HMAC
  110. _sha1 = sha1
  111. user = username.encode("utf-8").replace(b"=", b"=3D").replace(b",", b"=2C")
  112. nonce = standard_b64encode(
  113. (("%s" % (SystemRandom().random(),))[2:]).encode("utf-8"))
  114. first_bare = b"n=" + user + b",r=" + nonce
  115. cmd = SON([('saslStart', 1),
  116. ('mechanism', 'SCRAM-SHA-1'),
  117. ('payload', Binary(b"n,," + first_bare)),
  118. ('autoAuthorize', 1)])
  119. res = sock_info.command(source, cmd)
  120. server_first = res['payload']
  121. parsed = _parse_scram_response(server_first)
  122. iterations = int(parsed[b'i'])
  123. salt = parsed[b's']
  124. rnonce = parsed[b'r']
  125. if not rnonce.startswith(nonce):
  126. raise OperationFailure("Server returned an invalid nonce.")
  127. without_proof = b"c=biws,r=" + rnonce
  128. salted_pass = _hi(_password_digest(username, password).encode("utf-8"),
  129. standard_b64decode(salt),
  130. iterations)
  131. client_key = _hmac(salted_pass, b"Client Key", _sha1).digest()
  132. stored_key = _sha1(client_key).digest()
  133. auth_msg = b",".join((first_bare, server_first, without_proof))
  134. client_sig = _hmac(stored_key, auth_msg, _sha1).digest()
  135. client_proof = b"p=" + standard_b64encode(_xor(client_key, client_sig))
  136. client_final = b",".join((without_proof, client_proof))
  137. server_key = _hmac(salted_pass, b"Server Key", _sha1).digest()
  138. server_sig = standard_b64encode(
  139. _hmac(server_key, auth_msg, _sha1).digest())
  140. cmd = SON([('saslContinue', 1),
  141. ('conversationId', res['conversationId']),
  142. ('payload', Binary(client_final))])
  143. res = sock_info.command(source, cmd)
  144. parsed = _parse_scram_response(res['payload'])
  145. if parsed[b'v'] != server_sig:
  146. raise OperationFailure("Server returned an invalid signature.")
  147. # Depending on how it's configured, Cyrus SASL (which the server uses)
  148. # requires a third empty challenge.
  149. if not res['done']:
  150. cmd = SON([('saslContinue', 1),
  151. ('conversationId', res['conversationId']),
  152. ('payload', Binary(b''))])
  153. res = sock_info.command(source, cmd)
  154. if not res['done']:
  155. raise OperationFailure('SASL conversation failed to complete.')
  156. def _password_digest(username, password):
  157. """Get a password digest to use for authentication.
  158. """
  159. if not isinstance(password, string_type):
  160. raise TypeError("password must be an "
  161. "instance of %s" % (string_type.__name__,))
  162. if len(password) == 0:
  163. raise ValueError("password can't be empty")
  164. if not isinstance(username, string_type):
  165. raise TypeError("password must be an "
  166. "instance of %s" % (string_type.__name__,))
  167. md5hash = md5()
  168. data = "%s:mongo:%s" % (username, password)
  169. md5hash.update(data.encode('utf-8'))
  170. return _unicode(md5hash.hexdigest())
  171. def _auth_key(nonce, username, password):
  172. """Get an auth key to use for authentication.
  173. """
  174. digest = _password_digest(username, password)
  175. md5hash = md5()
  176. data = "%s%s%s" % (nonce, username, digest)
  177. md5hash.update(data.encode('utf-8'))
  178. return _unicode(md5hash.hexdigest())
  179. def _authenticate_gssapi(credentials, sock_info):
  180. """Authenticate using GSSAPI.
  181. """
  182. if not HAVE_KERBEROS:
  183. raise ConfigurationError('The "kerberos" module must be '
  184. 'installed to use GSSAPI authentication.')
  185. try:
  186. username = credentials.username
  187. gsn = credentials.mechanism_properties.service_name
  188. # Starting here and continuing through the while loop below - establish
  189. # the security context. See RFC 4752, Section 3.1, first paragraph.
  190. result, ctx = kerberos.authGSSClientInit(
  191. gsn + '@' + sock_info.host, gssflags=kerberos.GSS_C_MUTUAL_FLAG)
  192. if result != kerberos.AUTH_GSS_COMPLETE:
  193. raise OperationFailure('Kerberos context failed to initialize.')
  194. try:
  195. # pykerberos uses a weird mix of exceptions and return values
  196. # to indicate errors.
  197. # 0 == continue, 1 == complete, -1 == error
  198. # Only authGSSClientStep can return 0.
  199. if kerberos.authGSSClientStep(ctx, '') != 0:
  200. raise OperationFailure('Unknown kerberos '
  201. 'failure in step function.')
  202. # Start a SASL conversation with mongod/s
  203. # Note: pykerberos deals with base64 encoded byte strings.
  204. # Since mongo accepts base64 strings as the payload we don't
  205. # have to use bson.binary.Binary.
  206. payload = kerberos.authGSSClientResponse(ctx)
  207. cmd = SON([('saslStart', 1),
  208. ('mechanism', 'GSSAPI'),
  209. ('payload', payload),
  210. ('autoAuthorize', 1)])
  211. response = sock_info.command('$external', cmd)
  212. # Limit how many times we loop to catch protocol / library issues
  213. for _ in range(10):
  214. result = kerberos.authGSSClientStep(ctx,
  215. str(response['payload']))
  216. if result == -1:
  217. raise OperationFailure('Unknown kerberos '
  218. 'failure in step function.')
  219. payload = kerberos.authGSSClientResponse(ctx) or ''
  220. cmd = SON([('saslContinue', 1),
  221. ('conversationId', response['conversationId']),
  222. ('payload', payload)])
  223. response = sock_info.command('$external', cmd)
  224. if result == kerberos.AUTH_GSS_COMPLETE:
  225. break
  226. else:
  227. raise OperationFailure('Kerberos '
  228. 'authentication failed to complete.')
  229. # Once the security context is established actually authenticate.
  230. # See RFC 4752, Section 3.1, last two paragraphs.
  231. if kerberos.authGSSClientUnwrap(ctx,
  232. str(response['payload'])) != 1:
  233. raise OperationFailure('Unknown kerberos '
  234. 'failure during GSS_Unwrap step.')
  235. if kerberos.authGSSClientWrap(ctx,
  236. kerberos.authGSSClientResponse(ctx),
  237. username) != 1:
  238. raise OperationFailure('Unknown kerberos '
  239. 'failure during GSS_Wrap step.')
  240. payload = kerberos.authGSSClientResponse(ctx)
  241. cmd = SON([('saslContinue', 1),
  242. ('conversationId', response['conversationId']),
  243. ('payload', payload)])
  244. sock_info.command('$external', cmd)
  245. finally:
  246. kerberos.authGSSClientClean(ctx)
  247. except kerberos.KrbError as exc:
  248. raise OperationFailure(str(exc))
  249. def _authenticate_plain(credentials, sock_info):
  250. """Authenticate using SASL PLAIN (RFC 4616)
  251. """
  252. source = credentials.source
  253. username = credentials.username
  254. password = credentials.password
  255. payload = ('\x00%s\x00%s' % (username, password)).encode('utf-8')
  256. cmd = SON([('saslStart', 1),
  257. ('mechanism', 'PLAIN'),
  258. ('payload', Binary(payload)),
  259. ('autoAuthorize', 1)])
  260. sock_info.command(source, cmd)
  261. def _authenticate_cram_md5(credentials, sock_info):
  262. """Authenticate using CRAM-MD5 (RFC 2195)
  263. """
  264. source = credentials.source
  265. username = credentials.username
  266. password = credentials.password
  267. # The password used as the mac key is the
  268. # same as what we use for MONGODB-CR
  269. passwd = _password_digest(username, password)
  270. cmd = SON([('saslStart', 1),
  271. ('mechanism', 'CRAM-MD5'),
  272. ('payload', Binary(b'')),
  273. ('autoAuthorize', 1)])
  274. response = sock_info.command(source, cmd)
  275. # MD5 as implicit default digest for digestmod is deprecated
  276. # in python 3.4
  277. mac = hmac.HMAC(key=passwd.encode('utf-8'), digestmod=md5)
  278. mac.update(response['payload'])
  279. challenge = username.encode('utf-8') + b' ' + b(mac.hexdigest())
  280. cmd = SON([('saslContinue', 1),
  281. ('conversationId', response['conversationId']),
  282. ('payload', Binary(challenge))])
  283. sock_info.command(source, cmd)
  284. def _authenticate_x509(credentials, sock_info):
  285. """Authenticate using MONGODB-X509.
  286. """
  287. query = SON([('authenticate', 1),
  288. ('mechanism', 'MONGODB-X509'),
  289. ('user', credentials.username)])
  290. sock_info.command('$external', query)
  291. def _authenticate_mongo_cr(credentials, sock_info):
  292. """Authenticate using MONGODB-CR.
  293. """
  294. source = credentials.source
  295. username = credentials.username
  296. password = credentials.password
  297. # Get a nonce
  298. response = sock_info.command(source, {'getnonce': 1})
  299. nonce = response['nonce']
  300. key = _auth_key(nonce, username, password)
  301. # Actually authenticate
  302. query = SON([('authenticate', 1),
  303. ('user', username),
  304. ('nonce', nonce),
  305. ('key', key)])
  306. sock_info.command(source, query)
  307. def _authenticate_default(credentials, sock_info):
  308. if sock_info.max_wire_version >= 3:
  309. return _authenticate_scram_sha1(credentials, sock_info)
  310. else:
  311. return _authenticate_mongo_cr(credentials, sock_info)
  312. _AUTH_MAP = {
  313. 'CRAM-MD5': _authenticate_cram_md5,
  314. 'GSSAPI': _authenticate_gssapi,
  315. 'MONGODB-CR': _authenticate_mongo_cr,
  316. 'MONGODB-X509': _authenticate_x509,
  317. 'PLAIN': _authenticate_plain,
  318. 'SCRAM-SHA-1': _authenticate_scram_sha1,
  319. 'DEFAULT': _authenticate_default,
  320. }
  321. def authenticate(credentials, sock_info):
  322. """Authenticate sock_info."""
  323. mechanism = credentials.mechanism
  324. auth_func = _AUTH_MAP.get(mechanism)
  325. auth_func(credentials, sock_info)
  326. def logout(source, sock_info):
  327. """Log out from a database."""
  328. sock_info.command(source, {'logout': 1})