/circuits/web/_httpauth.py

https://bitbucket.org/prologic/circuits/ · Python · 391 lines · 353 code · 14 blank · 24 comment · 12 complexity · e2aad15454e165782cd95795b41beb07 MD5 · raw file

  1. """
  2. httpauth modules defines functions to implement HTTP Digest
  3. Authentication (RFC 2617).
  4. This has full compliance with 'Digest' and 'Basic' authentication methods. In
  5. 'Digest' it supports both MD5 and MD5-sess algorithms.
  6. Usage:
  7. First use 'doAuth' to request the client authentication for a
  8. certain resource. You should send an httplib.UNAUTHORIZED response to the
  9. client so he knows he has to authenticate itself.
  10. Then use 'parseAuthorization' to retrieve the 'auth_map' used in
  11. 'checkResponse'.
  12. To use 'checkResponse' you must have already verified the password
  13. associated with the 'username' key in 'auth_map' dict. Then you use the
  14. 'checkResponse' function to verify if the password matches the one sent by
  15. the client.
  16. SUPPORTED_ALGORITHM - list of supported 'Digest' algorithms
  17. SUPPORTED_QOP - list of supported 'Digest' 'qop'.
  18. """
  19. __version__ = 1, 0, 1
  20. __author__ = "Tiago Cogumbreiro <cogumbreiro@users.sf.net>"
  21. __credits__ = """
  22. Peter van Kampen for its recipe which implement most of Digest
  23. authentication:
  24. http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/302378
  25. """
  26. __license__ = """
  27. Copyright (c) 2005, Tiago Cogumbreiro <cogumbreiro@users.sf.net>
  28. All rights reserved.
  29. Redistribution and use in source and binary forms, with or without modification
  30. are permitted provided that the following conditions are met:
  31. * Redistributions of source code must retain the above copyright notice,
  32. this list of conditions and the following disclaimer.
  33. * Redistributions in binary form must reproduce the above copyright notice,
  34. this list of conditions and the following disclaimer in the documentation
  35. and/or other materials provided with the distribution.
  36. * Neither the name of Sylvain Hellegouarch nor the names of his contributor
  37. may be used to endorse or promote products derived from this software
  38. without specific prior written permission.
  39. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
  40. ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
  41. WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  42. DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
  43. FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  44. DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
  45. SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
  46. CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
  47. OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  48. OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  49. """
  50. __all__ = ("digestAuth", "basicAuth", "doAuth", "checkResponse",
  51. "parseAuthorization", "SUPPORTED_ALGORITHM", "md5SessionKey",
  52. "calculateNonce", "SUPPORTED_QOP")
  53. ###############################################################################
  54. import time
  55. try:
  56. from base64 import decodebytes as base64_decodebytes
  57. except ImportError:
  58. from base64 import b64decode as base64_decodebytes # NOQA
  59. try:
  60. from urllib.request import parse_http_list, parse_keqv_list
  61. except ImportError:
  62. from urllib2 import parse_http_list, parse_keqv_list # NOQA
  63. from hashlib import md5
  64. MD5 = "MD5"
  65. MD5_SESS = "MD5-sess"
  66. AUTH = "auth"
  67. AUTH_INT = "auth-int"
  68. SUPPORTED_ALGORITHM = (MD5, MD5_SESS)
  69. SUPPORTED_QOP = (AUTH, AUTH_INT)
  70. ###############################################################################
  71. # doAuth
  72. #
  73. DIGEST_AUTH_ENCODERS = {
  74. MD5: lambda val: md5(val).hexdigest(),
  75. MD5_SESS: lambda val: md5(val).hexdigest(),
  76. # SHA: lambda val: sha.new (val).hexdigest (),
  77. }
  78. def calculateNonce(realm, algorithm=MD5):
  79. """This is an auxaliary function that calculates 'nonce' value. It is used
  80. to handle sessions."""
  81. global SUPPORTED_ALGORITHM, DIGEST_AUTH_ENCODERS
  82. assert algorithm in SUPPORTED_ALGORITHM
  83. try:
  84. encoder = DIGEST_AUTH_ENCODERS[algorithm]
  85. except KeyError:
  86. raise NotImplementedError(
  87. "The chosen algorithm (%s) does not have "
  88. "an implementation yet" % algorithm
  89. )
  90. s = "%d:%s" % (time.time(), realm)
  91. return encoder(s.encode("utf-8"))
  92. def digestAuth(realm, algorithm=MD5, nonce=None, qop=AUTH):
  93. """Challenges the client for a Digest authentication."""
  94. global SUPPORTED_ALGORITHM, DIGEST_AUTH_ENCODERS, SUPPORTED_QOP
  95. assert algorithm in SUPPORTED_ALGORITHM
  96. assert qop in SUPPORTED_QOP
  97. if nonce is None:
  98. nonce = calculateNonce(realm, algorithm)
  99. return 'Digest realm="%s", nonce="%s", algorithm="%s", qop="%s"' % (
  100. realm, nonce, algorithm, qop
  101. )
  102. def basicAuth(realm):
  103. """Challengenes the client for a Basic authentication."""
  104. assert '"' not in realm, "Realms cannot contain the \" (quote) character."
  105. return 'Basic realm="%s"' % realm
  106. def doAuth(realm):
  107. """'doAuth' function returns the challenge string b giving priority over
  108. Digest and fallback to Basic authentication when the browser doesn't
  109. support the first one.
  110. This should be set in the HTTP header under the key 'WWW-Authenticate'."""
  111. return digestAuth(realm) + " " + basicAuth(realm)
  112. ###############################################################################
  113. # Parse authorization parameters
  114. #
  115. def _parseDigestAuthorization(auth_params):
  116. # Convert the auth params to a dict
  117. items = parse_http_list(auth_params)
  118. params = parse_keqv_list(items)
  119. # Now validate the params
  120. # Check for required parameters
  121. required = ["username", "realm", "nonce", "uri", "response"]
  122. for k in required:
  123. if k not in params:
  124. return None
  125. # If qop is sent then cnonce and nc MUST be present
  126. if "qop" in params and not ("cnonce" in params and "nc" in params):
  127. return None
  128. # If qop is not sent, neither cnonce nor nc can be present
  129. if ("cnonce" in params or "nc" in params) and "qop" not in params:
  130. return None
  131. return params
  132. def _parseBasicAuthorization(auth_params):
  133. auth_params = auth_params.encode("utf-8")
  134. username, password = base64_decodebytes(auth_params).split(b":", 1)
  135. username = username.decode("utf-8")
  136. password = password.decode("utf-8")
  137. return {"username": username, "password": password}
  138. AUTH_SCHEMES = {
  139. "basic": _parseBasicAuthorization,
  140. "digest": _parseDigestAuthorization,
  141. }
  142. def parseAuthorization(credentials):
  143. """parseAuthorization will convert the value of the 'Authorization' key in
  144. the HTTP header to a map itself. If the parsing fails 'None' is returned.
  145. """
  146. global AUTH_SCHEMES
  147. auth_scheme, auth_params = credentials.split(" ", 1)
  148. auth_scheme = auth_scheme.lower()
  149. parser = AUTH_SCHEMES[auth_scheme]
  150. params = parser(auth_params)
  151. if params is None:
  152. return
  153. assert "auth_scheme" not in params
  154. params["auth_scheme"] = auth_scheme
  155. return params
  156. ###############################################################################
  157. # Check provided response for a valid password
  158. #
  159. def md5SessionKey(params, password):
  160. """
  161. If the "algorithm" directive's value is "MD5-sess", then A1
  162. [the session key] is calculated only once - on the first request by the
  163. client following receipt of a WWW-Authenticate challenge from the server.
  164. This creates a 'session key' for the authentication of subsequent
  165. requests and responses which is different for each "authentication
  166. session", thus limiting the amount of material hashed with any one
  167. key.
  168. Because the server need only use the hash of the user
  169. credentials in order to create the A1 value, this construction could
  170. be used in conjunction with a third party authentication service so
  171. that the web server would not need the actual password value. The
  172. specification of such a protocol is beyond the scope of this
  173. specification.
  174. """
  175. keys = ("username", "realm", "nonce", "cnonce")
  176. params_copy = {}
  177. for key in keys:
  178. params_copy[key] = params[key]
  179. params_copy["algorithm"] = MD5_SESS
  180. return _A1(params_copy, password)
  181. def _A1(params, password):
  182. algorithm = params.get("algorithm", MD5)
  183. H = DIGEST_AUTH_ENCODERS[algorithm]
  184. if algorithm == MD5:
  185. # If the "algorithm" directive's value is "MD5" or is
  186. # unspecified, then A1 is:
  187. # A1 = unq(username-value) ":" unq(realm-value) ":" passwd
  188. return "%s:%s:%s" % (params["username"], params["realm"], password)
  189. elif algorithm == MD5_SESS:
  190. # This is A1 if qop is set
  191. # A1 = H( unq(username-value) ":" unq(realm-value) ":" passwd )
  192. # ":" unq(nonce-value) ":" unq(cnonce-value)
  193. s = "%s:%s:%s" % (params["username"], params["realm"], password)
  194. h_a1 = H(s.encode("utf-8"))
  195. return "%s:%s:%s" % (h_a1, params["nonce"], params["cnonce"])
  196. def _A2(params, method, kwargs):
  197. # If the "qop" directive's value is "auth" or is unspecified, then A2 is:
  198. # A2 = Method ":" digest-uri-value
  199. qop = params.get("qop", "auth")
  200. if qop == "auth":
  201. return method + ":" + params["uri"]
  202. elif qop == "auth-int":
  203. # If the "qop" value is "auth-int", then A2 is:
  204. # A2 = Method ":" digest-uri-value ":" H(entity-body)
  205. entity_body = kwargs.get("entity_body", "")
  206. H = kwargs["H"]
  207. return "%s:%s:%s" % (
  208. method,
  209. params["uri"],
  210. H(entity_body)
  211. )
  212. else:
  213. raise NotImplementedError("The 'qop' method is unknown: %s" % qop)
  214. def _computeDigestResponse(auth_map, password, method="GET", A1=None,
  215. **kwargs):
  216. """
  217. Generates a response respecting the algorithm defined in RFC 2617
  218. """
  219. params = auth_map
  220. algorithm = params.get("algorithm", MD5)
  221. H = DIGEST_AUTH_ENCODERS[algorithm]
  222. def KD(secret, data):
  223. s = secret + ":" + data
  224. return H(s.encode("utf-8"))
  225. qop = params.get("qop", None)
  226. s = _A2(params, method, kwargs)
  227. H_A2 = H(s.encode("utf-8"))
  228. if algorithm == MD5_SESS and A1 is not None:
  229. H_A1 = H(A1.encode("utf-8"))
  230. else:
  231. s = _A1(params, password)
  232. H_A1 = H(s.encode("utf-8"))
  233. if qop in ("auth", "auth-int"):
  234. # If the "qop" value is "auth" or "auth-int":
  235. # request-digest = <"> < KD ( H(A1), unq(nonce-value)
  236. # ":" nc-value
  237. # ":" unq(cnonce-value)
  238. # ":" unq(qop-value)
  239. # ":" H(A2)
  240. # ) <">
  241. request = "%s:%s:%s:%s:%s" % (
  242. params["nonce"],
  243. params["nc"],
  244. params["cnonce"],
  245. params["qop"],
  246. H_A2,
  247. )
  248. elif qop is None:
  249. # If the "qop" directive is not present (this construction is
  250. # for compatibility with RFC 2069):
  251. # request-digest =
  252. # <"> < KD ( H(A1), unq(nonce-value) ":" H(A2) ) > <">
  253. request = "%s:%s" % (params["nonce"], H_A2)
  254. return KD(H_A1, request)
  255. def _checkDigestResponse(auth_map, password, method="GET", A1=None, **kwargs):
  256. """This function is used to verify the response given by the client when
  257. he tries to authenticate.
  258. Optional arguments:
  259. entity_body - when 'qop' is set to 'auth-int' you MUST provide the
  260. raw data you are going to send to the client (usually the
  261. HTML page.
  262. request_uri - the uri from the request line compared with the 'uri'
  263. directive of the authorization map. They must represent
  264. the same resource (unused at this time).
  265. """
  266. if auth_map['realm'] != kwargs.get('realm', None):
  267. return False
  268. response = _computeDigestResponse(auth_map, password, method, A1, **kwargs)
  269. return response == auth_map["response"]
  270. def _checkBasicResponse(auth_map, password, method='GET', encrypt=None,
  271. **kwargs):
  272. # Note that the Basic response doesn't provide the realm value so we cannot
  273. # test it
  274. try:
  275. return encrypt(auth_map["password"], auth_map["username"]) == password
  276. except TypeError:
  277. return encrypt(auth_map["password"]) == password
  278. AUTH_RESPONSES = {
  279. "basic": _checkBasicResponse,
  280. "digest": _checkDigestResponse,
  281. }
  282. def checkResponse(auth_map, password, method="GET", encrypt=None, **kwargs):
  283. """'checkResponse' compares the auth_map with the password and optionally
  284. other arguments that each implementation might need.
  285. If the response is of type 'Basic' then the function has the following
  286. signature:
  287. checkBasicResponse (auth_map, password) -> bool
  288. If the response is of type 'Digest' then the function has the following
  289. signature:
  290. checkDigestResponse (auth_map, password, method = 'GET', A1 = None) -> bool
  291. The 'A1' argument is only used in MD5_SESS algorithm based responses.
  292. Check md5SessionKey() for more info.
  293. """
  294. global AUTH_RESPONSES
  295. checker = AUTH_RESPONSES[auth_map["auth_scheme"]]
  296. return checker(
  297. auth_map, password, method=method, encrypt=encrypt, **kwargs
  298. )