Tres Seaver
2012-03-18 6919cb8daf7c7f7291b8aa10a28334c1a61848e9
commit | author | age
02a504 1 # (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
AO 2 # Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
3 ##########################################################################
4 #
5 # Copyright (c) 2005 Imaginary Landscape LLC and Contributors.
6 #
7 # Permission is hereby granted, free of charge, to any person obtaining
8 # a copy of this software and associated documentation files (the
9 # "Software"), to deal in the Software without restriction, including
10 # without limitation the rights to use, copy, modify, merge, publish,
11 # distribute, sublicense, and/or sell copies of the Software, and to
12 # permit persons to whom the Software is furnished to do so, subject to
13 # the following conditions:
14 #
15 # The above copyright notice and this permission notice shall be
16 # included in all copies or substantial portions of the Software.
17 #
18 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
22 # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
24 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25 ##########################################################################
26 """
27 Implementation of cookie signing as done in `mod_auth_tkt
28 <http://www.openfusion.com.au/labs/mod_auth_tkt/>`_.
29
30 mod_auth_tkt is an Apache module that looks for these signed cookies
31 and sets ``REMOTE_USER``, ``REMOTE_USER_TOKENS`` (a comma-separated
32 list of groups) and ``REMOTE_USER_DATA`` (arbitrary string data).
33
34 This module is an alternative to the ``paste.auth.cookie`` module;
35 it's primary benefit is compatibility with mod_auth_tkt, which in turn
36 makes it possible to use the same authentication process with
37 non-Python code run under Apache.
38 """
39
6919cb 40 try:
TS 41     from Cookie import SimpleCookie
42 except ImportError: #pragma NO COVER Python >= 3.0
43     from http.cookies import SimpleCookie
02a504 44 import time as time_mod
6919cb 45 try:
TS 46     from urllib import quote as url_quote
47     from urllib import unquote as url_unquote
48 except ImportError: #pragma NO COVER Python >= 3.0
49     from urllib.parse import quote as url_quote
50     from urllib.parse import unquote as url_unquote
02a504 51 try:
AO 52     from hashlib import md5
53 except ImportError:
54     from md5 import md5
6919cb 55 try:
TS 56     STRING_TYPES = (str, unicode)
57 except NameError:  #pragma NO COVER Python >= 3.0
58     STRING_TYPES = (str,)
59
02a504 60 from repoze.who._compat import get_cookies
AO 61
62
63 class AuthTicket(object):
64
65     """
66     This class represents an authentication token.  You must pass in
67     the shared secret, the userid, and the IP address.  Optionally you
68     can include tokens (a list of strings, representing role names),
69     'user_data', which is arbitrary data available for your own use in
70     later scripts.  Lastly, you can override the cookie name and
71     timestamp.
72
73     Once you provide all the arguments, use .cookie_value() to
74     generate the appropriate authentication ticket.  .cookie()
75     generates a Cookie object, the str() of which is the complete
76     cookie header to be sent.
77
78     CGI usage::
79
80         token = auth_tkt.AuthTick('sharedsecret', 'username',
81             os.environ['REMOTE_ADDR'], tokens=['admin'])
82         print 'Status: 200 OK'
83         print 'Content-type: text/html'
84         print token.cookie()
85         print
86         ... redirect HTML ...
87
88     Webware usage::
89
90         token = auth_tkt.AuthTick('sharedsecret', 'username',
91             self.request().environ()['REMOTE_ADDR'], tokens=['admin'])
92         self.response().setCookie('auth_tkt', token.cookie_value())
93
94     Be careful not to do an HTTP redirect after login; use meta
95     refresh or Javascript -- some browsers have bugs where cookies
96     aren't saved when set on a redirect.
97     """
98
99     def __init__(self, secret, userid, ip, tokens=(), user_data='',
100                  time=None, cookie_name='auth_tkt',
101                  secure=False):
102         self.secret = secret
103         self.userid = userid
104         self.ip = ip
105         self.tokens = ','.join(tokens)
106         self.user_data = user_data
107         if time is None:
108             self.time = time_mod.time()
109         else:
110             self.time = time
111         self.cookie_name = cookie_name
112         self.secure = secure
113
114     def digest(self):
115         return calculate_digest(
116             self.ip, self.time, self.secret, self.userid, self.tokens,
117             self.user_data)
118
119     def cookie_value(self):
6919cb 120         v = '%s%08x%s!' % (self.digest(), int(self.time),
TS 121                            url_quote(self.userid))
02a504 122         if self.tokens:
AO 123             v += self.tokens + '!'
124         v += self.user_data
125         return v
126
127     def cookie(self):
6919cb 128         c = SimpleCookie()
TS 129         c_val = self.cookie_value().encode('base64').strip().replace('\n', '')
130         c[self.cookie_name] = c_val
02a504 131         c[self.cookie_name]['path'] = '/'
AO 132         if self.secure:
133             c[self.cookie_name]['secure'] = 'true'
134         return c
135
136
137 class BadTicket(Exception):
138     """
139     Exception raised when a ticket can't be parsed.  If we get
140     far enough to determine what the expected digest should have
141     been, expected is set.  This should not be shown by default,
142     but can be useful for debugging.
143     """
144     def __init__(self, msg, expected=None):
145         self.expected = expected
146         Exception.__init__(self, msg)
147
148
149 def parse_ticket(secret, ticket, ip):
150     """
151     Parse the ticket, returning (timestamp, userid, tokens, user_data).
152
153     If the ticket cannot be parsed, ``BadTicket`` will be raised with
154     an explanation.
155     """
156     ticket = ticket.strip('"')
157     digest = ticket[:32]
158     try:
159         timestamp = int(ticket[32:40], 16)
6919cb 160     except ValueError as e:
02a504 161         raise BadTicket('Timestamp is not a hex integer: %s' % e)
AO 162     try:
163         userid, data = ticket[40:].split('!', 1)
164     except ValueError:
165         raise BadTicket('userid is not followed by !')
166     userid = url_unquote(userid)
167     if '!' in data:
168         tokens, user_data = data.split('!', 1)
169     else:
170         # @@: Is this the right order?
171         tokens = ''
172         user_data = data
173
174     expected = calculate_digest(ip, timestamp, secret,
175                                 userid, tokens, user_data)
176
177     if expected != digest:
178         raise BadTicket('Digest signature is not correct',
179                         expected=(expected, digest))
180
181     tokens = tokens.split(',')
182
183     return (timestamp, userid, tokens, user_data)
184
185
186 def calculate_digest(ip, timestamp, secret, userid, tokens, user_data):
187     secret = maybe_encode(secret)
188     userid = maybe_encode(userid)
189     tokens = maybe_encode(tokens)
190     user_data = maybe_encode(user_data)
191     digest0 = md5(
192         encode_ip_timestamp(ip, timestamp) + secret + userid + '\0'
193         + tokens + '\0' + user_data).hexdigest()
194     digest = md5(digest0 + secret).hexdigest()
195     return digest
196
197
198 def encode_ip_timestamp(ip, timestamp):
199     ip_chars = ''.join(map(chr, map(int, ip.split('.'))))
200     t = int(timestamp)
201     ts = ((t & 0xff000000) >> 24,
202           (t & 0xff0000) >> 16,
203           (t & 0xff00) >> 8,
204           t & 0xff)
205     ts_chars = ''.join(map(chr, ts))
206     return ip_chars + ts_chars
207
208
209 def maybe_encode(s, encoding='utf8'):
210     if isinstance(s, unicode):
211         s = s.encode(encoding)
212     return s
213
214
215 class AuthTKTMiddleware(object):
216
217     """
218     Middleware that checks for signed cookies that match what
219     `mod_auth_tkt <http://www.openfusion.com.au/labs/mod_auth_tkt/>`_
220     looks for (if you have mod_auth_tkt installed, you don't need this
221     middleware, since Apache will set the environmental variables for
222     you).
223
224     Arguments:
225
226     ``secret``:
227         A secret that should be shared by any instances of this application.
228         If this app is served from more than one machine, they should all
229         have the same secret.
230
231     ``cookie_name``:
232         The name of the cookie to read and write from.  Default ``auth_tkt``.
233
234     ``secure``:
235         If the cookie should be set as 'secure' (only sent over SSL) and if
236         the login must be over SSL. (Defaults to False)
237
238     ``httponly``:
239         If the cookie should be marked as HttpOnly, which means that it's
240         not accessible to JavaScript. (Defaults to False)
241
242     ``include_ip``:
243         If the cookie should include the user's IP address.  If so, then
244         if they change IPs their cookie will be invalid.
245
246     ``logout_path``:
247         The path under this middleware that should signify a logout.  The
248         page will be shown as usual, but the user will also be logged out
249         when they visit this page.
250
251     If used with mod_auth_tkt, then these settings (except logout_path) should
252     match the analogous Apache configuration settings.
253
254     This also adds two functions to the request:
255
256     ``environ['paste.auth_tkt.set_user'](userid, tokens='', user_data='')``
257
258         This sets a cookie that logs the user in.  ``tokens`` is a
259         string (comma-separated groups) or a list of strings.
260         ``user_data`` is a string for your own use.
261
262     ``environ['paste.auth_tkt.logout_user']()``
263
264         Logs out the user.
265     """
266
267     def __init__(self, app, secret, cookie_name='auth_tkt', secure=False,
268                  include_ip=True, logout_path=None, httponly=False,
269                  no_domain_cookie=True, current_domain_cookie=True,
270                  wildcard_cookie=True):
271         self.app = app
272         self.secret = secret
273         self.cookie_name = cookie_name
274         self.secure = secure
275         self.httponly = httponly
276         self.include_ip = include_ip
277         self.logout_path = logout_path
278         self.no_domain_cookie = no_domain_cookie
279         self.current_domain_cookie = current_domain_cookie
280         self.wildcard_cookie = wildcard_cookie
281
282     def __call__(self, environ, start_response):
283         #cookies = request.get_cookies(environ)
284         cookies = get_cookies(environ)
285         if self.cookie_name in cookies:
286             cookie_value = cookies[self.cookie_name].value
287         else:
288             cookie_value = ''
289         if cookie_value:
290             if self.include_ip:
291                 remote_addr = environ['REMOTE_ADDR']
292             else:
293                 # mod_auth_tkt uses this dummy value when IP is not
294                 # checked:
295                 remote_addr = '0.0.0.0'
296             # @@: This should handle bad signatures better:
297             # Also, timeouts should cause cookie refresh
298             try:
299                 timestamp, userid, tokens, user_data = parse_ticket(
300                     self.secret, cookie_value, remote_addr)
301                 tokens = ','.join(tokens)
302                 environ['REMOTE_USER'] = userid
303                 if environ.get('REMOTE_USER_TOKENS'):
304                     # We want to add tokens/roles to what's there:
305                     tokens = environ['REMOTE_USER_TOKENS'] + ',' + tokens
306                 environ['REMOTE_USER_TOKENS'] = tokens
307                 environ['REMOTE_USER_DATA'] = user_data
308                 environ['AUTH_TYPE'] = 'cookie'
309             except BadTicket:
310                 # bad credentials, just ignore without logging the user
311                 # in or anything
312                 pass
313         set_cookies = []
314
315         def set_user(userid, tokens='', user_data=''):
316             set_cookies.extend(self.set_user_cookie(
317                 environ, userid, tokens, user_data))
318
319         def logout_user():
320             set_cookies.extend(self.logout_user_cookie(environ))
321
322         environ['paste.auth_tkt.set_user'] = set_user
323         environ['paste.auth_tkt.logout_user'] = logout_user
324         if self.logout_path and environ.get('PATH_INFO') == self.logout_path:
325             logout_user()
326
327         def cookie_setting_start_response(status, headers, exc_info=None):
328             headers.extend(set_cookies)
329             return start_response(status, headers, exc_info)
330
331         return self.app(environ, cookie_setting_start_response)
332
333     def set_user_cookie(self, environ, userid, tokens, user_data):
334         if not isinstance(tokens, basestring):
335             tokens = ','.join(tokens)
336         if self.include_ip:
337             remote_addr = environ['REMOTE_ADDR']
338         else:
339             remote_addr = '0.0.0.0'
340         ticket = AuthTicket(
341             self.secret,
342             userid,
343             remote_addr,
344             tokens=tokens,
345             user_data=user_data,
346             cookie_name=self.cookie_name,
347             secure=self.secure)
348         # @@: Should we set REMOTE_USER etc in the current
349         # environment right now as well?
350         cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME'))
351         wild_domain = '.' + cur_domain
352
353         cookie_options = ""
354         if self.secure:
355             cookie_options += "; secure"
356         if self.httponly:
357             cookie_options += "; HttpOnly"
358
359         cookies = []
360         if self.no_domain_cookie:
361             cookies.append(('Set-Cookie', '%s=%s; Path=/%s' % (
362                 self.cookie_name, ticket.cookie_value(), cookie_options)))
363         if self.current_domain_cookie:
364             cookies.append(('Set-Cookie', '%s=%s; Path=/; Domain=%s%s' % (
365                 self.cookie_name, ticket.cookie_value(), cur_domain,
366                 cookie_options)))
367         if self.wildcard_cookie:
368             cookies.append(('Set-Cookie', '%s=%s; Path=/; Domain=%s%s' % (
369                 self.cookie_name, ticket.cookie_value(), wild_domain,
370                 cookie_options)))
371
372         return cookies
373
374     def logout_user_cookie(self, environ):
375         cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME'))
376         wild_domain = '.' + cur_domain
377         expires = 'Sat, 01-Jan-2000 12:00:00 GMT'
378         cookies = [
6919cb 379             ('Set-Cookie', '%s=""; Expires="%s"; Path=/' %
TS 380              (self.cookie_name, expires)),
02a504 381             ('Set-Cookie', '%s=""; Expires="%s"; Path=/; Domain=%s' %
AO 382              (self.cookie_name, expires, cur_domain)),
383             ('Set-Cookie', '%s=""; Expires="%s"; Path=/; Domain=%s' %
384              (self.cookie_name, expires, wild_domain)),
385             ]
386         return cookies
387
388
6919cb 389 def asbool(obj):
TS 390     # Lifted from paste.deploy.converters
391     if isinstance(obj, (str, unicode)):
392         obj = obj.strip().lower()
393         if obj in ['true', 'yes', 'on', 'y', 't', '1']:
394             return True
395         elif obj in ['false', 'no', 'off', 'n', 'f', '0']:
396             return False
397         else:
398             raise ValueError(
399                 "String is not true/false: %r" % obj)
400     return bool(obj)
401
02a504 402 def make_auth_tkt_middleware(
AO 403     app,
404     global_conf,
405     secret=None,
406     cookie_name='auth_tkt',
407     secure=False,
408     include_ip=True,
6919cb 409     logout_path=None,
TS 410     ):
02a504 411     """
AO 412     Creates the `AuthTKTMiddleware
6919cb 413     <class-repoze.who._auth_tkt.AuthTKTMiddleware.html>`_.
02a504 414
6919cb 415     ``secret`` is required, but can be set globally or locally.
02a504 416     """
AO 417     secure = asbool(secure)
418     include_ip = asbool(include_ip)
419     if secret is None:
420         secret = global_conf.get('secret')
421     if not secret:
422         raise ValueError(
423             "You must provide a 'secret' (in global or local configuration)")
424     return AuthTKTMiddleware(
425         app, secret, cookie_name, secure, include_ip, logout_path or None)