root/oss/httpauthfilter/httpauth.py

Revision 176, 13.2 kB (checked in by sylvain, 2 years ago)

Fixed minor bugs with wrong qop

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