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