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