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""" 6Cookie "Saved" Authentication 7 8This authentication middleware saves the current REMOTE_USER, 9REMOTE_SESSION, and any other environment variables specified in a 10cookie so that it can be retrieved during the next request without 11requiring re-authentication. This uses a session cookie on the client 12side (so it goes away when the user closes their window) and does 13server-side expiration. 14 15Following is a very simple example where a form is presented asking for 16a user name (no actual checking), and dummy session identifier (perhaps 17corresponding to a database session id) is stored in the cookie. 18 19:: 20 21 >>> from paste.httpserver import serve 22 >>> from paste.fileapp import DataApp 23 >>> from paste.httpexceptions import * 24 >>> from paste.auth.cookie import AuthCookieHandler 25 >>> from paste.wsgilib import parse_querystring 26 >>> def testapp(environ, start_response): 27 ... user = dict(parse_querystring(environ)).get('user','') 28 ... if user: 29 ... environ['REMOTE_USER'] = user 30 ... environ['REMOTE_SESSION'] = 'a-session-id' 31 ... if environ.get('REMOTE_USER'): 32 ... page = '<html><body>Welcome %s (%s)</body></html>' 33 ... page %= (environ['REMOTE_USER'], environ['REMOTE_SESSION']) 34 ... else: 35 ... page = ('<html><body><form><input name="user" />' 36 ... '<input type="submit" /></form></body></html>') 37 ... return DataApp(page, content_type="text/html")( 38 ... environ, start_response) 39 >>> serve(AuthCookieHandler(testapp)) 40 serving on... 41 42""" 43 44import hmac, base64, random, six, time, warnings 45try: 46 from hashlib import sha1 47except ImportError: 48 # NOTE: We have to use the callable with hashlib (hashlib.sha1), 49 # otherwise hmac only accepts the sha module object itself 50 import sha as sha1 51from paste.request import get_cookies 52 53def make_time(value): 54 return time.strftime("%Y%m%d%H%M", time.gmtime(value)) 55_signature_size = len(hmac.new(b'x', b'x', sha1).digest()) 56_header_size = _signature_size + len(make_time(time.time())) 57 58# @@: Should this be using urllib.quote? 59# build encode/decode functions to safely pack away values 60_encode = [('\\', '\\x5c'), ('"', '\\x22'), 61 ('=', '\\x3d'), (';', '\\x3b')] 62_decode = [(v, k) for (k, v) in _encode] 63_decode.reverse() 64def encode(s, sublist = _encode): 65 return six.moves.reduce((lambda a, b: a.replace(b[0], b[1])), sublist, str(s)) 66decode = lambda s: encode(s, _decode) 67 68class CookieTooLarge(RuntimeError): 69 def __init__(self, content, cookie): 70 RuntimeError.__init__("Signed cookie exceeds maximum size of 4096") 71 self.content = content 72 self.cookie = cookie 73 74_all_chars = ''.join([chr(x) for x in range(0, 255)]) 75def new_secret(): 76 """ returns a 64 byte secret """ 77 secret = ''.join(random.sample(_all_chars, 64)) 78 if six.PY3: 79 secret = secret.encode('utf8') 80 return secret 81 82class AuthCookieSigner(object): 83 """ 84 save/restore ``environ`` entries via digially signed cookie 85 86 This class converts content into a timed and digitally signed 87 cookie, as well as having the facility to reverse this procedure. 88 If the cookie, after the content is encoded and signed exceeds the 89 maximum length (4096), then CookieTooLarge exception is raised. 90 91 The timeout of the cookie is handled on the server side for a few 92 reasons. First, if a 'Expires' directive is added to a cookie, then 93 the cookie becomes persistent (lasting even after the browser window 94 has closed). Second, the user's clock may be wrong (perhaps 95 intentionally). The timeout is specified in minutes; and expiration 96 date returned is rounded to one second. 97 98 Constructor Arguments: 99 100 ``secret`` 101 102 This is a secret key if you want to syncronize your keys so 103 that the cookie will be good across a cluster of computers. 104 It is recommended via the HMAC specification (RFC 2104) that 105 the secret key be 64 bytes since this is the block size of 106 the hashing. If you do not provide a secret key, a random 107 one is generated each time you create the handler; this 108 should be sufficient for most cases. 109 110 ``timeout`` 111 112 This is the time (in minutes) from which the cookie is set 113 to expire. Note that on each request a new (replacement) 114 cookie is sent, hence this is effectively a session timeout 115 parameter for your entire cluster. If you do not provide a 116 timeout, it is set at 30 minutes. 117 118 ``maxlen`` 119 120 This is the maximum size of the *signed* cookie; hence the 121 actual content signed will be somewhat less. If the cookie 122 goes over this size, a ``CookieTooLarge`` exception is 123 raised so that unexpected handling of cookies on the client 124 side are avoided. By default this is set at 4k (4096 bytes), 125 which is the standard cookie size limit. 126 127 """ 128 def __init__(self, secret = None, timeout = None, maxlen = None): 129 self.timeout = timeout or 30 130 if isinstance(timeout, six.string_types): 131 raise ValueError( 132 "Timeout must be a number (minutes), not a string (%r)" 133 % timeout) 134 self.maxlen = maxlen or 4096 135 self.secret = secret or new_secret() 136 137 def sign(self, content): 138 """ 139 Sign the content returning a valid cookie (that does not 140 need to be escaped and quoted). The expiration of this 141 cookie is handled server-side in the auth() function. 142 """ 143 timestamp = make_time(time.time() + 60*self.timeout) 144 if six.PY3: 145 content = content.encode('utf8') 146 timestamp = timestamp.encode('utf8') 147 cookie = base64.encodestring( 148 hmac.new(self.secret, content, sha1).digest() + 149 timestamp + 150 content) 151 cookie = cookie.replace(b"/", b"_").replace(b"=", b"~") 152 cookie = cookie.replace(b'\n', b'').replace(b'\r', b'') 153 if len(cookie) > self.maxlen: 154 raise CookieTooLarge(content, cookie) 155 return cookie 156 157 def auth(self, cookie): 158 """ 159 Authenticate the cooke using the signature, verify that it 160 has not expired; and return the cookie's content 161 """ 162 decode = base64.decodestring( 163 cookie.replace("_", "/").replace("~", "=")) 164 signature = decode[:_signature_size] 165 expires = decode[_signature_size:_header_size] 166 content = decode[_header_size:] 167 if signature == hmac.new(self.secret, content, sha1).digest(): 168 if int(expires) > int(make_time(time.time())): 169 return content 170 else: 171 # This is the normal case of an expired cookie; just 172 # don't bother doing anything here. 173 pass 174 else: 175 # This case can happen if the server is restarted with a 176 # different secret; or if the user's IP address changed 177 # due to a proxy. However, it could also be a break-in 178 # attempt -- so should it be reported? 179 pass 180 181class AuthCookieEnviron(list): 182 """ 183 a list of environment keys to be saved via cookie 184 185 An instance of this object, found at ``environ['paste.auth.cookie']`` 186 lists the `environ` keys that were restored from or will be added 187 to the digially signed cookie. This object can be accessed from an 188 `environ` variable by using this module's name. 189 """ 190 def __init__(self, handler, scanlist): 191 list.__init__(self, scanlist) 192 self.handler = handler 193 def append(self, value): 194 if value in self: 195 return 196 list.append(self, str(value)) 197 198class AuthCookieHandler(object): 199 """ 200 the actual handler that should be put in your middleware stack 201 202 This middleware uses cookies to stash-away a previously authenticated 203 user (and perhaps other variables) so that re-authentication is not 204 needed. This does not implement sessions; and therefore N servers 205 can be syncronized to accept the same saved authentication if they 206 all use the same cookie_name and secret. 207 208 By default, this handler scans the `environ` for the REMOTE_USER 209 and REMOTE_SESSION key; if found, it is stored. It can be 210 configured to scan other `environ` keys as well -- but be careful 211 not to exceed 2-3k (so that the encoded and signed cookie does not 212 exceed 4k). You can ask it to handle other environment variables 213 by doing: 214 215 ``environ['paste.auth.cookie'].append('your.environ.variable')`` 216 217 218 Constructor Arguments: 219 220 ``application`` 221 222 This is the wrapped application which will have access to 223 the ``environ['REMOTE_USER']`` restored by this middleware. 224 225 ``cookie_name`` 226 227 The name of the cookie used to store this content, by default 228 it is ``PASTE_AUTH_COOKIE``. 229 230 ``scanlist`` 231 232 This is the initial set of ``environ`` keys to 233 save/restore to the signed cookie. By default is consists 234 only of ``REMOTE_USER`` and ``REMOTE_SESSION``; any tuple 235 or list of environment keys will work. However, be 236 careful, as the total saved size is limited to around 3k. 237 238 ``signer`` 239 240 This is the signer object used to create the actual cookie 241 values, by default, it is ``AuthCookieSigner`` and is passed 242 the remaining arguments to this function: ``secret``, 243 ``timeout``, and ``maxlen``. 244 245 At this time, each cookie is individually signed. To store more 246 than the 4k of data; it is possible to sub-class this object to 247 provide different ``environ_name`` and ``cookie_name`` 248 """ 249 environ_name = 'paste.auth.cookie' 250 cookie_name = 'PASTE_AUTH_COOKIE' 251 signer_class = AuthCookieSigner 252 environ_class = AuthCookieEnviron 253 254 def __init__(self, application, cookie_name=None, scanlist=None, 255 signer=None, secret=None, timeout=None, maxlen=None): 256 if not signer: 257 signer = self.signer_class(secret, timeout, maxlen) 258 self.signer = signer 259 self.scanlist = scanlist or ('REMOTE_USER','REMOTE_SESSION') 260 self.application = application 261 self.cookie_name = cookie_name or self.cookie_name 262 263 def __call__(self, environ, start_response): 264 if self.environ_name in environ: 265 raise AssertionError("AuthCookie already installed!") 266 scanlist = self.environ_class(self, self.scanlist) 267 jar = get_cookies(environ) 268 if self.cookie_name in jar: 269 content = self.signer.auth(jar[self.cookie_name].value) 270 if content: 271 for pair in content.split(";"): 272 (k, v) = pair.split("=") 273 k = decode(k) 274 if k not in scanlist: 275 scanlist.append(k) 276 if k in environ: 277 continue 278 environ[k] = decode(v) 279 if 'REMOTE_USER' == k: 280 environ['AUTH_TYPE'] = 'cookie' 281 environ[self.environ_name] = scanlist 282 if "paste.httpexceptions" in environ: 283 warnings.warn("Since paste.httpexceptions is hooked in your " 284 "processing chain before paste.auth.cookie, if an " 285 "HTTPRedirection is raised, the cookies this module sets " 286 "will not be included in your response.\n") 287 288 def response_hook(status, response_headers, exc_info=None): 289 """ 290 Scan the environment for keys specified in the scanlist, 291 pack up their values, signs the content and issues a cookie. 292 """ 293 scanlist = environ.get(self.environ_name) 294 assert scanlist and isinstance(scanlist, self.environ_class) 295 content = [] 296 for k in scanlist: 297 v = environ.get(k) 298 if v is not None: 299 if type(v) is not str: 300 raise ValueError( 301 "The value of the environmental variable %r " 302 "is not a str (only str is allowed; got %r)" 303 % (k, v)) 304 content.append("%s=%s" % (encode(k), encode(v))) 305 if content: 306 content = ";".join(content) 307 content = self.signer.sign(content) 308 if six.PY3: 309 content = content.decode('utf8') 310 cookie = '%s=%s; Path=/;' % (self.cookie_name, content) 311 if 'https' == environ['wsgi.url_scheme']: 312 cookie += ' secure;' 313 response_headers.append(('Set-Cookie', cookie)) 314 return start_response(status, response_headers, exc_info) 315 return self.application(environ, response_hook) 316 317middleware = AuthCookieHandler 318 319# Paste Deploy entry point: 320def make_auth_cookie( 321 app, global_conf, 322 # Should this get picked up from global_conf somehow?: 323 cookie_name='PASTE_AUTH_COOKIE', 324 scanlist=('REMOTE_USER', 'REMOTE_SESSION'), 325 # signer cannot be set 326 secret=None, 327 timeout=30, 328 maxlen=4096): 329 """ 330 This middleware uses cookies to stash-away a previously 331 authenticated user (and perhaps other variables) so that 332 re-authentication is not needed. This does not implement 333 sessions; and therefore N servers can be syncronized to accept the 334 same saved authentication if they all use the same cookie_name and 335 secret. 336 337 By default, this handler scans the `environ` for the REMOTE_USER 338 and REMOTE_SESSION key; if found, it is stored. It can be 339 configured to scan other `environ` keys as well -- but be careful 340 not to exceed 2-3k (so that the encoded and signed cookie does not 341 exceed 4k). You can ask it to handle other environment variables 342 by doing: 343 344 ``environ['paste.auth.cookie'].append('your.environ.variable')`` 345 346 Configuration: 347 348 ``cookie_name`` 349 350 The name of the cookie used to store this content, by 351 default it is ``PASTE_AUTH_COOKIE``. 352 353 ``scanlist`` 354 355 This is the initial set of ``environ`` keys to 356 save/restore to the signed cookie. By default is consists 357 only of ``REMOTE_USER`` and ``REMOTE_SESSION``; any 358 space-separated list of environment keys will work. 359 However, be careful, as the total saved size is limited to 360 around 3k. 361 362 ``secret`` 363 364 The secret that will be used to sign the cookies. If you 365 don't provide one (and none is set globally) then a random 366 secret will be created. Each time the server is restarted 367 a new secret will then be created and all cookies will 368 become invalid! This can be any string value. 369 370 ``timeout`` 371 372 The time to keep the cookie, expressed in minutes. This 373 is handled server-side, so a new cookie with a new timeout 374 is added to every response. 375 376 ``maxlen`` 377 378 The maximum length of the cookie that is sent (default 4k, 379 which is a typical browser maximum) 380 381 """ 382 if isinstance(scanlist, six.string_types): 383 scanlist = scanlist.split() 384 if secret is None and global_conf.get('secret'): 385 secret = global_conf['secret'] 386 try: 387 timeout = int(timeout) 388 except ValueError: 389 raise ValueError('Bad value for timeout (must be int): %r' 390 % timeout) 391 try: 392 maxlen = int(maxlen) 393 except ValueError: 394 raise ValueError('Bad value for maxlen (must be int): %r' 395 % maxlen) 396 return AuthCookieHandler( 397 app, cookie_name=cookie_name, scanlist=scanlist, 398 secret=secret, timeout=timeout, maxlen=maxlen) 399 400__all__ = ['AuthCookieHandler', 'AuthCookieSigner', 'AuthCookieEnviron'] 401 402if "__main__" == __name__: 403 import doctest 404 doctest.testmod(optionflags=doctest.ELLIPSIS) 405 406