python_digest.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. '''
  2. Copyright (c) 2009, Akoha, Inc.
  3. All rights reserved.
  4. Redistribution and use in source and binary forms, with or without
  5. modification, are permitted provided that the following conditions are met:
  6. * Redistributions of source code must retain the above copyright notice, this
  7. list of conditions and the following disclaimer.
  8. * Redistributions in binary form must reproduce the above copyright notice,
  9. this list of conditions and the following disclaimer in the documentation
  10. and/or other materials provided with the distribution.
  11. * Neither the name of python-digest nor the names of its contributors may be
  12. used to endorse or promote products derived from this software without
  13. specific prior written permission.
  14. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
  15. ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
  16. WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  17. DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
  18. FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  19. DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
  20. SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
  21. CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
  22. OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  23. OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  24. This code was downloaded from https://github.com/dimagi/python-digest to avoid
  25. having to download python-digest as a dependency for wspy.
  26. '''
  27. try:
  28. import hashlib as md5
  29. except ImportError: # Python <2.5
  30. import md5
  31. try:
  32. from cStringIO import StringIO
  33. except ImportError:
  34. from StringIO import StringIO
  35. import random
  36. import types
  37. import urllib
  38. import urlparse
  39. import logging
  40. # Make sure a NullHandler is available
  41. # This was added in Python 2.7/3.2
  42. try:
  43. from logging import NullHandler
  44. except ImportError:
  45. class NullHandler(logging.Handler):
  46. def emit(self, record):
  47. pass
  48. _REQUIRED_DIGEST_RESPONSE_PARTS = ['username', 'realm', 'nonce', 'uri', 'response', 'algorithm',
  49. 'opaque', 'qop', 'nc', 'cnonce']
  50. _REQUIRED_DIGEST_CHALLENGE_PARTS = ['realm', 'nonce', 'stale', 'algorithm',
  51. 'opaque', 'qop']
  52. l = logging.getLogger(__name__)
  53. l.addHandler(NullHandler())
  54. _LWS=[chr(9), ' ', '\r', '\n']
  55. _ILLEGAL_TOKEN_CHARACTERS = (
  56. [chr(n) for n in range(0-31)] + # control characters
  57. [chr(127)] + # DEL
  58. ['(',')','<','>','@',',',';',':','\\','"','/','[',']','?','=','{','}',' '] +
  59. [chr(9)]) # horizontal tab
  60. class State(object):
  61. def character(self, c):
  62. return self.consume(c)
  63. def close(self):
  64. return self.eof()
  65. def eof(self):
  66. raise ValueError('EOF not permitted in this state.')
  67. '''
  68. Return False to keep the current state, or True to pop it
  69. '''
  70. def consume(c):
  71. raise Exception('Unimplemented')
  72. class ParentState(State):
  73. def __init__(self):
  74. super(State, self).__init__()
  75. self.child = None
  76. def close(self):
  77. if self.child:
  78. return self.handle_child_return(self.child.close())
  79. else:
  80. return self.eof()
  81. def push_child(self, child, c=None):
  82. self.child = child
  83. if c is not None:
  84. return self.send_to_child(c)
  85. else:
  86. return False
  87. def send_to_child(self, c):
  88. return self.handle_child_return(self.child.character(c))
  89. def handle_child_return(self, returned_value):
  90. if returned_value:
  91. child = self.child
  92. self.child = None
  93. return self.child_complete(child)
  94. return False
  95. '''
  96. Return False to keep the current state, or True to pop it.
  97. '''
  98. def child_complete(self, child):
  99. return False
  100. def character(self, c):
  101. if self.child:
  102. return self.send_to_child(c)
  103. else:
  104. return self.consume(c)
  105. def consume(self, c):
  106. return False
  107. class EscapedCharacterState(State):
  108. def __init__(self, io):
  109. super(EscapedCharacterState, self).__init__()
  110. self.io = io
  111. def consume(self, c):
  112. self.io.write(c)
  113. return True
  114. class KeyTrailingWhitespaceState(State):
  115. def consume(self, c):
  116. if c in _LWS:
  117. return False
  118. elif c == '=':
  119. return True
  120. else:
  121. raise ValueError("Expected whitespace or '='")
  122. class ValueLeadingWhitespaceState(ParentState):
  123. def __init__(self, io):
  124. super(ValueLeadingWhitespaceState, self).__init__()
  125. self.io = io
  126. def consume(self, c):
  127. if c in _LWS:
  128. return False
  129. elif c == '"':
  130. return self.push_child(QuotedValueState(self.io))
  131. elif c in _ILLEGAL_TOKEN_CHARACTERS:
  132. raise ValueError('The character %r is not a legal token character' % c)
  133. else:
  134. self.io.write(c)
  135. return self.push_child(UnquotedValueState(self.io))
  136. def child_complete(self, child):
  137. return True
  138. class ValueTrailingWhitespaceState(State):
  139. def eof(self):
  140. return True
  141. def consume(self, c):
  142. if c in _LWS:
  143. return False
  144. elif c == ',':
  145. return True
  146. else:
  147. raise ValueError("Expected whitespace, ',', or EOF")
  148. class BaseQuotedState(ParentState):
  149. def __init__(self, io):
  150. super(BaseQuotedState, self).__init__()
  151. self.key_io = io
  152. def consume(self, c):
  153. if c == '\\':
  154. return self.push_child(EscapedCharacterState(self.key_io))
  155. elif c == '"':
  156. return self.push_child(self.TrailingWhitespaceState())
  157. else:
  158. self.key_io.write(c)
  159. return False
  160. def child_complete(self, child):
  161. if type(child) == EscapedCharacterState:
  162. return False
  163. elif type(child) == self.TrailingWhitespaceState:
  164. return True
  165. class BaseUnquotedState(ParentState):
  166. def __init__(self, io):
  167. super(BaseUnquotedState, self).__init__()
  168. self.io = io
  169. def consume(self, c):
  170. if c == self.terminating_character:
  171. return True
  172. elif c in _LWS:
  173. return self.push_child(self.TrailingWhitespaceState())
  174. elif c in _ILLEGAL_TOKEN_CHARACTERS:
  175. raise ValueError('The character %r is not a legal token character' % c)
  176. else:
  177. self.io.write(c)
  178. return False
  179. def child_complete(self, child):
  180. # type(child) == self.TrailingWhitespaceState
  181. return True
  182. class QuotedKeyState(BaseQuotedState):
  183. TrailingWhitespaceState = KeyTrailingWhitespaceState
  184. class QuotedValueState(BaseQuotedState):
  185. TrailingWhitespaceState = ValueTrailingWhitespaceState
  186. class UnquotedKeyState(BaseUnquotedState):
  187. TrailingWhitespaceState = KeyTrailingWhitespaceState
  188. terminating_character = '='
  189. class UnquotedValueState(BaseUnquotedState):
  190. TrailingWhitespaceState = ValueTrailingWhitespaceState
  191. terminating_character = ','
  192. def eof(self):
  193. return True
  194. class NewPartState(ParentState):
  195. def __init__(self, parts):
  196. super(NewPartState, self).__init__()
  197. self.parts = parts
  198. self.key_io = StringIO()
  199. self.value_io = StringIO()
  200. def consume(self, c):
  201. if c in _LWS:
  202. return False
  203. elif c == '"':
  204. return self.push_child(QuotedKeyState(self.key_io))
  205. elif c in _ILLEGAL_TOKEN_CHARACTERS:
  206. raise ValueError('The character %r is not a legal token character' % c)
  207. else:
  208. self.key_io.write(c)
  209. return self.push_child(UnquotedKeyState(self.key_io))
  210. def child_complete(self, child):
  211. if type(child) in [QuotedKeyState, UnquotedKeyState]:
  212. return self.push_child(ValueLeadingWhitespaceState(self.value_io))
  213. else:
  214. self.parts[self.key_io.getvalue()] = self.value_io.getvalue()
  215. return True
  216. class FoundationState(ParentState):
  217. def __init__(self, defaults):
  218. super(FoundationState, self).__init__()
  219. self.parts = defaults.copy()
  220. def result(self):
  221. return self.parts
  222. def consume(self, c):
  223. return self.push_child(NewPartState(self.parts), c)
  224. def parse_parts(parts_string, defaults={}):
  225. state_machine = FoundationState(defaults)
  226. index = 0
  227. try:
  228. for c in parts_string:
  229. state_machine.character(c)
  230. index += 1
  231. state_machine.close()
  232. return state_machine.result()
  233. except ValueError, e:
  234. annotated_parts_string = "%s[%s]%s" % (parts_string[0:index],
  235. index < len(parts_string) and parts_string[index] or '',
  236. index + 1 < len(parts_string) and parts_string[index+1:] or '')
  237. l.exception("Failed to parse the Digest string "
  238. "(offending character is in []): %r" % annotated_parts_string)
  239. return None
  240. def format_parts(**kwargs):
  241. return ", ".join(['%s="%s"' % (k,v.encode('utf-8')) for (k,v) in kwargs.items()])
  242. def validate_uri(digest_uri, request_path):
  243. digest_url_components = urlparse.urlparse(digest_uri)
  244. return urllib.unquote(digest_url_components[2]) == request_path
  245. def validate_nonce(nonce, secret):
  246. '''
  247. Is the nonce one that was generated by this library using the provided secret?
  248. '''
  249. nonce_components = nonce.split(':', 2)
  250. if not len(nonce_components) == 3:
  251. return False
  252. timestamp = nonce_components[0]
  253. salt = nonce_components[1]
  254. nonce_signature = nonce_components[2]
  255. calculated_nonce = calculate_nonce(timestamp, secret, salt)
  256. if not nonce == calculated_nonce:
  257. return False
  258. return True
  259. def calculate_partial_digest(username, realm, password):
  260. '''
  261. Calculate a partial digest that may be stored and used to authenticate future
  262. HTTP Digest sessions.
  263. '''
  264. return md5.md5("%s:%s:%s" % (username.encode('utf-8'), realm, password.encode('utf-8'))).hexdigest()
  265. def build_digest_challenge(timestamp, secret, realm, opaque, stale):
  266. '''
  267. Builds a Digest challenge that may be sent as the value of the 'WWW-Authenticate' header
  268. in a 401 or 403 response.
  269. 'opaque' may be any value - it will be returned by the client.
  270. 'timestamp' will be incorporated and signed in the nonce - it may be retrieved from the
  271. client's authentication request using get_nonce_timestamp()
  272. '''
  273. nonce = calculate_nonce(timestamp, secret)
  274. return 'Digest %s' % format_parts(realm=realm, qop='auth', nonce=nonce,
  275. opaque=opaque, algorithm='MD5',
  276. stale=stale and 'true' or 'false')
  277. def calculate_request_digest(method, partial_digest, digest_response=None,
  278. uri=None, nonce=None, nonce_count=None, client_nonce=None):
  279. '''
  280. Calculates a value for the 'response' value of the client authentication request.
  281. Requires the 'partial_digest' calculated from the realm, username, and password.
  282. Either call it with a digest_response to use the values from an authentication request,
  283. or pass the individual parameters (i.e. to generate an authentication request).
  284. '''
  285. if digest_response:
  286. if uri or nonce or nonce_count or client_nonce:
  287. raise Exception("Both digest_response and one or more "
  288. "individual parameters were sent.")
  289. uri = digest_response.uri
  290. nonce = digest_response.nonce
  291. nonce_count = digest_response.nc
  292. client_nonce=digest_response.cnonce
  293. elif not (uri and nonce and (nonce_count != None) and client_nonce):
  294. raise Exception("Neither digest_response nor all individual parameters were sent.")
  295. ha2 = md5.md5("%s:%s" % (method, uri)).hexdigest()
  296. data = "%s:%s:%s:%s:%s" % (nonce, "%08x" % nonce_count, client_nonce, 'auth', ha2)
  297. kd = md5.md5("%s:%s" % (partial_digest, data)).hexdigest()
  298. return kd
  299. def get_nonce_timestamp(nonce):
  300. '''
  301. Extract the timestamp from a Nonce. To be sure the timestamp was generated by this site,
  302. make sure you validate the nonce using validate_nonce().
  303. '''
  304. components = nonce.split(':',2)
  305. if not len(components) == 3:
  306. return None
  307. try:
  308. return float(components[0])
  309. except ValueError:
  310. return None
  311. def calculate_nonce(timestamp, secret, salt=None):
  312. '''
  313. Generate a nonce using the provided timestamp, secret, and salt. If the salt is not provided,
  314. (and one should only be provided when validating a nonce) one will be generated randomly
  315. in order to ensure that two simultaneous requests do not generate identical nonces.
  316. '''
  317. if not salt:
  318. salt = ''.join([random.choice('0123456789ABCDEF') for x in range(4)])
  319. return "%s:%s:%s" % (timestamp, salt,
  320. md5.md5("%s:%s:%s" % (timestamp, salt, secret)).hexdigest())
  321. def build_authorization_request(username, method, uri, nonce_count, digest_challenge=None,
  322. realm=None, nonce=None, opaque=None, password=None,
  323. request_digest=None, client_nonce=None):
  324. '''
  325. Builds an authorization request that may be sent as the value of the 'Authorization'
  326. header in an HTTP request.
  327. Either a digest_challenge object (as returned from parse_digest_challenge) or its required
  328. component parameters (nonce, realm, opaque) must be provided.
  329. The nonce_count should be the last used nonce_count plus one.
  330. Either the password or the request_digest should be provided - if provided, the password
  331. will be used to generate a request digest. The client_nonce is optional - if not provided,
  332. a random value will be generated.
  333. '''
  334. if not client_nonce:
  335. client_nonce = ''.join([random.choice('0123456789ABCDEF') for x in range(32)])
  336. if digest_challenge and (realm or nonce or opaque):
  337. raise Exception("Both digest_challenge and one or more of realm, nonce, and opaque"
  338. "were sent.")
  339. if digest_challenge:
  340. if isinstance(digest_challenge, types.StringType):
  341. digest_challenge_header = digest_challenge
  342. digest_challenge = parse_digest_challenge(digest_challenge_header)
  343. if not digest_challenge:
  344. raise Exception("The provided digest challenge header could not be parsed: %s" %
  345. digest_challenge_header)
  346. realm = digest_challenge.realm
  347. nonce = digest_challenge.nonce
  348. opaque = digest_challenge.opaque
  349. elif not (realm and nonce and opaque):
  350. raise Exception("Either digest_challenge or realm, nonce, and opaque must be sent.")
  351. if password and request_digest:
  352. raise Exception("Both password and calculated request_digest were sent.")
  353. elif not request_digest:
  354. if not password:
  355. raise Exception("Either password or calculated request_digest must be provided.")
  356. partial_digest = calculate_partial_digest(username, realm, password)
  357. request_digest = calculate_request_digest(method, partial_digest, uri=uri, nonce=nonce,
  358. nonce_count=nonce_count,
  359. client_nonce=client_nonce)
  360. return 'Digest %s' % format_parts(username=username, realm=realm, nonce=nonce, uri=uri,
  361. response=request_digest, algorithm='MD5', opaque=opaque,
  362. qop='auth', nc='%08x' % nonce_count, cnonce=client_nonce)
  363. def _check_required_parts(parts, required_parts):
  364. if parts == None:
  365. return False
  366. missing_parts = [part for part in required_parts if not part in parts]
  367. return len(missing_parts) == 0
  368. def _build_object_from_parts(parts, names):
  369. obj = type("", (), {})()
  370. for part_name in names:
  371. val = parts[part_name]
  372. if isinstance(val, basestring):
  373. val = unicode(val, "utf-8")
  374. setattr(obj, part_name, val)
  375. return obj
  376. def parse_digest_response(digest_response_string):
  377. '''
  378. Parse the parameters of a Digest response. The input is a comma separated list of
  379. token=(token|quoted-string). See RFCs 2616 and 2617 for details.
  380. Known issue: this implementation will fail if there are commas embedded in quoted-strings.
  381. '''
  382. parts = parse_parts(digest_response_string, defaults={'algorithm': 'MD5'})
  383. if not _check_required_parts(parts, _REQUIRED_DIGEST_RESPONSE_PARTS):
  384. return None
  385. if not parts['nc'] or [c for c in parts['nc'] if not c in '0123456789abcdefABCDEF']:
  386. return None
  387. parts['nc'] = int(parts['nc'], 16)
  388. digest_response = _build_object_from_parts(parts, _REQUIRED_DIGEST_RESPONSE_PARTS)
  389. if ('MD5', 'auth') != (digest_response.algorithm, digest_response.qop):
  390. return None
  391. return digest_response
  392. def is_digest_credential(authorization_header):
  393. '''
  394. Determines if the header value is potentially a Digest response sent by a client (i.e.
  395. if it starts with 'Digest ' (case insensitive).
  396. '''
  397. return authorization_header[:7].lower() == 'digest '
  398. def parse_digest_credentials(authorization_header):
  399. '''
  400. Parses the value of an 'Authorization' header. Returns an object with properties
  401. corresponding to each of the recognized parameters in the header.
  402. '''
  403. if not is_digest_credential(authorization_header):
  404. return None
  405. return parse_digest_response(authorization_header[7:])
  406. def is_digest_challenge(authentication_header):
  407. '''
  408. Determines if the header value is potentially a Digest challenge sent by a server (i.e.
  409. if it starts with 'Digest ' (case insensitive).
  410. '''
  411. return authentication_header[:7].lower() == 'digest '
  412. def parse_digest_challenge(authentication_header):
  413. '''
  414. Parses the value of a 'WWW-Authenticate' header. Returns an object with properties
  415. corresponding to each of the recognized parameters in the header.
  416. '''
  417. if not is_digest_challenge(authentication_header):
  418. return None
  419. parts = parse_parts(authentication_header[7:], defaults={'algorithm': 'MD5',
  420. 'stale': 'false'})
  421. if not _check_required_parts(parts, _REQUIRED_DIGEST_CHALLENGE_PARTS):
  422. return None
  423. parts['stale'] = parts['stale'].lower() == 'true'
  424. digest_challenge = _build_object_from_parts(parts, _REQUIRED_DIGEST_CHALLENGE_PARTS)
  425. if ('MD5', 'auth') != (digest_challenge.algorithm, digest_challenge.qop):
  426. return None
  427. return digest_challenge