From 42b22881290e00e06b840dee1e42f0f5ef044d47 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 7 Mar 2016 14:05:52 -0800 Subject: tox.ini: Add py35 to envlist --- paste/auth/__init__.py | 9 ++ paste/auth/auth_tkt.py | 429 +++++++++++++++++++++++++++++++++++++++++++++++++ paste/auth/basic.py | 122 ++++++++++++++ paste/auth/cas.py | 99 ++++++++++++ paste/auth/cookie.py | 405 ++++++++++++++++++++++++++++++++++++++++++++++ paste/auth/digest.py | 254 +++++++++++++++++++++++++++++ paste/auth/form.py | 149 +++++++++++++++++ paste/auth/grantip.py | 114 +++++++++++++ paste/auth/multi.py | 79 +++++++++ paste/auth/open_id.py | 413 +++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 2073 insertions(+) create mode 100644 paste/auth/__init__.py create mode 100644 paste/auth/auth_tkt.py create mode 100644 paste/auth/basic.py create mode 100644 paste/auth/cas.py create mode 100644 paste/auth/cookie.py create mode 100644 paste/auth/digest.py create mode 100644 paste/auth/form.py create mode 100644 paste/auth/grantip.py create mode 100644 paste/auth/multi.py create mode 100644 paste/auth/open_id.py (limited to 'paste/auth') diff --git a/paste/auth/__init__.py b/paste/auth/__init__.py new file mode 100644 index 0000000..186e2ef --- /dev/null +++ b/paste/auth/__init__.py @@ -0,0 +1,9 @@ +# (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 +""" +Package for authentication/identification of requests. + +The objective of this package is to provide single-focused middleware +components that implement a particular specification. Integration of +the components into a usable system is up to a higher-level framework. +""" 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 +`_. + +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 `_ + 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 + `_. + + ``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) diff --git a/paste/auth/basic.py b/paste/auth/basic.py new file mode 100644 index 0000000..24d1731 --- /dev/null +++ b/paste/auth/basic.py @@ -0,0 +1,122 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +Basic HTTP/1.0 Authentication + +This module implements ``Basic`` authentication as described in +HTTP/1.0 specification [1]_ . Do not use this module unless you +are using SSL or need to work with very out-dated clients, instead +use ``digest`` authentication. + +>>> from paste.wsgilib import dump_environ +>>> from paste.httpserver import serve +>>> # from paste.auth.basic import AuthBasicHandler +>>> realm = 'Test Realm' +>>> def authfunc(environ, username, password): +... return username == password +>>> serve(AuthBasicHandler(dump_environ, realm, authfunc)) +serving on... + +.. [1] http://www.w3.org/Protocols/HTTP/1.0/draft-ietf-http-spec.html#BasicAA +""" +from paste.httpexceptions import HTTPUnauthorized +from paste.httpheaders import * + +class AuthBasicAuthenticator(object): + """ + implements ``Basic`` authentication details + """ + type = 'basic' + def __init__(self, realm, authfunc): + self.realm = realm + self.authfunc = authfunc + + def build_authentication(self): + head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm) + return HTTPUnauthorized(headers=head) + + def authenticate(self, environ): + authorization = AUTHORIZATION(environ) + if not authorization: + return self.build_authentication() + (authmeth, auth) = authorization.split(' ', 1) + if 'basic' != authmeth.lower(): + return self.build_authentication() + auth = auth.strip().decode('base64') + username, password = auth.split(':', 1) + if self.authfunc(environ, username, password): + return username + return self.build_authentication() + + __call__ = authenticate + +class AuthBasicHandler(object): + """ + HTTP/1.0 ``Basic`` authentication middleware + + Parameters: + + ``application`` + + The application object is called only upon successful + authentication, and can assume ``environ['REMOTE_USER']`` + is set. If the ``REMOTE_USER`` is already set, this + middleware is simply pass-through. + + ``realm`` + + This is a identifier for the authority that is requesting + authorization. It is shown to the user and should be unique + within the domain it is being used. + + ``authfunc`` + + This is a mandatory user-defined function which takes a + ``environ``, ``username`` and ``password`` for its first + three arguments. It should return ``True`` if the user is + authenticated. + + """ + def __init__(self, application, realm, authfunc): + self.application = application + self.authenticate = AuthBasicAuthenticator(realm, authfunc) + + def __call__(self, environ, start_response): + username = REMOTE_USER(environ) + if not username: + result = self.authenticate(environ) + if isinstance(result, str): + AUTH_TYPE.update(environ, 'basic') + REMOTE_USER.update(environ, result) + else: + return result.wsgi_application(environ, start_response) + return self.application(environ, start_response) + +middleware = AuthBasicHandler + +__all__ = ['AuthBasicHandler'] + +def make_basic(app, global_conf, realm, authfunc, **kw): + """ + Grant access via basic authentication + + Config looks like this:: + + [filter:grant] + use = egg:Paste#auth_basic + realm=myrealm + authfunc=somepackage.somemodule:somefunction + + """ + from paste.util.import_string import eval_import + import types + authfunc = eval_import(authfunc) + assert isinstance(authfunc, types.FunctionType), "authfunc must resolve to a function" + return AuthBasicHandler(app, realm, authfunc) + + +if "__main__" == __name__: + import doctest + doctest.testmod(optionflags=doctest.ELLIPSIS) diff --git a/paste/auth/cas.py b/paste/auth/cas.py new file mode 100644 index 0000000..44e4e98 --- /dev/null +++ b/paste/auth/cas.py @@ -0,0 +1,99 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +CAS 1.0 Authentication + +The Central Authentication System is a straight-forward single sign-on +mechanism developed by Yale University's ITS department. It has since +enjoyed widespread success and is deployed at many major universities +and some corporations. + + https://clearinghouse.ja-sig.org/wiki/display/CAS/Home + http://www.yale.edu/tp/auth/usingcasatyale.html + +This implementation has the goal of maintaining current path arguments +passed to the system so that it can be used as middleware at any stage +of processing. It has the secondary goal of allowing for other +authentication methods to be used concurrently. +""" +from six.moves.urllib.parse import urlencode +from paste.request import construct_url +from paste.httpexceptions import HTTPSeeOther, HTTPForbidden + +class CASLoginFailure(HTTPForbidden): + """ The exception raised if the authority returns 'no' """ + +class CASAuthenticate(HTTPSeeOther): + """ The exception raised to authenticate the user """ + +def AuthCASHandler(application, authority): + """ + middleware to implement CAS 1.0 authentication + + There are several possible outcomes: + + 0. If the REMOTE_USER environment variable is already populated; + then this middleware is a no-op, and the request is passed along + to the application. + + 1. If a query argument 'ticket' is found, then an attempt to + validate said ticket /w the authentication service done. If the + ticket is not validated; an 403 'Forbidden' exception is raised. + Otherwise, the REMOTE_USER variable is set with the NetID that + was validated and AUTH_TYPE is set to "cas". + + 2. Otherwise, a 303 'See Other' is returned to the client directing + them to login using the CAS service. After logon, the service + will send them back to this same URL, only with a 'ticket' query + argument. + + Parameters: + + ``authority`` + + This is a fully-qualified URL to a CAS 1.0 service. The URL + should end with a '/' and have the 'login' and 'validate' + sub-paths as described in the CAS 1.0 documentation. + + """ + assert authority.endswith("/") and authority.startswith("http") + def cas_application(environ, start_response): + username = environ.get('REMOTE_USER','') + if username: + return application(environ, start_response) + qs = environ.get('QUERY_STRING','').split("&") + if qs and qs[-1].startswith("ticket="): + # assume a response from the authority + ticket = qs.pop().split("=", 1)[1] + environ['QUERY_STRING'] = "&".join(qs) + service = construct_url(environ) + args = urlencode( + {'service': service,'ticket': ticket}) + requrl = authority + "validate?" + args + result = urlopen(requrl).read().split("\n") + if 'yes' == result[0]: + environ['REMOTE_USER'] = result[1] + environ['AUTH_TYPE'] = 'cas' + return application(environ, start_response) + exce = CASLoginFailure() + else: + service = construct_url(environ) + args = urlencode({'service': service}) + location = authority + "login?" + args + exce = CASAuthenticate(location) + return exce.wsgi_application(environ, start_response) + return cas_application + +middleware = AuthCASHandler + +__all__ = ['CASLoginFailure', 'CASAuthenticate', 'AuthCASHandler' ] + +if '__main__' == __name__: + authority = "https://secure.its.yale.edu/cas/servlet/" + from paste.wsgilib import dump_environ + from paste.httpserver import serve + from paste.httpexceptions import * + serve(HTTPExceptionHandler( + AuthCASHandler(dump_environ, authority))) diff --git a/paste/auth/cookie.py b/paste/auth/cookie.py new file mode 100644 index 0000000..8f11d1b --- /dev/null +++ b/paste/auth/cookie.py @@ -0,0 +1,405 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +Cookie "Saved" Authentication + +This authentication middleware saves the current REMOTE_USER, +REMOTE_SESSION, and any other environment variables specified in a +cookie so that it can be retrieved during the next request without +requiring re-authentication. This uses a session cookie on the client +side (so it goes away when the user closes their window) and does +server-side expiration. + +Following is a very simple example where a form is presented asking for +a user name (no actual checking), and dummy session identifier (perhaps +corresponding to a database session id) is stored in the cookie. + +:: + + >>> from paste.httpserver import serve + >>> from paste.fileapp import DataApp + >>> from paste.httpexceptions import * + >>> from paste.auth.cookie import AuthCookieHandler + >>> from paste.wsgilib import parse_querystring + >>> def testapp(environ, start_response): + ... user = dict(parse_querystring(environ)).get('user','') + ... if user: + ... environ['REMOTE_USER'] = user + ... environ['REMOTE_SESSION'] = 'a-session-id' + ... if environ.get('REMOTE_USER'): + ... page = 'Welcome %s (%s)' + ... page %= (environ['REMOTE_USER'], environ['REMOTE_SESSION']) + ... else: + ... page = ('
' + ... '
') + ... return DataApp(page, content_type="text/html")( + ... environ, start_response) + >>> serve(AuthCookieHandler(testapp)) + serving on... + +""" + +import hmac, base64, random, six, time, warnings +try: + from hashlib import sha1 +except ImportError: + # NOTE: We have to use the callable with hashlib (hashlib.sha1), + # otherwise hmac only accepts the sha module object itself + import sha as sha1 +from paste.request import get_cookies + +def make_time(value): + return time.strftime("%Y%m%d%H%M", time.gmtime(value)) +_signature_size = len(hmac.new(b'x', b'x', sha1).digest()) +_header_size = _signature_size + len(make_time(time.time())) + +# @@: Should this be using urllib.quote? +# build encode/decode functions to safely pack away values +_encode = [('\\', '\\x5c'), ('"', '\\x22'), + ('=', '\\x3d'), (';', '\\x3b')] +_decode = [(v, k) for (k, v) in _encode] +_decode.reverse() +def encode(s, sublist = _encode): + return six.moves.reduce((lambda a, b: a.replace(b[0], b[1])), sublist, str(s)) +decode = lambda s: encode(s, _decode) + +class CookieTooLarge(RuntimeError): + def __init__(self, content, cookie): + RuntimeError.__init__("Signed cookie exceeds maximum size of 4096") + self.content = content + self.cookie = cookie + +_all_chars = ''.join([chr(x) for x in range(0, 255)]) +def new_secret(): + """ returns a 64 byte secret """ + secret = ''.join(random.sample(_all_chars, 64)) + if six.PY3: + secret = secret.encode('utf8') + return secret + +class AuthCookieSigner(object): + """ + save/restore ``environ`` entries via digially signed cookie + + This class converts content into a timed and digitally signed + cookie, as well as having the facility to reverse this procedure. + If the cookie, after the content is encoded and signed exceeds the + maximum length (4096), then CookieTooLarge exception is raised. + + The timeout of the cookie is handled on the server side for a few + reasons. First, if a 'Expires' directive is added to a cookie, then + the cookie becomes persistent (lasting even after the browser window + has closed). Second, the user's clock may be wrong (perhaps + intentionally). The timeout is specified in minutes; and expiration + date returned is rounded to one second. + + Constructor Arguments: + + ``secret`` + + This is a secret key if you want to syncronize your keys so + that the cookie will be good across a cluster of computers. + It is recommended via the HMAC specification (RFC 2104) that + the secret key be 64 bytes since this is the block size of + the hashing. If you do not provide a secret key, a random + one is generated each time you create the handler; this + should be sufficient for most cases. + + ``timeout`` + + This is the time (in minutes) from which the cookie is set + to expire. Note that on each request a new (replacement) + cookie is sent, hence this is effectively a session timeout + parameter for your entire cluster. If you do not provide a + timeout, it is set at 30 minutes. + + ``maxlen`` + + This is the maximum size of the *signed* cookie; hence the + actual content signed will be somewhat less. If the cookie + goes over this size, a ``CookieTooLarge`` exception is + raised so that unexpected handling of cookies on the client + side are avoided. By default this is set at 4k (4096 bytes), + which is the standard cookie size limit. + + """ + def __init__(self, secret = None, timeout = None, maxlen = None): + self.timeout = timeout or 30 + if isinstance(timeout, six.string_types): + raise ValueError( + "Timeout must be a number (minutes), not a string (%r)" + % timeout) + self.maxlen = maxlen or 4096 + self.secret = secret or new_secret() + + def sign(self, content): + """ + Sign the content returning a valid cookie (that does not + need to be escaped and quoted). The expiration of this + cookie is handled server-side in the auth() function. + """ + timestamp = make_time(time.time() + 60*self.timeout) + if six.PY3: + content = content.encode('utf8') + timestamp = timestamp.encode('utf8') + cookie = base64.encodestring( + hmac.new(self.secret, content, sha1).digest() + + timestamp + + content) + cookie = cookie.replace(b"/", b"_").replace(b"=", b"~") + cookie = cookie.replace(b'\n', b'').replace(b'\r', b'') + if len(cookie) > self.maxlen: + raise CookieTooLarge(content, cookie) + return cookie + + def auth(self, cookie): + """ + Authenticate the cooke using the signature, verify that it + has not expired; and return the cookie's content + """ + decode = base64.decodestring( + cookie.replace("_", "/").replace("~", "=")) + signature = decode[:_signature_size] + expires = decode[_signature_size:_header_size] + content = decode[_header_size:] + if signature == hmac.new(self.secret, content, sha1).digest(): + if int(expires) > int(make_time(time.time())): + return content + else: + # This is the normal case of an expired cookie; just + # don't bother doing anything here. + pass + else: + # This case can happen if the server is restarted with a + # different secret; or if the user's IP address changed + # due to a proxy. However, it could also be a break-in + # attempt -- so should it be reported? + pass + +class AuthCookieEnviron(list): + """ + a list of environment keys to be saved via cookie + + An instance of this object, found at ``environ['paste.auth.cookie']`` + lists the `environ` keys that were restored from or will be added + to the digially signed cookie. This object can be accessed from an + `environ` variable by using this module's name. + """ + def __init__(self, handler, scanlist): + list.__init__(self, scanlist) + self.handler = handler + def append(self, value): + if value in self: + return + list.append(self, str(value)) + +class AuthCookieHandler(object): + """ + the actual handler that should be put in your middleware stack + + This middleware uses cookies to stash-away a previously authenticated + user (and perhaps other variables) so that re-authentication is not + needed. This does not implement sessions; and therefore N servers + can be syncronized to accept the same saved authentication if they + all use the same cookie_name and secret. + + By default, this handler scans the `environ` for the REMOTE_USER + and REMOTE_SESSION key; if found, it is stored. It can be + configured to scan other `environ` keys as well -- but be careful + not to exceed 2-3k (so that the encoded and signed cookie does not + exceed 4k). You can ask it to handle other environment variables + by doing: + + ``environ['paste.auth.cookie'].append('your.environ.variable')`` + + + Constructor Arguments: + + ``application`` + + This is the wrapped application which will have access to + the ``environ['REMOTE_USER']`` restored by this middleware. + + ``cookie_name`` + + The name of the cookie used to store this content, by default + it is ``PASTE_AUTH_COOKIE``. + + ``scanlist`` + + This is the initial set of ``environ`` keys to + save/restore to the signed cookie. By default is consists + only of ``REMOTE_USER`` and ``REMOTE_SESSION``; any tuple + or list of environment keys will work. However, be + careful, as the total saved size is limited to around 3k. + + ``signer`` + + This is the signer object used to create the actual cookie + values, by default, it is ``AuthCookieSigner`` and is passed + the remaining arguments to this function: ``secret``, + ``timeout``, and ``maxlen``. + + At this time, each cookie is individually signed. To store more + than the 4k of data; it is possible to sub-class this object to + provide different ``environ_name`` and ``cookie_name`` + """ + environ_name = 'paste.auth.cookie' + cookie_name = 'PASTE_AUTH_COOKIE' + signer_class = AuthCookieSigner + environ_class = AuthCookieEnviron + + def __init__(self, application, cookie_name=None, scanlist=None, + signer=None, secret=None, timeout=None, maxlen=None): + if not signer: + signer = self.signer_class(secret, timeout, maxlen) + self.signer = signer + self.scanlist = scanlist or ('REMOTE_USER','REMOTE_SESSION') + self.application = application + self.cookie_name = cookie_name or self.cookie_name + + def __call__(self, environ, start_response): + if self.environ_name in environ: + raise AssertionError("AuthCookie already installed!") + scanlist = self.environ_class(self, self.scanlist) + jar = get_cookies(environ) + if self.cookie_name in jar: + content = self.signer.auth(jar[self.cookie_name].value) + if content: + for pair in content.split(";"): + (k, v) = pair.split("=") + k = decode(k) + if k not in scanlist: + scanlist.append(k) + if k in environ: + continue + environ[k] = decode(v) + if 'REMOTE_USER' == k: + environ['AUTH_TYPE'] = 'cookie' + environ[self.environ_name] = scanlist + if "paste.httpexceptions" in environ: + warnings.warn("Since paste.httpexceptions is hooked in your " + "processing chain before paste.auth.cookie, if an " + "HTTPRedirection is raised, the cookies this module sets " + "will not be included in your response.\n") + + def response_hook(status, response_headers, exc_info=None): + """ + Scan the environment for keys specified in the scanlist, + pack up their values, signs the content and issues a cookie. + """ + scanlist = environ.get(self.environ_name) + assert scanlist and isinstance(scanlist, self.environ_class) + content = [] + for k in scanlist: + v = environ.get(k) + if v is not None: + if type(v) is not str: + raise ValueError( + "The value of the environmental variable %r " + "is not a str (only str is allowed; got %r)" + % (k, v)) + content.append("%s=%s" % (encode(k), encode(v))) + if content: + content = ";".join(content) + content = self.signer.sign(content) + if six.PY3: + content = content.decode('utf8') + cookie = '%s=%s; Path=/;' % (self.cookie_name, content) + if 'https' == environ['wsgi.url_scheme']: + cookie += ' secure;' + response_headers.append(('Set-Cookie', cookie)) + return start_response(status, response_headers, exc_info) + return self.application(environ, response_hook) + +middleware = AuthCookieHandler + +# Paste Deploy entry point: +def make_auth_cookie( + app, global_conf, + # Should this get picked up from global_conf somehow?: + cookie_name='PASTE_AUTH_COOKIE', + scanlist=('REMOTE_USER', 'REMOTE_SESSION'), + # signer cannot be set + secret=None, + timeout=30, + maxlen=4096): + """ + This middleware uses cookies to stash-away a previously + authenticated user (and perhaps other variables) so that + re-authentication is not needed. This does not implement + sessions; and therefore N servers can be syncronized to accept the + same saved authentication if they all use the same cookie_name and + secret. + + By default, this handler scans the `environ` for the REMOTE_USER + and REMOTE_SESSION key; if found, it is stored. It can be + configured to scan other `environ` keys as well -- but be careful + not to exceed 2-3k (so that the encoded and signed cookie does not + exceed 4k). You can ask it to handle other environment variables + by doing: + + ``environ['paste.auth.cookie'].append('your.environ.variable')`` + + Configuration: + + ``cookie_name`` + + The name of the cookie used to store this content, by + default it is ``PASTE_AUTH_COOKIE``. + + ``scanlist`` + + This is the initial set of ``environ`` keys to + save/restore to the signed cookie. By default is consists + only of ``REMOTE_USER`` and ``REMOTE_SESSION``; any + space-separated list of environment keys will work. + However, be careful, as the total saved size is limited to + around 3k. + + ``secret`` + + The secret that will be used to sign the cookies. If you + don't provide one (and none is set globally) then a random + secret will be created. Each time the server is restarted + a new secret will then be created and all cookies will + become invalid! This can be any string value. + + ``timeout`` + + The time to keep the cookie, expressed in minutes. This + is handled server-side, so a new cookie with a new timeout + is added to every response. + + ``maxlen`` + + The maximum length of the cookie that is sent (default 4k, + which is a typical browser maximum) + + """ + if isinstance(scanlist, six.string_types): + scanlist = scanlist.split() + if secret is None and global_conf.get('secret'): + secret = global_conf['secret'] + try: + timeout = int(timeout) + except ValueError: + raise ValueError('Bad value for timeout (must be int): %r' + % timeout) + try: + maxlen = int(maxlen) + except ValueError: + raise ValueError('Bad value for maxlen (must be int): %r' + % maxlen) + return AuthCookieHandler( + app, cookie_name=cookie_name, scanlist=scanlist, + secret=secret, timeout=timeout, maxlen=maxlen) + +__all__ = ['AuthCookieHandler', 'AuthCookieSigner', 'AuthCookieEnviron'] + +if "__main__" == __name__: + import doctest + doctest.testmod(optionflags=doctest.ELLIPSIS) + diff --git a/paste/auth/digest.py b/paste/auth/digest.py new file mode 100644 index 0000000..85e0362 --- /dev/null +++ b/paste/auth/digest.py @@ -0,0 +1,254 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +Digest HTTP/1.1 Authentication + +This module implements ``Digest`` authentication as described by +RFC 2617 [1]_ . + +Basically, you just put this module before your application, and it +takes care of requesting and handling authentication requests. This +module has been tested with several common browsers "out-in-the-wild". + +>>> from paste.wsgilib import dump_environ +>>> from paste.httpserver import serve +>>> # from paste.auth.digest import digest_password, AuthDigestHandler +>>> realm = 'Test Realm' +>>> def authfunc(environ, realm, username): +... return digest_password(realm, username, username) +>>> serve(AuthDigestHandler(dump_environ, realm, authfunc)) +serving on... + +This code has not been audited by a security expert, please use with +caution (or better yet, report security holes). At this time, this +implementation does not provide for further challenges, nor does it +support Authentication-Info header. It also uses md5, and an option +to use sha would be a good thing. + +.. [1] http://www.faqs.org/rfcs/rfc2617.html +""" +from paste.httpexceptions import HTTPUnauthorized +from paste.httpheaders import * +try: + from hashlib import md5 +except ImportError: + from md5 import md5 +import time, random +from six.moves.urllib.parse import quote as url_quote +import six + +def _split_auth_string(auth_string): + """ split a digest auth string into individual key=value strings """ + prev = None + for item in auth_string.split(","): + try: + if prev.count('"') == 1: + prev = "%s,%s" % (prev, item) + continue + except AttributeError: + if prev == None: + prev = item + continue + else: + raise StopIteration + yield prev.strip() + prev = item + + yield prev.strip() + raise StopIteration + +def _auth_to_kv_pairs(auth_string): + """ split a digest auth string into key, value pairs """ + for item in _split_auth_string(auth_string): + (k, v) = item.split("=", 1) + if v.startswith('"') and len(v) > 1 and v.endswith('"'): + v = v[1:-1] + yield (k, v) + +def digest_password(realm, username, password): + """ construct the appropriate hashcode needed for HTTP digest """ + content = "%s:%s:%s" % (username, realm, password) + if six.PY3: + content = content.encode('utf8') + return md5(content).hexdigest() + +class AuthDigestAuthenticator(object): + """ implementation of RFC 2617 - HTTP Digest Authentication """ + def __init__(self, realm, authfunc): + self.nonce = {} # list to prevent replay attacks + self.authfunc = authfunc + self.realm = realm + + def build_authentication(self, stale = ''): + """ builds the authentication error """ + content = "%s:%s" % (time.time(), random.random()) + if six.PY3: + content = content.encode('utf-8') + nonce = md5(content).hexdigest() + + content = "%s:%s" % (time.time(), random.random()) + if six.PY3: + content = content.encode('utf-8') + opaque = md5(content).hexdigest() + + self.nonce[nonce] = None + parts = {'realm': self.realm, 'qop': 'auth', + 'nonce': nonce, 'opaque': opaque } + if stale: + parts['stale'] = 'true' + head = ", ".join(['%s="%s"' % (k, v) for (k, v) in parts.items()]) + head = [("WWW-Authenticate", 'Digest %s' % head)] + return HTTPUnauthorized(headers=head) + + def compute(self, ha1, username, response, method, + path, nonce, nc, cnonce, qop): + """ computes the authentication, raises error if unsuccessful """ + if not ha1: + return self.build_authentication() + content = '%s:%s' % (method, path) + if six.PY3: + content = content.encode('utf8') + ha2 = md5(content).hexdigest() + if qop: + chk = "%s:%s:%s:%s:%s:%s" % (ha1, nonce, nc, cnonce, qop, ha2) + else: + chk = "%s:%s:%s" % (ha1, nonce, ha2) + if six.PY3: + chk = chk.encode('utf8') + if response != md5(chk).hexdigest(): + if nonce in self.nonce: + del self.nonce[nonce] + return self.build_authentication() + pnc = self.nonce.get(nonce,'00000000') + if pnc is not None and nc <= pnc: + if nonce in self.nonce: + del self.nonce[nonce] + return self.build_authentication(stale = True) + self.nonce[nonce] = nc + return username + + def authenticate(self, environ): + """ This function takes a WSGI environment and authenticates + the request returning authenticated user or error. + """ + method = REQUEST_METHOD(environ) + fullpath = url_quote(SCRIPT_NAME(environ)) + url_quote(PATH_INFO(environ)) + authorization = AUTHORIZATION(environ) + if not authorization: + return self.build_authentication() + (authmeth, auth) = authorization.split(" ", 1) + if 'digest' != authmeth.lower(): + return self.build_authentication() + amap = dict(_auth_to_kv_pairs(auth)) + try: + username = amap['username'] + authpath = amap['uri'] + nonce = amap['nonce'] + realm = amap['realm'] + response = amap['response'] + assert authpath.split("?", 1)[0] in fullpath + assert realm == self.realm + qop = amap.get('qop', '') + cnonce = amap.get('cnonce', '') + nc = amap.get('nc', '00000000') + if qop: + assert 'auth' == qop + assert nonce and nc + except: + return self.build_authentication() + ha1 = self.authfunc(environ, realm, username) + return self.compute(ha1, username, response, method, authpath, + nonce, nc, cnonce, qop) + + __call__ = authenticate + +class AuthDigestHandler(object): + """ + middleware for HTTP Digest authentication (RFC 2617) + + This component follows the procedure below: + + 0. If the REMOTE_USER environment variable is already populated; + then this middleware is a no-op, and the request is passed + along to the application. + + 1. If the HTTP_AUTHORIZATION header was not provided or specifies + an algorithem other than ``digest``, then a HTTPUnauthorized + response is generated with the challenge. + + 2. If the response is malformed or or if the user's credientials + do not pass muster, another HTTPUnauthorized is raised. + + 3. If all goes well, and the user's credintials pass; then + REMOTE_USER environment variable is filled in and the + AUTH_TYPE is listed as 'digest'. + + Parameters: + + ``application`` + + The application object is called only upon successful + authentication, and can assume ``environ['REMOTE_USER']`` + is set. If the ``REMOTE_USER`` is already set, this + middleware is simply pass-through. + + ``realm`` + + This is a identifier for the authority that is requesting + authorization. It is shown to the user and should be unique + within the domain it is being used. + + ``authfunc`` + + This is a callback function which performs the actual + authentication; the signature of this callback is: + + authfunc(environ, realm, username) -> hashcode + + This module provides a 'digest_password' helper function + which can help construct the hashcode; it is recommended + that the hashcode is stored in a database, not the user's + actual password (since you only need the hashcode). + """ + def __init__(self, application, realm, authfunc): + self.authenticate = AuthDigestAuthenticator(realm, authfunc) + self.application = application + + def __call__(self, environ, start_response): + username = REMOTE_USER(environ) + if not username: + result = self.authenticate(environ) + if isinstance(result, str): + AUTH_TYPE.update(environ,'digest') + REMOTE_USER.update(environ, result) + else: + return result.wsgi_application(environ, start_response) + return self.application(environ, start_response) + +middleware = AuthDigestHandler + +__all__ = ['digest_password', 'AuthDigestHandler' ] + +def make_digest(app, global_conf, realm, authfunc, **kw): + """ + Grant access via digest authentication + + Config looks like this:: + + [filter:grant] + use = egg:Paste#auth_digest + realm=myrealm + authfunc=somepackage.somemodule:somefunction + + """ + from paste.util.import_string import eval_import + import types + authfunc = eval_import(authfunc) + assert isinstance(authfunc, types.FunctionType), "authfunc must resolve to a function" + return AuthDigestHandler(app, realm, authfunc) + +if "__main__" == __name__: + import doctest + doctest.testmod(optionflags=doctest.ELLIPSIS) diff --git a/paste/auth/form.py b/paste/auth/form.py new file mode 100644 index 0000000..9be82a2 --- /dev/null +++ b/paste/auth/form.py @@ -0,0 +1,149 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +Authentication via HTML Form + +This is a very simple HTML form login screen that asks for the username +and password. This middleware component requires that an authorization +function taking the name and passsword and that it be placed in your +application stack. This class does not include any session management +code or way to save the user's authorization; however, it is easy enough +to put ``paste.auth.cookie`` in your application stack. + +>>> from paste.wsgilib import dump_environ +>>> from paste.httpserver import serve +>>> from paste.auth.cookie import AuthCookieHandler +>>> from paste.auth.form import AuthFormHandler +>>> def authfunc(environ, username, password): +... return username == password +>>> serve(AuthCookieHandler( +... AuthFormHandler(dump_environ, authfunc))) +serving on... + +""" +from paste.request import construct_url, parse_formvars + +TEMPLATE = """\ + + Please Login! + +

