python_digest.py 18 KB

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