1 | # (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org) |
---|
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 | |
---|
40 | import time as time_mod |
---|
41 | import md5 |
---|
42 | import Cookie |
---|
43 | from paste import request |
---|
44 | |
---|
45 | class AuthTicket(object): |
---|
46 | |
---|
47 | """ |
---|
48 | This class represents an authentication token. You must pass in |
---|
49 | the shared secret, the userid, and the IP address. Optionally you |
---|
50 | can include tokens (a list of strings, representing role names), |
---|
51 | 'user_data', which is arbitrary data available for your own use in |
---|
52 | later scripts. Lastly, you can override the cookie name and |
---|
53 | timestamp. |
---|
54 | |
---|
55 | Once you provide all the arguments, use .cookie_value() to |
---|
56 | generate the appropriate authentication ticket. .cookie() |
---|
57 | generates a Cookie object, the str() of which is the complete |
---|
58 | cookie header to be sent. |
---|
59 | |
---|
60 | CGI usage:: |
---|
61 | |
---|
62 | token = auth_tkt.AuthTick('sharedsecret', 'username', |
---|
63 | os.environ['REMOTE_ADDR'], tokens=['admin']) |
---|
64 | print 'Status: 200 OK' |
---|
65 | print 'Content-type: text/html' |
---|
66 | print token.cookie() |
---|
67 | print |
---|
68 | ... redirect HTML ... |
---|
69 | |
---|
70 | Webware usage:: |
---|
71 | |
---|
72 | token = auth_tkt.AuthTick('sharedsecret', 'username', |
---|
73 | self.request().environ()['REMOTE_ADDR'], tokens=['admin']) |
---|
74 | self.response().setCookie('auth_tkt', token.cookie_value()) |
---|
75 | |
---|
76 | Be careful not to do an HTTP redirect after login; use meta |
---|
77 | refresh or Javascript -- some browsers have bugs where cookies |
---|
78 | aren't saved when set on a redirect. |
---|
79 | """ |
---|
80 | |
---|
81 | def __init__(self, secret, userid, ip, tokens=(), user_data='', |
---|
82 | time=None, cookie_name='auth_tkt', |
---|
83 | secure=False): |
---|
84 | self.secret = secret |
---|
85 | self.userid = userid |
---|
86 | self.ip = ip |
---|
87 | self.tokens = ','.join(tokens) |
---|
88 | self.user_data = user_data |
---|
89 | if time is None: |
---|
90 | self.time = time_mod.time() |
---|
91 | else: |
---|
92 | self.time = time |
---|
93 | self.cookie_name = cookie_name |
---|
94 | self.secure = secure |
---|
95 | |
---|
96 | def digest(self): |
---|
97 | return calculate_digest( |
---|
98 | self.ip, self.time, self.secret, self.userid, self.tokens, |
---|
99 | self.user_data) |
---|
100 | |
---|
101 | def cookie_value(self): |
---|
102 | v = '%s%08x%s!' % (self.digest(), int(self.time), self.userid) |
---|
103 | if self.tokens: |
---|
104 | v += self.tokens + '!' |
---|
105 | v += self.user_data |
---|
106 | return v |
---|
107 | |
---|
108 | def cookie(self): |
---|
109 | c = Cookie.SimpleCookie() |
---|
110 | c[self.cookie_name] = self.cookie_value().encode('base64').strip().replace('\n', '') |
---|
111 | c[self.cookie_name]['path'] = '/' |
---|
112 | if self.secure: |
---|
113 | c[self.cookie_name]['secure'] = 'true' |
---|
114 | return c |
---|
115 | |
---|
116 | class BadTicket(Exception): |
---|
117 | """ |
---|
118 | Exception raised when a ticket can't be parsed. If we get |
---|
119 | far enough to determine what the expected digest should have |
---|
120 | been, expected is set. This should not be shown by default, |
---|
121 | but can be useful for debugging. |
---|
122 | """ |
---|
123 | def __init__(self, msg, expected=None): |
---|
124 | self.expected = expected |
---|
125 | Exception.__init__(self, msg) |
---|
126 | |
---|
127 | def parse_ticket(secret, ticket, ip): |
---|
128 | """ |
---|
129 | Parse the ticket, returning (timestamp, userid, tokens, user_data). |
---|
130 | |
---|
131 | If the ticket cannot be parsed, ``BadTicket`` will be raised with |
---|
132 | an explanation. |
---|
133 | """ |
---|
134 | ticket = ticket.strip('"') |
---|
135 | digest = ticket[:32] |
---|
136 | try: |
---|
137 | timestamp = int(ticket[32:40], 16) |
---|
138 | except ValueError, e: |
---|
139 | raise BadTicket('Timestamp is not a hex integer: %s' % e) |
---|
140 | try: |
---|
141 | userid, data = ticket[40:].split('!', 1) |
---|
142 | except ValueError: |
---|
143 | raise BadTicket('userid is not followed by !') |
---|
144 | if '!' in data: |
---|
145 | tokens, user_data = data.split('!', 1) |
---|
146 | else: |
---|
147 | # @@: Is this the right order? |
---|
148 | tokens = '' |
---|
149 | user_data = data |
---|
150 | |
---|
151 | expected = calculate_digest(ip, timestamp, secret, |
---|
152 | userid, tokens, user_data) |
---|
153 | |
---|
154 | if expected != digest: |
---|
155 | raise BadTicket('Digest signature is not correct', |
---|
156 | expected=(expected, digest)) |
---|
157 | |
---|
158 | tokens = tokens.split(',') |
---|
159 | |
---|
160 | return (timestamp, userid, tokens, user_data) |
---|
161 | |
---|
162 | def calculate_digest(ip, timestamp, secret, userid, tokens, user_data): |
---|
163 | secret = maybe_encode(secret) |
---|
164 | userid = maybe_encode(userid) |
---|
165 | tokens = maybe_encode(tokens) |
---|
166 | user_data = maybe_encode(user_data) |
---|
167 | digest0 = md5.new( |
---|
168 | encode_ip_timestamp(ip, timestamp) + secret + userid + '\0' |
---|
169 | + tokens + '\0' + user_data).hexdigest() |
---|
170 | digest = md5.new(digest0 + secret).hexdigest() |
---|
171 | return digest |
---|
172 | |
---|
173 | def encode_ip_timestamp(ip, timestamp): |
---|
174 | ip_chars = ''.join(map(chr, map(int, ip.split('.')))) |
---|
175 | t = int(timestamp) |
---|
176 | ts = ((t & 0xff000000) >> 24, |
---|
177 | (t & 0xff0000) >> 16, |
---|
178 | (t & 0xff00) >> 8, |
---|
179 | t & 0xff) |
---|
180 | ts_chars = ''.join(map(chr, ts)) |
---|
181 | return ip_chars + ts_chars |
---|
182 | |
---|
183 | def maybe_encode(s, encoding='utf8'): |
---|
184 | if isinstance(s, unicode): |
---|
185 | s = s.encode(encoding) |
---|
186 | return s |
---|
187 | |
---|
188 | class AuthTKTMiddleware(object): |
---|
189 | |
---|
190 | """ |
---|
191 | Middleware that checks for signed cookies that match what |
---|
192 | `mod_auth_tkt <http://www.openfusion.com.au/labs/mod_auth_tkt/>`_ |
---|
193 | looks for (if you have mod_auth_tkt installed, you don't need this |
---|
194 | middleware, since Apache will set the environmental variables for |
---|
195 | you). |
---|
196 | |
---|
197 | Arguments: |
---|
198 | |
---|
199 | ``secret``: |
---|
200 | A secret that should be shared by any instances of this application. |
---|
201 | If this app is served from more than one machine, they should all |
---|
202 | have the same secret. |
---|
203 | |
---|
204 | ``cookie_name``: |
---|
205 | The name of the cookie to read and write from. Default ``auth_tkt``. |
---|
206 | |
---|
207 | ``secure``: |
---|
208 | If the cookie should be set as 'secure' (only sent over SSL) and if |
---|
209 | the login must be over SSL. |
---|
210 | |
---|
211 | ``include_ip``: |
---|
212 | If the cookie should include the user's IP address. If so, then |
---|
213 | if they change IPs their cookie will be invalid. |
---|
214 | |
---|
215 | ``logout_path``: |
---|
216 | The path under this middleware that should signify a logout. The |
---|
217 | page will be shown as usual, but the user will also be logged out |
---|
218 | when they visit this page. |
---|
219 | |
---|
220 | If used with mod_auth_tkt, then these settings (except logout_path) should |
---|
221 | match the analogous Apache configuration settings. |
---|
222 | |
---|
223 | This also adds two functions to the request: |
---|
224 | |
---|
225 | ``environ['paste.auth_tkt.set_user'](userid, tokens='', user_data='')`` |
---|
226 | |
---|
227 | This sets a cookie that logs the user in. ``tokens`` is a |
---|
228 | string (comma-separated groups) or a list of strings. |
---|
229 | ``user_data`` is a string for your own use. |
---|
230 | |
---|
231 | ``environ['paste.auth_tkt.logout_user']()`` |
---|
232 | |
---|
233 | Logs out the user. |
---|
234 | """ |
---|
235 | |
---|
236 | def __init__(self, app, secret, cookie_name='auth_tkt', secure=False, |
---|
237 | include_ip=True, logout_path=None): |
---|
238 | self.app = app |
---|
239 | self.secret = secret |
---|
240 | self.cookie_name = cookie_name |
---|
241 | self.secure = secure |
---|
242 | self.include_ip = include_ip |
---|
243 | self.logout_path = logout_path |
---|
244 | |
---|
245 | def __call__(self, environ, start_response): |
---|
246 | cookies = request.get_cookies(environ) |
---|
247 | if cookies.has_key(self.cookie_name): |
---|
248 | cookie_value = cookies[self.cookie_name].value |
---|
249 | else: |
---|
250 | cookie_value = '' |
---|
251 | if cookie_value: |
---|
252 | if self.include_ip: |
---|
253 | remote_addr = environ['REMOTE_ADDR'] |
---|
254 | else: |
---|
255 | # mod_auth_tkt uses this dummy value when IP is not |
---|
256 | # checked: |
---|
257 | remote_addr = '0.0.0.0' |
---|
258 | # @@: This should handle bad signatures better: |
---|
259 | # Also, timeouts should cause cookie refresh |
---|
260 | timestamp, userid, tokens, user_data = parse_ticket( |
---|
261 | self.secret, cookie_value, remote_addr) |
---|
262 | tokens = ','.join(tokens) |
---|
263 | environ['REMOTE_USER'] = userid |
---|
264 | if environ.get('REMOTE_USER_TOKENS'): |
---|
265 | # We want to add tokens/roles to what's there: |
---|
266 | tokens = environ['REMOTE_USER_TOKENS'] + ',' + tokens |
---|
267 | environ['REMOTE_USER_TOKENS'] = tokens |
---|
268 | environ['REMOTE_USER_DATA'] = user_data |
---|
269 | environ['AUTH_TYPE'] = 'cookie' |
---|
270 | set_cookies = [] |
---|
271 | def set_user(userid, tokens='', user_data=''): |
---|
272 | set_cookies.extend(self.set_user_cookie( |
---|
273 | environ, userid, tokens, user_data)) |
---|
274 | def logout_user(): |
---|
275 | set_cookies.extend(self.logout_user_cookie(environ)) |
---|
276 | environ['paste.auth_tkt.set_user'] = set_user |
---|
277 | environ['paste.auth_tkt.logout_user'] = logout_user |
---|
278 | if self.logout_path and environ.get('PATH_INFO') == self.logout_path: |
---|
279 | logout_user() |
---|
280 | def cookie_setting_start_response(status, headers, exc_info=None): |
---|
281 | headers.extend(set_cookies) |
---|
282 | return start_response(status, headers, exc_info) |
---|
283 | return self.app(environ, cookie_setting_start_response) |
---|
284 | |
---|
285 | def set_user_cookie(self, environ, userid, tokens, user_data): |
---|
286 | if not isinstance(tokens, basestring): |
---|
287 | tokens = ','.join(tokens) |
---|
288 | if self.include_ip: |
---|
289 | remote_addr = environ['REMOTE_ADDR'] |
---|
290 | else: |
---|
291 | remote_addr = '0.0.0.0' |
---|
292 | ticket = AuthTicket( |
---|
293 | self.secret, |
---|
294 | userid, |
---|
295 | remote_addr, |
---|
296 | tokens=tokens, |
---|
297 | user_data=user_data, |
---|
298 | cookie_name=self.cookie_name, |
---|
299 | secure=self.secure) |
---|
300 | # @@: Should we set REMOTE_USER etc in the current |
---|
301 | # environment right now as well? |
---|
302 | cookies = [ |
---|
303 | ('Set-Cookie', '%s=%s; Path=/' % ( |
---|
304 | self.cookie_name, ticket.cookie_value()))] |
---|
305 | return cookies |
---|
306 | |
---|
307 | def logout_user_cookie(self, environ): |
---|
308 | cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME')) |
---|
309 | wild_domain = '.' + cur_domain |
---|
310 | cookies = [ |
---|
311 | ('Set-Cookie', '%s=""; Path=/' % self.cookie_name), |
---|
312 | ('Set-Cookie', '%s=""; Path=/; Domain=%s' % |
---|
313 | (self.cookie_name, cur_domain)), |
---|
314 | ('Set-Cookie', '%s=""; Path=/; Domain=%s' % |
---|
315 | (self.cookie_name, wild_domain)), |
---|
316 | ] |
---|
317 | return cookies |
---|
318 | |
---|
319 | def make_auth_tkt_middleware( |
---|
320 | app, |
---|
321 | global_conf, |
---|
322 | secret=None, |
---|
323 | cookie_name='auth_tkt', |
---|
324 | secure=False, |
---|
325 | include_ip=True, |
---|
326 | logout_path=None): |
---|
327 | """ |
---|
328 | Creates the `AuthTKTMiddleware |
---|
329 | <class-paste.auth.auth_tkt.AuthTKTMiddleware.html>`_. |
---|
330 | |
---|
331 | ``secret`` is requird, but can be set globally or locally. |
---|
332 | """ |
---|
333 | from paste.deploy.converters import asbool |
---|
334 | secure = asbool(secure) |
---|
335 | include_ip = asbool(include_ip) |
---|
336 | if secret is None: |
---|
337 | secret = global_conf.get('secret') |
---|
338 | if not secret: |
---|
339 | raise ValueError( |
---|
340 | "You must provide a 'secret' (in global or local configuration)") |
---|
341 | return AuthTKTMiddleware( |
---|
342 | app, secret, cookie_name, secure, include_ip, logout_path or None) |
---|