| 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 | |
|---|