Please Login

+
+
+
Username:
+
+
Password:
+
+
+ +
+
+ + +""" + +class AuthFormHandler(object): + """ + HTML-based login middleware + + This causes a HTML form to be returned if ``REMOTE_USER`` is + not found in the ``environ``. If the form is returned, the + ``username`` and ``password`` combination are given to a + user-supplied authentication function, ``authfunc``. If this + is successful, then application processing continues. + + Parameters: + + ``application`` + + The application object is called only upon successful + authentication, and can assume ``environ['REMOTE_USER']`` + is set. If the ``REMOTE_USER`` is already set, this + middleware is simply pass-through. + + ``authfunc`` + + This is a mandatory user-defined function which takes a + ``environ``, ``username`` and ``password`` for its first + three arguments. It should return ``True`` if the user is + authenticated. + + ``template`` + + This is an optional (a default is provided) HTML + fragment that takes exactly one ``%s`` substution + argument; which *must* be used for the form's ``action`` + to ensure that this middleware component does not alter + the current path. The HTML form must use ``POST`` and + have two input names: ``username`` and ``password``. + + Since the authentication form is submitted (via ``POST``) + neither the ``PATH_INFO`` nor the ``QUERY_STRING`` are accessed, + and hence the current path remains _unaltered_ through the + entire authentication process. If authentication succeeds, the + ``REQUEST_METHOD`` is converted from a ``POST`` to a ``GET``, + so that a redirect is unnecessary (unlike most form auth + implementations) + """ + + def __init__(self, application, authfunc, template=None): + self.application = application + self.authfunc = authfunc + self.template = template or TEMPLATE + + def __call__(self, environ, start_response): + username = environ.get('REMOTE_USER','') + if username: + return self.application(environ, start_response) + + if 'POST' == environ['REQUEST_METHOD']: + formvars = parse_formvars(environ, include_get_vars=False) + username = formvars.get('username') + password = formvars.get('password') + if username and password: + if self.authfunc(environ, username, password): + environ['AUTH_TYPE'] = 'form' + environ['REMOTE_USER'] = username + environ['REQUEST_METHOD'] = 'GET' + environ['CONTENT_LENGTH'] = '' + environ['CONTENT_TYPE'] = '' + del environ['paste.parsed_formvars'] + return self.application(environ, start_response) + + content = self.template % construct_url(environ) + start_response("200 OK", [('Content-Type', 'text/html'), + ('Content-Length', str(len(content)))]) + return [content] + +middleware = AuthFormHandler + +__all__ = ['AuthFormHandler'] + +def make_form(app, global_conf, realm, authfunc, **kw): + """ + Grant access via form authentication + + Config looks like this:: + + [filter:grant] + use = egg:Paste#auth_form + realm=myrealm + authfunc=somepackage.somemodule:somefunction + + """ + from paste.util.import_string import eval_import + import types + authfunc = eval_import(authfunc) + assert isinstance(authfunc, types.FunctionType), "authfunc must resolve to a function" + template = kw.get('template') + if template is not None: + template = eval_import(template) + assert isinstance(template, str), "template must resolve to a string" + + return AuthFormHandler(app, authfunc, template) + +if "__main__" == __name__: + import doctest + doctest.testmod(optionflags=doctest.ELLIPSIS) diff --git a/paste/auth/grantip.py b/paste/auth/grantip.py new file mode 100644 index 0000000..3fe6e1c --- /dev/null +++ b/paste/auth/grantip.py @@ -0,0 +1,114 @@ +# (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 +""" +Grant roles and logins based on IP address. +""" +import six +from paste.util import ip4 + +class GrantIPMiddleware(object): + + """ + On each request, ``ip_map`` is checked against ``REMOTE_ADDR`` + and logins and roles are assigned based on that. + + ``ip_map`` is a map of {ip_mask: (username, roles)}. Either + ``username`` or ``roles`` may be None. Roles may also be prefixed + with ``-``, like ``'-system'`` meaning that role should be + revoked. ``'__remove__'`` for a username will remove the username. + + If ``clobber_username`` is true (default) then any user + specification will override the current value of ``REMOTE_USER``. + ``'__remove__'`` will always clobber the username. + + ``ip_mask`` is something that `paste.util.ip4:IP4Range + `_ can parse. Simple IP + addresses, IP/mask, ip<->ip ranges, and hostnames are allowed. + """ + + def __init__(self, app, ip_map, clobber_username=True): + self.app = app + self.ip_map = [] + for key, value in ip_map.items(): + self.ip_map.append((ip4.IP4Range(key), + self._convert_user_role(value[0], value[1]))) + self.clobber_username = clobber_username + + def _convert_user_role(self, username, roles): + if roles and isinstance(roles, six.string_types): + roles = roles.split(',') + return (username, roles) + + def __call__(self, environ, start_response): + addr = ip4.ip2int(environ['REMOTE_ADDR'], False) + remove_user = False + add_roles = [] + for range, (username, roles) in self.ip_map: + if addr in range: + if roles: + add_roles.extend(roles) + if username == '__remove__': + remove_user = True + elif username: + if (not environ.get('REMOTE_USER') + or self.clobber_username): + environ['REMOTE_USER'] = username + if (remove_user and 'REMOTE_USER' in environ): + del environ['REMOTE_USER'] + if roles: + self._set_roles(environ, add_roles) + return self.app(environ, start_response) + + def _set_roles(self, environ, roles): + cur_roles = environ.get('REMOTE_USER_TOKENS', '').split(',') + # Get rid of empty roles: + cur_roles = list(filter(None, cur_roles)) + remove_roles = [] + for role in roles: + if role.startswith('-'): + remove_roles.append(role[1:]) + else: + if role not in cur_roles: + cur_roles.append(role) + for role in remove_roles: + if role in cur_roles: + cur_roles.remove(role) + environ['REMOTE_USER_TOKENS'] = ','.join(cur_roles) + + +def make_grantip(app, global_conf, clobber_username=False, **kw): + """ + Grant roles or usernames based on IP addresses. + + Config looks like this:: + + [filter:grant] + use = egg:Paste#grantip + clobber_username = true + # Give localhost system role (no username): + 127.0.0.1 = -:system + # Give everyone in 192.168.0.* editor role: + 192.168.0.0/24 = -:editor + # Give one IP the username joe: + 192.168.0.7 = joe + # And one IP is should not be logged in: + 192.168.0.10 = __remove__:-editor + + """ + from paste.deploy.converters import asbool + clobber_username = asbool(clobber_username) + ip_map = {} + for key, value in kw.items(): + if ':' in value: + username, role = value.split(':', 1) + else: + username = value + role = '' + if username == '-': + username = '' + if role == '-': + role = '' + ip_map[key] = value + return GrantIPMiddleware(app, ip_map, clobber_username) + + diff --git a/paste/auth/multi.py b/paste/auth/multi.py new file mode 100644 index 0000000..b378fa6 --- /dev/null +++ b/paste/auth/multi.py @@ -0,0 +1,79 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +Authentication via Multiple Methods + +In some environments, the choice of authentication method to be used +depends upon the environment and is not "fixed". This middleware allows +N authentication methods to be registered along with a goodness function +which determines which method should be used. The following example +demonstrates how to use both form and digest authentication in a server +stack; by default it uses form-based authentication unless +``*authmeth=digest`` is specified as a query argument. + +>>> from paste.auth import form, cookie, digest, multi +>>> from paste.wsgilib import dump_environ +>>> from paste.httpserver import serve +>>> +>>> multi = multi.MultiHandler(dump_environ) +>>> def authfunc(environ, realm, user): +... return digest.digest_password(realm, user, user) +>>> multi.add_method('digest', digest.middleware, "Test Realm", authfunc) +>>> multi.set_query_argument('digest') +>>> +>>> def authfunc(environ, username, password): +... return username == password +>>> multi.add_method('form', form.middleware, authfunc) +>>> multi.set_default('form') +>>> serve(cookie.middleware(multi)) +serving on... + +""" + +class MultiHandler(object): + """ + Multiple Authentication Handler + + This middleware provides two othogonal facilities: + + - a manner to register any number of authentication middlewares + + - a mechanism to register predicates which cause one of the + registered middlewares to be used depending upon the request + + If none of the predicates returns True, then the application is + invoked directly without middleware + """ + def __init__(self, application): + self.application = application + self.default = application + self.binding = {} + self.predicate = [] + def add_method(self, name, factory, *args, **kwargs): + self.binding[name] = factory(self.application, *args, **kwargs) + def add_predicate(self, name, checker): + self.predicate.append((checker, self.binding[name])) + def set_default(self, name): + """ set default authentication method """ + self.default = self.binding[name] + def set_query_argument(self, name, key = '*authmeth', value = None): + """ choose authentication method based on a query argument """ + lookfor = "%s=%s" % (key, value or name) + self.add_predicate(name, + lambda environ: lookfor in environ.get('QUERY_STRING','')) + def __call__(self, environ, start_response): + for (checker, binding) in self.predicate: + if checker(environ): + return binding(environ, start_response) + return self.default(environ, start_response) + +middleware = MultiHandler + +__all__ = ['MultiHandler'] + +if "__main__" == __name__: + import doctest + doctest.testmod(optionflags=doctest.ELLIPSIS) + diff --git a/paste/auth/open_id.py b/paste/auth/open_id.py new file mode 100644 index 0000000..f79f7f8 --- /dev/null +++ b/paste/auth/open_id.py @@ -0,0 +1,413 @@ +# (c) 2005 Ben Bangert +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +""" +OpenID Authentication (Consumer) + +OpenID is a distributed authentication system for single sign-on originally +developed at/for LiveJournal.com. + + http://openid.net/ + +URL. You can have multiple identities in the same way you can have multiple +URLs. All OpenID does is provide a way to prove that you own a URL (identity). +And it does this without passing around your password, your email address, or +anything you don't want it to. There's no profile exchange component at all: +your profiile is your identity URL, but recipients of your identity can then +learn more about you from any public, semantically interesting documents +linked thereunder (FOAF, RSS, Atom, vCARD, etc.). + +``Note``: paste.auth.openid requires installation of the Python-OpenID +libraries:: + + http://www.openidenabled.com/ + +This module is based highly off the consumer.py that Python OpenID comes with. + +Using the OpenID Middleware +=========================== + +Using the OpenID middleware is fairly easy, the most minimal example using the +basic login form thats included:: + + # Add to your wsgi app creation + from paste.auth import open_id + + wsgi_app = open_id.middleware(wsgi_app, '/somewhere/to/store/openid/data') + +You will now have the OpenID form available at /oid on your site. Logging in will +verify that the login worked. + +A more complete login should involve having the OpenID middleware load your own +login page after verifying the OpenID URL so that you can retain the login +information in your webapp (session, cookies, etc.):: + + wsgi_app = open_id.middleware(wsgi_app, '/somewhere/to/store/openid/data', + login_redirect='/your/login/code') + +Your login code should then be configured to retrieve 'paste.auth.open_id' for +the users OpenID URL. If this key does not exist, the user has not logged in. + +Once the login is retrieved, it should be saved in your webapp, and the user +should be redirected to wherever they would normally go after a successful +login. +""" + +__all__ = ['AuthOpenIDHandler'] + +import cgi +import urlparse +import re +import six + +import paste.request +from paste import httpexceptions + +def quoteattr(s): + qs = cgi.escape(s, 1) + return '"%s"' % (qs,) + +# You may need to manually add the openid package into your +# python path if you don't have it installed with your system python. +# If so, uncomment the line below, and change the path where you have +# Python-OpenID. +# sys.path.append('/path/to/openid/') + +from openid.store import filestore +from openid.consumer import consumer +from openid.oidutil import appendArgs + +class AuthOpenIDHandler(object): + """ + This middleware implements OpenID Consumer behavior to authenticate a + URL against an OpenID Server. + """ + + def __init__(self, app, data_store_path, auth_prefix='/oid', + login_redirect=None, catch_401=False, + url_to_username=None): + """ + Initialize the OpenID middleware + + ``app`` + Your WSGI app to call + + ``data_store_path`` + Directory to store crypto data in for use with OpenID servers. + + ``auth_prefix`` + Location for authentication process/verification + + ``login_redirect`` + Location to load after successful process of login + + ``catch_401`` + If true, then any 401 responses will turn into open ID login + requirements. + + ``url_to_username`` + A function called like ``url_to_username(environ, url)``, which should + return a string username. If not given, the URL will be the username. + """ + store = filestore.FileOpenIDStore(data_store_path) + self.oidconsumer = consumer.OpenIDConsumer(store) + + self.app = app + self.auth_prefix = auth_prefix + self.data_store_path = data_store_path + self.login_redirect = login_redirect + self.catch_401 = catch_401 + self.url_to_username = url_to_username + + def __call__(self, environ, start_response): + if environ['PATH_INFO'].startswith(self.auth_prefix): + # Let's load everything into a request dict to pass around easier + request = dict(environ=environ, start=start_response, body=[]) + request['base_url'] = paste.request.construct_url(environ, with_path_info=False, + with_query_string=False) + + path = re.sub(self.auth_prefix, '', environ['PATH_INFO']) + request['parsed_uri'] = urlparse.urlparse(path) + request['query'] = dict(paste.request.parse_querystring(environ)) + + path = request['parsed_uri'][2] + if path == '/' or not path: + return self.render(request) + elif path == '/verify': + return self.do_verify(request) + elif path == '/process': + return self.do_process(request) + else: + return self.not_found(request) + else: + if self.catch_401: + return self.catch_401_app_call(environ, start_response) + return self.app(environ, start_response) + + def catch_401_app_call(self, environ, start_response): + """ + Call the application, and redirect if the app returns a 401 response + """ + was_401 = [] + def replacement_start_response(status, headers, exc_info=None): + if int(status.split(None, 1)) == 401: + # @@: Do I need to append something to go back to where we + # came from? + was_401.append(1) + def dummy_writer(v): + pass + return dummy_writer + else: + return start_response(status, headers, exc_info) + app_iter = self.app(environ, replacement_start_response) + if was_401: + try: + list(app_iter) + finally: + if hasattr(app_iter, 'close'): + app_iter.close() + redir_url = paste.request.construct_url(environ, with_path_info=False, + with_query_string=False) + exc = httpexceptions.HTTPTemporaryRedirect(redir_url) + return exc.wsgi_application(environ, start_response) + else: + return app_iter + + def do_verify(self, request): + """Process the form submission, initating OpenID verification. + """ + + # First, make sure that the user entered something + openid_url = request['query'].get('openid_url') + if not openid_url: + return self.render(request, 'Enter an identity URL to verify.', + css_class='error', form_contents=openid_url) + + oidconsumer = self.oidconsumer + + # Then, ask the library to begin the authorization. + # Here we find out the identity server that will verify the + # user's identity, and get a token that allows us to + # communicate securely with the identity server. + status, info = oidconsumer.beginAuth(openid_url) + + # If the URL was unusable (either because of network + # conditions, a server error, or that the response returned + # was not an OpenID identity page), the library will return + # an error code. Let the user know that that URL is unusable. + if status in [consumer.HTTP_FAILURE, consumer.PARSE_ERROR]: + if status == consumer.HTTP_FAILURE: + fmt = 'Failed to retrieve %s' + else: + fmt = 'Could not find OpenID information in %s' + + message = fmt % (cgi.escape(openid_url),) + return self.render(request, message, css_class='error', form_contents=openid_url) + elif status == consumer.SUCCESS: + # The URL was a valid identity URL. Now we construct a URL + # that will get us to process the server response. We will + # need the token from the beginAuth call when processing + # the response. A cookie or a session object could be used + # to accomplish this, but for simplicity here we just add + # it as a query parameter of the return-to URL. + return_to = self.build_url(request, 'process', token=info.token) + + # Now ask the library for the URL to redirect the user to + # his OpenID server. It is required for security that the + # return_to URL must be under the specified trust_root. We + # just use the base_url for this server as a trust root. + redirect_url = oidconsumer.constructRedirect( + info, return_to, trust_root=request['base_url']) + + # Send the redirect response + return self.redirect(request, redirect_url) + else: + assert False, 'Not reached' + + def do_process(self, request): + """Handle the redirect from the OpenID server. + """ + oidconsumer = self.oidconsumer + + # retrieve the token from the environment (in this case, the URL) + token = request['query'].get('token', '') + + # Ask the library to check the response that the server sent + # us. Status is a code indicating the response type. info is + # either None or a string containing more information about + # the return type. + status, info = oidconsumer.completeAuth(token, request['query']) + + css_class = 'error' + openid_url = None + if status == consumer.FAILURE and info: + # In the case of failure, if info is non-None, it is the + # URL that we were verifying. We include it in the error + # message to help the user figure out what happened. + openid_url = info + fmt = "Verification of %s failed." + message = fmt % (cgi.escape(openid_url),) + elif status == consumer.SUCCESS: + # Success means that the transaction completed without + # error. If info is None, it means that the user cancelled + # the verification. + css_class = 'alert' + if info: + # This is a successful verification attempt. If this + # was a real application, we would do our login, + # comment posting, etc. here. + openid_url = info + if self.url_to_username: + username = self.url_to_username(request['environ'], openid_url) + else: + username = openid_url + if 'paste.auth_tkt.set_user' in request['environ']: + request['environ']['paste.auth_tkt.set_user'](username) + if not self.login_redirect: + fmt = ("If you had supplied a login redirect path, you would have " + "been redirected there. " + "You have successfully verified %s as your identity.") + message = fmt % (cgi.escape(openid_url),) + else: + # @@: This stuff doesn't make sense to me; why not a remote redirect? + request['environ']['paste.auth.open_id'] = openid_url + request['environ']['PATH_INFO'] = self.login_redirect + return self.app(request['environ'], request['start']) + #exc = httpexceptions.HTTPTemporaryRedirect(self.login_redirect) + #return exc.wsgi_application(request['environ'], request['start']) + else: + # cancelled + message = 'Verification cancelled' + else: + # Either we don't understand the code or there is no + # openid_url included with the error. Give a generic + # failure message. The library should supply debug + # information in a log. + message = 'Verification failed.' + + return self.render(request, message, css_class, openid_url) + + def build_url(self, request, action, **query): + """Build a URL relative to the server base_url, with the given + query parameters added.""" + base = urlparse.urljoin(request['base_url'], self.auth_prefix + '/' + action) + return appendArgs(base, query) + + def redirect(self, request, redirect_url): + """Send a redirect response to the given URL to the browser.""" + response_headers = [('Content-type', 'text/plain'), + ('Location', redirect_url)] + request['start']('302 REDIRECT', response_headers) + return ["Redirecting to %s" % redirect_url] + + def not_found(self, request): + """Render a page with a 404 return code and a message.""" + fmt = 'The path %s was not understood by this server.' + msg = fmt % (request['parsed_uri'],) + openid_url = request['query'].get('openid_url') + return self.render(request, msg, 'error', openid_url, status='404 Not Found') + + def render(self, request, message=None, css_class='alert', form_contents=None, + status='200 OK', title="Python OpenID Consumer"): + """Render a page.""" + response_headers = [('Content-type', 'text/html')] + request['start'](str(status), response_headers) + + self.page_header(request, title) + if message: + request['body'].append("
" % (css_class,)) + request['body'].append(message) + request['body'].append("
") + self.page_footer(request, form_contents) + return request['body'] + + def page_header(self, request, title): + """Render the page header""" + request['body'].append('''\ + + %s + + +

%s

+

+ This example consumer uses the Python OpenID library. It + just verifies that the URL that you enter is your identity URL. +

+''' % (title, title)) + + def page_footer(self, request, form_contents): + """Render the page footer""" + if not form_contents: + form_contents = '' + + request['body'].append('''\ +
+
+ Identity URL: + + +
+
+ + +''' % (quoteattr(self.build_url(request, 'verify')), quoteattr(form_contents))) + + +middleware = AuthOpenIDHandler + +def make_open_id_middleware( + app, + global_conf, + # Should this default to something, or inherit something from global_conf?: + data_store_path, + auth_prefix='/oid', + login_redirect=None, + catch_401=False, + url_to_username=None, + apply_auth_tkt=False, + auth_tkt_logout_path=None): + from paste.deploy.converters import asbool + from paste.util import import_string + catch_401 = asbool(catch_401) + if url_to_username and isinstance(url_to_username, six.string_types): + url_to_username = import_string.eval_import(url_to_username) + apply_auth_tkt = asbool(apply_auth_tkt) + new_app = AuthOpenIDHandler( + app, data_store_path=data_store_path, auth_prefix=auth_prefix, + login_redirect=login_redirect, catch_401=catch_401, + url_to_username=url_to_username or None) + if apply_auth_tkt: + from paste.auth import auth_tkt + new_app = auth_tkt.make_auth_tkt_middleware( + new_app, global_conf, logout_path=auth_tkt_logout_path) + return new_app -- cgit v1.2.1