0001"""Authentication middleware
0002
0003This module provides one piece of middleware named 
0004``authkit.authenticate.middleware`` which is used to intercept responses with
0005a specified status code, present a user with a means of authenticating 
0006themselves and handle the sign in process.
0007
0008Each of the authentication methods supported by the middleware is described in
0009detail in the main AuthKit manual. The methods include:
0010
0011* HTTP Basic (``basic``)
0012* HTTP Digest (``digest``)
0013* OpenID Passurl (``openid``)
0014* Form and Cookie (``form``)
0015* Forward (``forward``)
0016* Redirect (``redirect``)
0017
0018The authenticate middleware can be configured directly or by means of a Paste
0019deploy config file as used by Pylons. It can be used directly like this:
0020
0021.. code-block:: Python
0022
0023    from authkit.authenticate import middleware, test_app
0024    from paste.httpserver import serve
0025
0026    import sys
0027    app = middleware(
0028        test_app,
0029        enable = True,
0030        method = 'passurl',
0031        cookie_secret='some_secret',
0032    )
0033    
0034    serve(app, host='0.0.0.0', port=8000)
0035
0036"""
0037
0038import types
0039import warnings
0040import logging
0041import os
0042import os.path
0043
0044from paste.util.import_string import eval_import
0045from multi import MultiHandler, status_checker
0046from pkg_resources import iter_entry_points, load_entry_point
0047from paste.deploy.converters import asbool
0048from paste.httpexceptions import HTTPExceptionHandler
0049
0050from authkit.authorize import authorize_request
0051from authkit.permissions import RemoteUser, no_authkit_users_in_environ,       AuthKitConfigError
0053
0054# Main middleware base classes
0055
0056class AuthKitAuthHandler(object):
0057    """
0058    The base class for all middleware responsible for handling 
0059    authentication and setting whatever needs to be set so that the
0060    ``AuthKitUserSetter`` middleware can set REMOTE_USER on subsequent
0061    requests. ``AuthKitAuthHandler``s only get inserted into the 
0062    middleware stack if an appropriate status code (as set in the 
0063    ``authkit.setup.intercept`` config option) is intercepted by the 
0064    authentication middleware.
0065    """
0066    pass
0067
0068class AuthKitUserSetter(object):
0069    """
0070    The base class for all middleware responsible for attempting to set
0071    REMOTE_USER on each request. The class is overridden by the induvidual
0072    handlers.
0073    """
0074    pass
0075
0076# Setting up logging
0077
0078log = logging.getLogger('authkit.authenticate')
0079
0080def strip_base(conf, base):
0081    result = {}
0082    for key in conf.keys():
0083        if key.startswith(base):
0084            result[key[len(base):]] = conf[key]
0085    return result
0086
0087def swap_underscore(*confs):
0088    results = []
0089    for conf in confs:
0090        result = {}
0091        for k,v in conf.items():
0092            result[k.replace('.','_')] = v
0093        results.append(result)
0094    return results
0095
0096def valid_password(environ, username, password):
0097    """ 
0098    A function which can be used with the ``basic`` and ``form`` authentication
0099    methods to validate a username and passowrd.
0100
0101    This implementation is used by default if no other method is specified. It
0102    checks the for an ``authkit.users`` object present in the ``environ``
0103    dictionary under the ``authkit.users`` key and uses the information there
0104    to validate the username and password.
0105
0106    In this implementation usernames are case insensitive and passwords are
0107    case sensitive. The function returns ``True`` if the user ``username`` has
0108    the password specified by ``password`` and returns ``False`` if the user
0109    doesn't exist or the password is incorrect.
0110
0111    If you create and specify your own ``authkit.users`` object with the same
0112    API, this method will also work correctly with your custom solution. See
0113    the AuthKit manual for information on the user management api, how to
0114    specify a different ``authkit.users`` object (say to read user information
0115    from a file rather than have it specified directly) and for information on
0116    how to create your own ``Users`` objects.
0117    """
0118    log.debug("valid_password called. username: %s", username)
0119    if not environ.has_key('authkit.users'):
0120        raise no_authkit_users_in_environ
0121    users = environ['authkit.users']
0122    if not users.user_exists(username):
0123        return False
0124    elif users.user_has_password(username.lower(), password):
0125        return True
0126    return False
0127
0128def digest_password(environ, realm, username):
0129    """
0130    This is similar to ``valid_password()`` but is used with the ``digest``
0131    authentication method and rather than checking a username and password and
0132    returning ``True`` or ``False`` it takes the realm and username as input,
0133    looks up the correct password and and returns a digest by calling the
0134    ``authkit.authenticate.digest.digest_password()`` function with the
0135    parameters ``realm``, ``username`` and ``password`` respectively. The
0136    digest returned is then compared with the one submitted by the browser.
0137
0138    As with ``valid_password()`` this method is designed to work with the user
0139    management API so you can use it with ``authkit.users`` objects or your own
0140    custom ``Users`` objects. Alternatively you can specify your own function
0141    which can lookup the password in whichever way you prefer, perhaps from a
0142    database or LDAP connection.
0143    
0144    Only required if you intend to use HTTP digest authentication.
0145    """
0146    log.debug(
0147        "digest_password called. username: %s, realm: %s", username, realm
0148    )
0149    if not environ.has_key('authkit.users'):
0150        raise no_authkit_users_in_environ
0151    users = environ['authkit.users']
0152    if users.user_exists(username):
0153        password = users.user(username)['password']
0154        return digest.digest_password(realm, username, password)
0155    # After speaking to Clark Evans who wrote the origianl code, this is the 
0156    # correct thing:
0157    return None
0158
0159def get_authenticate_function(app, authenticate_conf, format, prefix):
0160    """
0161    Sets up the users object, adds the middleware to add the users object
0162    to the environ and then returns authenticate methods to check a password
0163    and a digest.
0164    """
0165    function = None
0166    users = None
0167    if len(authenticate_conf) < 1:
0168        raise AuthKitConfigError('Expected at least one authenticate key, not'
0169                                 ' %r'%authenticate_conf)
0170    if authenticate_conf.keys() == ['function']:
0171        function = authenticate_conf['function']
0172        if isinstance(function, (str, unicode)):
0173            function = eval_import(function)
0174    else:
0175        user_conf = strip_base(authenticate_conf, 'user.')
0176        if not user_conf:
0177            raise AuthKitConfigError('No authenticate function or users specified')
0178        else:
0179            if user_conf.has_key('encrypt'):
0180                enc_func = eval_import(user_conf['encrypt'])
0181                secret = user_conf.get('encrypt.secret','')
0182                def encrypt(password):
0183                    return enc_func(password, secret)
0184            else:
0185                encrypt = None
0186            user_object = 'authkit.users.UsersFromString'
0187            if 'type' in user_conf.keys():
0188                user_object = user_conf['type']
0189            if isinstance(user_object, (str, unicode)):
0190                user_object = eval_import(user_object)
0191            users = user_object(user_conf['data'], encrypt)
0192            app = AddToEnviron(app, 'authkit.users', users)
0193            log.debug("authkit.users added to environ")
0194            if format == 'basic':
0195                function = valid_password
0196                log.debug("valid_password chosen %r", function)
0197            elif format == 'digest':
0198                log.debug("digest_password chosen %r", function)
0199                function = digest_password
0200            else:
0201                raise Exception('Invalid format for authenticate function %r'
0202                                % format)
0203    return app, function, users
0204
0205def get_template(template_conf, prefix):
0206    """
0207    Another utility method to reduce code duplication. This function parses a
0208    template from one of the available template options:
0209
0210    ``string``
0211        The template as a string
0212        
0213    ``file``
0214        A file containing the template
0215
0216    ``obj``
0217        A paste eval_import string or callable which returns a string
0218
0219    authkit.form.template.string = 
0220    authkit.form.template.file = 
0221    authkit.form.template.obj =
0222
0223    """
0224    template = None
0225    if len(template_conf) != 1:
0226        raise AuthKitConfigError('Expected one template entry, not %r' %
0227                                 (', '.join(template_conf.keys())))
0228    if template_conf.keys()[0] not in ['string', 'file', 'obj']:
0229        raise AuthKitConfigError("Template option can only be 'string', 'file'"
0230                                 " or 'obj'")
0231    if template_conf.keys()[0] == 'string':
0232        template = template_conf['string']
0233    elif template_conf.keys()[0] == 'file':
0234        if not os.path.exists(template_conf['file']):
0235            raise AuthKitConfigError('No such file %r exists. It was specified'
0236                                     ' by config option %r' %
0237                                     (template_conf['file'], prefix+'file'))
0238        fp = open(template_conf['file'], 'r')
0239        template = fp.read()
0240        fp.close()
0241        if not template:
0242            raise AuthKitConfigError('No data in template file %s specified by'
0243                                     ' config option %r' %
0244                                     (template_conf['file'], prefix+'file'))
0245    elif template_conf.keys()[0] == 'obj':
0246        template = eval_import(template_conf['obj'])
0247        if not template:
0248            raise AuthKitConfigError('No data in template obj %s specified by '
0249                                     'config option %r' %
0250                                     (template_conf['obj'], prefix+'obj'))
0251    else:
0252        raise AuthKitConfigError("Unknown option %r" %
0253                                 (prefix+template_conf.keys()[0]))
0254    if not template:
0255        raise AuthKitConfigError("The template loaded did not contain any data")
0256    if isinstance(template, (str, unicode)):
0257        def render_template():
0258            return template
0259        return render_template
0260    return template
0261
0262#
0263# Main middleware creator 
0264#
0265
0266class AddToEnviron(object):
0267    """
0268    Simple middleware which adds a key to the ``environ`` dictionary.
0269    
0270    Used to add the ``authkit.users`` key to the environ when this is
0271    appropriate.
0272    """
0273    def __init__(self, app, key, object):
0274        self.app = app
0275        self.key = key
0276        self.object = object
0277
0278    def __call__(self, environ, start_response):
0279        environ[self.key] = self.object
0280        return self.app(environ, start_response)
0281
0282class AddDictToEnviron(object):
0283    """Simple middleware which adds the values of a dict to the environ."""
0284    def __init__(self, app, dct):
0285        self.app = app
0286        self.dct = dct
0287
0288    def __call__(self, environ, start_response):
0289        environ.update(self.dct)
0290        return self.app(environ, start_response)
0291
0292class RequireEnvironKey(object):
0293    def __init__(self, app, key, missing_error=None):
0294        self.app = app
0295        self.key = key
0296        self.missing_error = missing_error or               'Missing the key %(key)s from the environ. Have you setup the '               'correct middleware?'
0299
0300    def __call__(self, environ, start_response):
0301        if not environ.has_key(self.key):
0302            raise Exception(self.missing_error%{'key':self.key})
0303        return self.app(environ, start_response)
0304
0305def get_methods():
0306    """Get a dictionary of the available method entry points."""
0307    available_methods = {}
0308    for method_handler in iter_entry_points(group='authkit.method', name=None):
0309        available_methods[method_handler.name] = method_handler
0310    return available_methods
0311
0312def load_method(name, from_these=None):
0313    if from_these:
0314        return from_these[name].load()
0315    else:
0316        return load_entry_point('AuthKit','authkit.method',name)
0317
0318def load_config(options, app_conf, prefix):
0319    merged = strip_base(app_conf, prefix)
0320
0321    # Now override the auth_conf_options with the manaully specified options
0322    for key, value in options.items():
0323        if merged.has_key(key):
0324            warnings.warn(
0325                'Key %s with value %r set in the config file is being ' +                   'replaced with value %r set in the application'%(
0327                    key,
0328                    auth_conf_options[key],
0329                    value
0330                )
0331            )
0332        merged[key.replace('_','.')] = value
0333    return merged
0334
0335def middleware(app, app_conf=None, global_conf=None, prefix='authkit.',
0336               handle_httpexception=True, middleware=None, **options):
0337    """
0338    This function sets up the AuthKit authenticate middleware and its use and 
0339    options are described in detail in the AuthKit manual.
0340   
0341    The function takes the following arguments and returns a WSGI application 
0342    wrapped in the appropriate AuthKit authentication middleware based on the 
0343    options specified:
0344
0345    ``app``
0346        The WSGI application the authenticate middleware should wrap
0347
0348    ``app_conf``
0349        A paste deploy ``app_conf`` dictionary to be used to setup the 
0350        middleware
0351
0352    ``global_conf``
0353         A paste deploy ``global_conf`` dictionary
0354
0355    ``prefix``
0356        The prefix which all authkit related options in the config file will
0357        have prefixed to their names. This defaults to ``authkit.`` and
0358        shouldn't normally need overriding.
0359
0360    ``middleware``
0361        A make_middleware function which should be called directly instead of 
0362        loading and calling a function based on the method name. If this is 
0363        set then ``authkit.setup.methof`` should not be set.
0364    
0365    ``**options``
0366        Any AuthKit options which are setup directly in Python code. If 
0367        specified, these options will override any options specifed in a config
0368        file.
0369
0370    All option names specified in the config file will have their prefix
0371    removed and any ``.`` characters replaced by ``_`` before the options
0372    specified by ``options`` are merged in. This means that the the option
0373    ``authkit.cookie.name`` specified in a config file sets the same options as
0374    ``cookie_name`` specified directly as an option.
0375    """
0376    if handle_httpexception:
0377        app = HTTPExceptionHandler(app)
0378
0379    # Configure the config files
0380
0381    if global_conf is None:
0382        global_conf = {}
0383    if app_conf is None:
0384        app_conf = {}
0385    if not isinstance(app_conf, dict):
0386        raise AuthKitConfigError(
0387            "Expected app_conf to be paste deploy app_conf dictionary "
0388            "from not %r" % app_conf
0389        )
0390
0391    # Merge config file and options
0392    available_methods = get_methods()
0393
0394    all_conf = load_config(options, app_conf, prefix)
0395    if middleware is not None and all_conf.has_key('setup.method'):
0396        raise AuthKitConfigError(
0397            'You cannot specify a middleware function '
0398            'and an authkit.setup.method'
0399        )
0400    if not middleware and not all_conf.has_key('setup.method'):
0401        raise AuthKitConfigError('No authkit.setup.method was specified')
0402
0403    # Check to see if middleware is disabled
0404    if asbool(all_conf.get('setup.enable', True)) == False:
0405        warnings.warn("AuthKit middleware has been turned off by the config "
0406                      "option authkit.setup.enable")
0407        return app
0408
0409    # Status Checking/Changing Middleware
0410    intercept = [str(x).strip() for x in                    all_conf.get('setup.intercept','401').split(',')]
0412    if not '401' in intercept:
0413        warnings.warn(
0414            "AuthKit is configured via the authkit.setup.intercept option not "
0415            "to intercept 401 responses so the authentication middleware will "
0416            "not be triggered even if a 401 Unauthenticated response is "
0417            "returned.")
0418
0419    if middleware:
0420        prefix_ = prefix
0421        app = middleware(
0422            app,
0423            auth_conf=all_conf,
0424            app_conf=app_conf,
0425            global_conf=global_conf,
0426            prefix=prefix_,
0427        )
0428    else:
0429        methods = [method.strip() for method in all_conf['setup.method'].split(',')]
0430        log.debug("Trying to load the following methods: %r", methods)
0431        for method in methods:
0432            if method in ['setup','config']:
0433                raise AuthKitConfigError("The name %s is reserved cannot be used "
0434                                         "as a method name" % method)
0435            if not available_methods.has_key(method):
0436                raise AuthKitConfigError(
0437                    'The authkit method %r is not available. The available methods '
0438                    'are %s and %s'%(
0439                        all_conf['setup.method'],
0440                        ', '.join(available_methods.keys()[:-1]),
0441                        available_methods.keys()[-1],
0442                    )
0443                )
0444            prefix_ = prefix+method+'.'
0445            auth_conf = strip_base(all_conf, method+'.')
0446
0447            app = available_methods[method].load()(
0448                app,
0449                auth_conf=auth_conf,
0450                app_conf=app_conf,
0451                global_conf=global_conf,
0452                prefix=prefix_,
0453            )
0454    app = AddDictToEnviron(
0455        app,
0456        {
0457            'authkit.config':strip_base(all_conf, 'config.'),
0458            'authkit.intercept':intercept,
0459            'authkit.authenticate': True,
0460        }
0461    )
0462    return app
0463
0464def sample_app(environ, start_response):
0465    """
0466    A sample WSGI application that returns a 401 status code when the path 
0467    ``/private`` is entered, triggering the authenticate middleware to 
0468    prompt the user to sign in.
0469    
0470    If used with the authenticate middleware's form method, the path 
0471    ``/signout`` will display a signed out message if 
0472    ``authkit.cookie.signout = /signout`` is specified in the config file.
0473    
0474    If used with the authenticate middleware's forward method, the path 
0475    ``/signin`` should be used to display the sign in form.
0476    
0477    The path ``/`` always displays the environment.
0478    """
0479    if environ['PATH_INFO']=='/private':
0480        authorize_request(environ, RemoteUser())
0481    if environ['PATH_INFO'] == '/signout':
0482        start_response('200 OK', [('Content-type', 'text/plain; charset=UTF-8')])
0483        if environ.has_key('REMOTE_USER'):
0484            return ["Signed Out"]
0485        else:
0486            return ["Not signed in"]
0487    elif environ['PATH_INFO'] == '/signin':
0488        start_response('200 OK', [('Content-type', 'text/plain; charset=UTF-8')])
0489        return ["Your application would display a \nsign in form here."]
0490    else:
0491        start_response('200 OK', [('Content-type', 'text/plain; charset=UTF-8')])
0492    result = ['You Have Access To This Page.\n\nHere is the environment...\n\n']
0493    for k,v in environ.items():
0494        result.append('%s: %s\n'%(k,v))
0495    return result