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