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