diff options
author | Marc Abramowitz <marc@marc-abramowitz.com> | 2016-03-07 14:05:52 -0800 |
---|---|---|
committer | Marc Abramowitz <marc@marc-abramowitz.com> | 2016-03-07 14:05:52 -0800 |
commit | 42b22881290e00e06b840dee1e42f0f5ef044d47 (patch) | |
tree | b4fef928625acd3e8ee45ccaa8c7a6c9810b3601 /paste/auth/auth_tkt.py | |
download | paste-git-tox_add_py35.tar.gz |
tox.ini: Add py35 to envlisttox_add_py35
Diffstat (limited to 'paste/auth/auth_tkt.py')
-rw-r--r-- | paste/auth/auth_tkt.py | 429 |
1 files changed, 429 insertions, 0 deletions
diff --git a/paste/auth/auth_tkt.py b/paste/auth/auth_tkt.py new file mode 100644 index 0000000..da8ddbd --- /dev/null +++ b/paste/auth/auth_tkt.py @@ -0,0 +1,429 @@ +# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org) +# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php +########################################################################## +# +# Copyright (c) 2005 Imaginary Landscape LLC and Contributors. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +########################################################################## +""" +Implementation of cookie signing as done in `mod_auth_tkt +<http://www.openfusion.com.au/labs/mod_auth_tkt/>`_. + +mod_auth_tkt is an Apache module that looks for these signed cookies +and sets ``REMOTE_USER``, ``REMOTE_USER_TOKENS`` (a comma-separated +list of groups) and ``REMOTE_USER_DATA`` (arbitrary string data). + +This module is an alternative to the ``paste.auth.cookie`` module; +it's primary benefit is compatibility with mod_auth_tkt, which in turn +makes it possible to use the same authentication process with +non-Python code run under Apache. +""" + +import time as time_mod +try: + import hashlib +except ImportError: + # mimic hashlib (will work for md5, fail for secure hashes) + import md5 as hashlib +try: + from http.cookies import SimpleCookie +except ImportError: + # Python 2 + from Cookie import SimpleCookie +from paste import request +from urllib import quote as url_quote +from urllib import unquote as url_unquote + +DEFAULT_DIGEST = hashlib.md5 + + +class AuthTicket(object): + + """ + This class represents an authentication token. You must pass in + the shared secret, the userid, and the IP address. Optionally you + can include tokens (a list of strings, representing role names), + 'user_data', which is arbitrary data available for your own use in + later scripts. Lastly, you can override the timestamp, cookie name, + whether to secure the cookie and the digest algorithm (for details + look at ``AuthTKTMiddleware``). + + Once you provide all the arguments, use .cookie_value() to + generate the appropriate authentication ticket. .cookie() + generates a Cookie object, the str() of which is the complete + cookie header to be sent. + + CGI usage:: + + token = auth_tkt.AuthTick('sharedsecret', 'username', + os.environ['REMOTE_ADDR'], tokens=['admin']) + print('Status: 200 OK') + print('Content-type: text/html') + print(token.cookie()) + print("") + ... redirect HTML ... + + Webware usage:: + + token = auth_tkt.AuthTick('sharedsecret', 'username', + self.request().environ()['REMOTE_ADDR'], tokens=['admin']) + self.response().setCookie('auth_tkt', token.cookie_value()) + + Be careful not to do an HTTP redirect after login; use meta + refresh or Javascript -- some browsers have bugs where cookies + aren't saved when set on a redirect. + """ + + def __init__(self, secret, userid, ip, tokens=(), user_data='', + time=None, cookie_name='auth_tkt', + secure=False, digest_algo=DEFAULT_DIGEST): + self.secret = secret + self.userid = userid + self.ip = ip + if not isinstance(tokens, basestring): + tokens = ','.join(tokens) + self.tokens = tokens + self.user_data = user_data + if time is None: + self.time = time_mod.time() + else: + self.time = time + self.cookie_name = cookie_name + self.secure = secure + if isinstance(digest_algo, str): + # correct specification of digest from hashlib or fail + self.digest_algo = getattr(hashlib, digest_algo) + else: + self.digest_algo = digest_algo + + def digest(self): + return calculate_digest( + self.ip, self.time, self.secret, self.userid, self.tokens, + self.user_data, self.digest_algo) + + def cookie_value(self): + v = '%s%08x%s!' % (self.digest(), int(self.time), url_quote(self.userid)) + if self.tokens: + v += self.tokens + '!' + v += self.user_data + return v + + def cookie(self): + c = SimpleCookie() + c[self.cookie_name] = self.cookie_value().encode('base64').strip().replace('\n', '') + c[self.cookie_name]['path'] = '/' + if self.secure: + c[self.cookie_name]['secure'] = 'true' + return c + + +class BadTicket(Exception): + """ + Exception raised when a ticket can't be parsed. If we get + far enough to determine what the expected digest should have + been, expected is set. This should not be shown by default, + but can be useful for debugging. + """ + def __init__(self, msg, expected=None): + self.expected = expected + Exception.__init__(self, msg) + + +def parse_ticket(secret, ticket, ip, digest_algo=DEFAULT_DIGEST): + """ + Parse the ticket, returning (timestamp, userid, tokens, user_data). + + If the ticket cannot be parsed, ``BadTicket`` will be raised with + an explanation. + """ + if isinstance(digest_algo, str): + # correct specification of digest from hashlib or fail + digest_algo = getattr(hashlib, digest_algo) + digest_hexa_size = digest_algo().digest_size * 2 + ticket = ticket.strip('"') + digest = ticket[:digest_hexa_size] + try: + timestamp = int(ticket[digest_hexa_size:digest_hexa_size + 8], 16) + except ValueError as e: + raise BadTicket('Timestamp is not a hex integer: %s' % e) + try: + userid, data = ticket[digest_hexa_size + 8:].split('!', 1) + except ValueError: + raise BadTicket('userid is not followed by !') + userid = url_unquote(userid) + if '!' in data: + tokens, user_data = data.split('!', 1) + else: + # @@: Is this the right order? + tokens = '' + user_data = data + + expected = calculate_digest(ip, timestamp, secret, + userid, tokens, user_data, + digest_algo) + + if expected != digest: + raise BadTicket('Digest signature is not correct', + expected=(expected, digest)) + + tokens = tokens.split(',') + + return (timestamp, userid, tokens, user_data) + + +# @@: Digest object constructor compatible with named ones in hashlib only +def calculate_digest(ip, timestamp, secret, userid, tokens, user_data, + digest_algo): + secret = maybe_encode(secret) + userid = maybe_encode(userid) + tokens = maybe_encode(tokens) + user_data = maybe_encode(user_data) + digest0 = digest_algo( + encode_ip_timestamp(ip, timestamp) + secret + userid + '\0' + + tokens + '\0' + user_data).hexdigest() + digest = digest_algo(digest0 + secret).hexdigest() + return digest + + +def encode_ip_timestamp(ip, timestamp): + ip_chars = ''.join(map(chr, map(int, ip.split('.')))) + t = int(timestamp) + ts = ((t & 0xff000000) >> 24, + (t & 0xff0000) >> 16, + (t & 0xff00) >> 8, + t & 0xff) + ts_chars = ''.join(map(chr, ts)) + return ip_chars + ts_chars + + +def maybe_encode(s, encoding='utf8'): + if isinstance(s, unicode): + s = s.encode(encoding) + return s + + +class AuthTKTMiddleware(object): + + """ + Middleware that checks for signed cookies that match what + `mod_auth_tkt <http://www.openfusion.com.au/labs/mod_auth_tkt/>`_ + looks for (if you have mod_auth_tkt installed, you don't need this + middleware, since Apache will set the environmental variables for + you). + + Arguments: + + ``secret``: + A secret that should be shared by any instances of this application. + If this app is served from more than one machine, they should all + have the same secret. + + ``cookie_name``: + The name of the cookie to read and write from. Default ``auth_tkt``. + + ``secure``: + If the cookie should be set as 'secure' (only sent over SSL) and if + the login must be over SSL. (Defaults to False) + + ``httponly``: + If the cookie should be marked as HttpOnly, which means that it's + not accessible to JavaScript. (Defaults to False) + + ``include_ip``: + If the cookie should include the user's IP address. If so, then + if they change IPs their cookie will be invalid. + + ``logout_path``: + The path under this middleware that should signify a logout. The + page will be shown as usual, but the user will also be logged out + when they visit this page. + + ``digest_algo``: + Digest algorithm specified as a name of the algorithm provided by + ``hashlib`` or as a compatible digest object constructor. + Defaults to ``md5``, as in mod_auth_tkt. The others currently + compatible with mod_auth_tkt are ``sha256`` and ``sha512``. + + If used with mod_auth_tkt, then these settings (except logout_path) should + match the analogous Apache configuration settings. + + This also adds two functions to the request: + + ``environ['paste.auth_tkt.set_user'](userid, tokens='', user_data='')`` + + This sets a cookie that logs the user in. ``tokens`` is a + string (comma-separated groups) or a list of strings. + ``user_data`` is a string for your own use. + + ``environ['paste.auth_tkt.logout_user']()`` + + Logs out the user. + """ + + def __init__(self, app, secret, cookie_name='auth_tkt', secure=False, + include_ip=True, logout_path=None, httponly=False, + no_domain_cookie=True, current_domain_cookie=True, + wildcard_cookie=True, digest_algo=DEFAULT_DIGEST): + self.app = app + self.secret = secret + self.cookie_name = cookie_name + self.secure = secure + self.httponly = httponly + self.include_ip = include_ip + self.logout_path = logout_path + self.no_domain_cookie = no_domain_cookie + self.current_domain_cookie = current_domain_cookie + self.wildcard_cookie = wildcard_cookie + if isinstance(digest_algo, str): + # correct specification of digest from hashlib or fail + self.digest_algo = getattr(hashlib, digest_algo) + else: + self.digest_algo = digest_algo + + def __call__(self, environ, start_response): + cookies = request.get_cookies(environ) + if self.cookie_name in cookies: + cookie_value = cookies[self.cookie_name].value + else: + cookie_value = '' + if cookie_value: + if self.include_ip: + remote_addr = environ['REMOTE_ADDR'] + else: + # mod_auth_tkt uses this dummy value when IP is not + # checked: + remote_addr = '0.0.0.0' + # @@: This should handle bad signatures better: + # Also, timeouts should cause cookie refresh + try: + timestamp, userid, tokens, user_data = parse_ticket( + self.secret, cookie_value, remote_addr, self.digest_algo) + tokens = ','.join(tokens) + environ['REMOTE_USER'] = userid + if environ.get('REMOTE_USER_TOKENS'): + # We want to add tokens/roles to what's there: + tokens = environ['REMOTE_USER_TOKENS'] + ',' + tokens + environ['REMOTE_USER_TOKENS'] = tokens + environ['REMOTE_USER_DATA'] = user_data + environ['AUTH_TYPE'] = 'cookie' + except BadTicket: + # bad credentials, just ignore without logging the user + # in or anything + pass + set_cookies = [] + + def set_user(userid, tokens='', user_data=''): + set_cookies.extend(self.set_user_cookie( + environ, userid, tokens, user_data)) + + def logout_user(): + set_cookies.extend(self.logout_user_cookie(environ)) + + environ['paste.auth_tkt.set_user'] = set_user + environ['paste.auth_tkt.logout_user'] = logout_user + if self.logout_path and environ.get('PATH_INFO') == self.logout_path: + logout_user() + + def cookie_setting_start_response(status, headers, exc_info=None): + headers.extend(set_cookies) + return start_response(status, headers, exc_info) + + return self.app(environ, cookie_setting_start_response) + + def set_user_cookie(self, environ, userid, tokens, user_data): + if not isinstance(tokens, basestring): + tokens = ','.join(tokens) + if self.include_ip: + remote_addr = environ['REMOTE_ADDR'] + else: + remote_addr = '0.0.0.0' + ticket = AuthTicket( + self.secret, + userid, + remote_addr, + tokens=tokens, + user_data=user_data, + cookie_name=self.cookie_name, + secure=self.secure) + # @@: Should we set REMOTE_USER etc in the current + # environment right now as well? + cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME')) + wild_domain = '.' + cur_domain + + cookie_options = "" + if self.secure: + cookie_options += "; secure" + if self.httponly: + cookie_options += "; HttpOnly" + + cookies = [] + if self.no_domain_cookie: + cookies.append(('Set-Cookie', '%s=%s; Path=/%s' % ( + self.cookie_name, ticket.cookie_value(), cookie_options))) + if self.current_domain_cookie: + cookies.append(('Set-Cookie', '%s=%s; Path=/; Domain=%s%s' % ( + self.cookie_name, ticket.cookie_value(), cur_domain, + cookie_options))) + if self.wildcard_cookie: + cookies.append(('Set-Cookie', '%s=%s; Path=/; Domain=%s%s' % ( + self.cookie_name, ticket.cookie_value(), wild_domain, + cookie_options))) + + return cookies + + def logout_user_cookie(self, environ): + cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME')) + wild_domain = '.' + cur_domain + expires = 'Sat, 01-Jan-2000 12:00:00 GMT' + cookies = [ + ('Set-Cookie', '%s=""; Expires="%s"; Path=/' % (self.cookie_name, expires)), + ('Set-Cookie', '%s=""; Expires="%s"; Path=/; Domain=%s' % + (self.cookie_name, expires, cur_domain)), + ('Set-Cookie', '%s=""; Expires="%s"; Path=/; Domain=%s' % + (self.cookie_name, expires, wild_domain)), + ] + return cookies + + +def make_auth_tkt_middleware( + app, + global_conf, + secret=None, + cookie_name='auth_tkt', + secure=False, + include_ip=True, + logout_path=None): + """ + Creates the `AuthTKTMiddleware + <class-paste.auth.auth_tkt.AuthTKTMiddleware.html>`_. + + ``secret`` is requird, but can be set globally or locally. + """ + from paste.deploy.converters import asbool + secure = asbool(secure) + include_ip = asbool(include_ip) + if secret is None: + secret = global_conf.get('secret') + if not secret: + raise ValueError( + "You must provide a 'secret' (in global or local configuration)") + return AuthTKTMiddleware( + app, secret, cookie_name, secure, include_ip, logout_path or None) |