[3] | 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) |
---|