1 # (c) 2005 Clark C. Evans 2 # This module is part of the Python Paste Project and is released under 3 # the MIT License: http://www.opensource.org/licenses/mit-license.php 4 # This code was written with funding by http://prometheusresearch.com 5 """ 6 Digest HTTP/1.1 Authentication 7 8 This module implements ``Digest`` authentication as described by 9 RFC 2617 [1]_ . 10 11 Basically, you just put this module before your application, and it 12 takes care of requesting and handling authentication requests. This 13 module has been tested with several common browsers "out-in-the-wild". 14 15 >>> from paste.wsgilib import dump_environ 16 >>> from paste.httpserver import serve 17 >>> # from paste.auth.digest import digest_password, AuthDigestHandler 18 >>> realm = 'Test Realm' 19 >>> def authfunc(environ, realm, username): 20 ... return digest_password(realm, username, username) 21 >>> serve(AuthDigestHandler(dump_environ, realm, authfunc)) 22 serving on... 23 24 This code has not been audited by a security expert, please use with 25 caution (or better yet, report security holes). At this time, this 26 implementation does not provide for further challenges, nor does it 27 support Authentication-Info header. It also uses md5, and an option 28 to use sha would be a good thing. 29 30 .. [1] http://www.faqs.org/rfcs/rfc2617.html 31 """ 32 from paste.httpexceptions import HTTPUnauthorized 33 from paste.httpheaders import * 34 try: 35 from hashlib import md5 36 except ImportError: 37 from md5 import md5 38 import time, random 39 from six.moves.urllib.parse import quote as url_quote 40 import six 41 42 def _split_auth_string(auth_string): 43 """ split a digest auth string into individual key=value strings """ 44 prev = None 45 for item in auth_string.split(","): 46 try: 47 if prev.count('"') == 1: 48 prev = "%s,%s" % (prev, item) 49 continue 50 except AttributeError: 51 if prev == None: 52 prev = item 53 continue 54 else: 55 raise StopIteration 56 yield prev.strip() 57 prev = item 58 59 yield prev.strip() 60 raise StopIteration 61 62 def _auth_to_kv_pairs(auth_string): 63 """ split a digest auth string into key, value pairs """ 64 for item in _split_auth_string(auth_string): 65 (k, v) = item.split("=", 1) 66 if v.startswith('"') and len(v) > 1 and v.endswith('"'): 67 v = v[1:-1] 68 yield (k, v) 69 70 def digest_password(realm, username, password): 71 """ construct the appropriate hashcode needed for HTTP digest """ 72 content = "%s:%s:%s" % (username, realm, password) 73 if six.PY3: 74 content = content.encode('utf8') 75 return md5(content).hexdigest() 76 77 class AuthDigestAuthenticator(object): 78 """ implementation of RFC 2617 - HTTP Digest Authentication """ 79 def __init__(self, realm, authfunc): 80 self.nonce = {} # list to prevent replay attacks 81 self.authfunc = authfunc 82 self.realm = realm 83 84 def build_authentication(self, stale = ''): 85 """ builds the authentication error """ 86 content = "%s:%s" % (time.time(), random.random()) 87 if six.PY3: 88 content = content.encode('utf-8') 89 nonce = md5(content).hexdigest() 90 91 content = "%s:%s" % (time.time(), random.random()) 92 if six.PY3: 93 content = content.encode('utf-8') 94 opaque = md5(content).hexdigest() 95 96 self.nonce[nonce] = None 97 parts = {'realm': self.realm, 'qop': 'auth', 98 'nonce': nonce, 'opaque': opaque } 99 if stale: 100 parts['stale'] = 'true' 101 head = ", ".join(['%s="%s"' % (k, v) for (k, v) in parts.items()]) 102 head = [("WWW-Authenticate", 'Digest %s' % head)] 103 return HTTPUnauthorized(headers=head) 104 105 def compute(self, ha1, username, response, method, 106 path, nonce, nc, cnonce, qop): 107 """ computes the authentication, raises error if unsuccessful """ 108 if not ha1: 109 return self.build_authentication() 110 content = '%s:%s' % (method, path) 111 if six.PY3: 112 content = content.encode('utf8') 113 ha2 = md5(content).hexdigest() 114 if qop: 115 chk = "%s:%s:%s:%s:%s:%s" % (ha1, nonce, nc, cnonce, qop, ha2) 116 else: 117 chk = "%s:%s:%s" % (ha1, nonce, ha2) 118 if six.PY3: 119 chk = chk.encode('utf8') 120 if response != md5(chk).hexdigest(): 121 if nonce in self.nonce: 122 del self.nonce[nonce] 123 return self.build_authentication() 124 pnc = self.nonce.get(nonce,'00000000') 125 if pnc is not None and nc <= pnc: 126 if nonce in self.nonce: 127 del self.nonce[nonce] 128 return self.build_authentication(stale = True) 129 self.nonce[nonce] = nc 130 return username 131 132 def authenticate(self, environ): 133 """ This function takes a WSGI environment and authenticates 134 the request returning authenticated user or error. 135 """ 136 method = REQUEST_METHOD(environ) 137 fullpath = url_quote(SCRIPT_NAME(environ)) + url_quote(PATH_INFO(environ)) 138 authorization = AUTHORIZATION(environ) 139 if not authorization: 140 return self.build_authentication() 141 (authmeth, auth) = authorization.split(" ", 1) 142 if 'digest' != authmeth.lower(): 143 return self.build_authentication() 144 amap = dict(_auth_to_kv_pairs(auth)) 145 try: 146 username = amap['username'] 147 authpath = amap['uri'] 148 nonce = amap['nonce'] 149 realm = amap['realm'] 150 response = amap['response'] 151 assert authpath.split("?", 1)[0] in fullpath 152 assert realm == self.realm 153 qop = amap.get('qop', '') 154 cnonce = amap.get('cnonce', '') 155 nc = amap.get('nc', '00000000') 156 if qop: 157 assert 'auth' == qop 158 assert nonce and nc 159 except: 160 return self.build_authentication() 161 ha1 = self.authfunc(environ, realm, username) 162 return self.compute(ha1, username, response, method, authpath, 163 nonce, nc, cnonce, qop) 164 165 __call__ = authenticate 166 167 class AuthDigestHandler(object): 168 """ 169 middleware for HTTP Digest authentication (RFC 2617) 170 171 This component follows the procedure below: 172 173 0. If the REMOTE_USER environment variable is already populated; 174 then this middleware is a no-op, and the request is passed 175 along to the application. 176 177 1. If the HTTP_AUTHORIZATION header was not provided or specifies 178 an algorithem other than ``digest``, then a HTTPUnauthorized 179 response is generated with the challenge. 180 181 2. If the response is malformed or or if the user's credientials 182 do not pass muster, another HTTPUnauthorized is raised. 183 184 3. If all goes well, and the user's credintials pass; then 185 REMOTE_USER environment variable is filled in and the 186 AUTH_TYPE is listed as 'digest'. 187 188 Parameters: 189 190 ``application`` 191 192 The application object is called only upon successful 193 authentication, and can assume ``environ['REMOTE_USER']`` 194 is set. If the ``REMOTE_USER`` is already set, this 195 middleware is simply pass-through. 196 197 ``realm`` 198 199 This is a identifier for the authority that is requesting 200 authorization. It is shown to the user and should be unique 201 within the domain it is being used. 202 203 ``authfunc`` 204 205 This is a callback function which performs the actual 206 authentication; the signature of this callback is: 207 208 authfunc(environ, realm, username) -> hashcode 209 210 This module provides a 'digest_password' helper function 211 which can help construct the hashcode; it is recommended 212 that the hashcode is stored in a database, not the user's 213 actual password (since you only need the hashcode). 214 """ 215 def __init__(self, application, realm, authfunc): 216 self.authenticate = AuthDigestAuthenticator(realm, authfunc) 217 self.application = application 218 219 def __call__(self, environ, start_response): 220 username = REMOTE_USER(environ) 221 if not username: 222 result = self.authenticate(environ) 223 if isinstance(result, str): 224 AUTH_TYPE.update(environ,'digest') 225 REMOTE_USER.update(environ, result) 226 else: 227 return result.wsgi_application(environ, start_response) 228 return self.application(environ, start_response) 229 230 middleware = AuthDigestHandler 231 232 __all__ = ['digest_password', 'AuthDigestHandler' ] 233 234 def make_digest(app, global_conf, realm, authfunc, **kw): 235 """ 236 Grant access via digest authentication 237 238 Config looks like this:: 239 240 [filter:grant] 241 use = egg:Paste#auth_digest 242 realm=myrealm 243 authfunc=somepackage.somemodule:somefunction 244 245 """ 246 from paste.util.import_string import eval_import 247 import types 248 authfunc = eval_import(authfunc) 249 assert isinstance(authfunc, types.FunctionType), "authfunc must resolve to a function" 250 return AuthDigestHandler(app, realm, authfunc) 251 252 if "__main__" == __name__: 253 import doctest 254 doctest.testmod(optionflags=doctest.ELLIPSIS) 255