# -*- Python -*-
"""Authenticate using OpenID.

See L{ConfigMixin} for information on how to configure your
installation to use this.  When this authentication method is enabled,
a user can set her OpenID URL through her UserPreferences.  Once set,
the user can log in to MoinMoin by entering their OpenID in the
Username login field.  A password is not necessary; authorization will
be done through the OpenID server.

Known Issues:
=============

 - No indication on the login form that it accepts OpenID.

 - Lack of feedback to the user.  There are a number of ways a user
   can fail to authenticate, and sometimes we allow MoinMoin to fall
   back to another authentication method, and sometimes we deny the
   request outright.  But while some cases are logged, the user gets
   no information about why they were not logged in.  It's impossible
   for them to tell if their OpenID was not found in the user database
   or if their server is down or if we had a problem parsing their
   input.  While it is in some cases advisable to hold back
   information so as not to leak it to attackers, feedback in most of
   the error cases is really only useful to a user in an honest login
   attempt.

   It's not clear how to fix this in the context of the current
   pluggable authentication scheme.  We should come up with revisions
   to the API with this in mind.

 - There is no validation of OpenID URL upon setting it in
   UserPreferences.  This leads to several potential problems.  The
   user won't be prompted to correct an invalid or unusable value.  No
   check is performed to ensure that the OpenID is unique within the
   user database, or that the user is authorized to use that OpenID.

   This leads to a per-user denial of service attack: If Alice wants
   to harass Bob, she can enter Bob's OpenID in her preferences.  This
   will not give Alice access to Bob's account; rather, it may
   (depending on who comes first in the user table) cause Bob to log
   in as Alice when he logs in with OpenID.  If Bob notices, he can
   then fix the preferences for that account, but it's a nuisance at
   best.

   Again, it does not seem possible to correct this as a standalone
   authentication module, as no hooks for the processing of the
   UserPreferences form are provided by MoinMoin 1.5.  The solution to
   this lies in either revising the MoinMoin API or folding this code
   into the MoinMoin core.

 - User creation.  Ideally, one should be able to create an account with
   an OpenID and never set a password in MoinMoin at all.  With
   C{user_autocreate} in the wiki configuration this is partially implemented,
   but users will quickly find that the userform code really doesn't want
   to let them through that screen without setting a password and an email
   address.

@see: U{OpenID Enabled<http://www.openidenabled.com/>}

@requires:
  U{python-openid<http://www.openidenabled.com/openid/libraries/python>} 1.0.x

@requires: U{MoinMoin<http://moinmoin.wikiwikiweb.de/>}

@author: Kevin Turner
@contact: openid@janrain.com
@organization: JanRain, Inc.
@license: GPL

@copyright: Copyright 2005 by JanRain, Inc.
"""

__version__ = '0.0.2005-12-19'

from openid.consumer import consumer
from openid import oidutil
from openid.store.filestore import FileOpenIDStore

try:
    import cPickle as pickle
except ImportError:
    import pickle

from MoinMoin import caching, user, wikiutil

def openid(request, name=None, password=None, login=None, logout=None,
           _consumer=None, **kw):
    """Authenticate by OpenID.

    This is an authentication plug-in for use with MoinMoin 1.5's
    modular authentication code.  The signature is defined by L{MoinMoin.auth}.
    """

    # Set the log function for the OpenID libraries.
    origlog = oidutil.log
    oidutil.log = request.log

    try:
        if logout:
            # Somebody Else's Problem
            return (None, True)
        elif login:
            if _consumer is None:
                _consumer = _getConsumer(request)

            return beginAuth(request, _consumer, name)
        elif request.form.has_key('openid.mode'):
            try:
                token = request.form['moidtoken'][0]
            except KeyError:
                # XXX: Malformed reply, should let someone know that the server
                # is busted.
                request.log("Can't find the token for the OpenID reply.")
                return (None, False)
            if _consumer is None:
                _consumer = _getConsumer(request)

            theuser, cont = completeAuth(request, _consumer, token)

            if theuser is not None:
                # If we don't set a cookie, we'll have to re-authenticate with
                # every request.
                
                # FIXME: We have a problem interfacing with request.setCookie,
                # because it sets a cookie for "the current user."  There's
                # precedent for the following kludge in auth.moin_cookie and
                # auth.interwiki, but that doesn't mean I think it's a good
                # idea.
                request.user = theuser
                request.setCookie()

            return (theuser, cont)
        else:
            # Somebody Else's Problem
            return (None, True)
    finally:
        oidutil.log = origlog


def beginAuth(request, theconsumer, name):
    """Handle user input and redirect to an OpenID server."""
    
    status, info = theconsumer.beginAuth(name)

    if status is not consumer.SUCCESS:
        # Try other auth methods.
        # XXX: Provides no feedback if they *did* want to log in with
        # OpenID and need to know if their server is down, etc.
        # Maybe include some heuristic here, e.g. "return an error
        # iff username.startswith('http')."
        return (None, True)

    auth_request = info
    trust_root = request.getBaseURL()
    if request.query_string:
        sep = '&'
    else:
        sep = '?'
    return_to = '%s%smoidtoken=%s' % (
        request.getQualifiedURL(request.request_uri),
        sep,
        wikiutil.url_quote(auth_request.token))

    redirect = theconsumer.constructRedirect(auth_request,
                                             return_to,
                                             trust_root)
    request.http_redirect(redirect)
    request.finish()

    # Don't bother trying more auth methods, we just hijacked the request.
    return (None, False)

def completeAuth(request, theconsumer, token):
    """Handle response from the OpenID server and return an authenticated user.
    """
    args = {}
    for key, value in request.form.iteritems():
        # openid.consumer doesn't want to believe that unicode objects
        # come from GET requests.
        args[key.encode('utf-8')] = value[0].encode('utf-8')

    status, info = theconsumer.completeAuth(token, args)
    if status is not consumer.SUCCESS:
        # XXX: Should probably give some information to the user here.
        request.log("OpenID auth failed: %s, %s" % (status, info))
        return (None, False)
    if info is None:
        request.log("OpenID request denied or canceled.")
        # Request denied at OpenID server.
        return (None, False)
    identityURL = info
    theuser_id = userLookupByOpenID(request, identityURL)
    User = lambda **kw: user.User(request, auth_method="openid",
                                  **kw)
    if theuser_id is not None:
        theuser = User(id=theuser_id)
        # XXX: There is some weirdness around the User.trusted
        # attribute depending on which arguments you pass to the
        # constructor.  This may end up in us creating non-"trusted"
        # users, whatever that means.
    else:
        if user.getUserId(request, identityURL):
            # Okay, so we have someone with an authenticated OpenID,
            # an account *named* that OpenID, but we're not at all
            # sure that authorizes the user to access that account,
            # because the account's "openid" field wasn't set.
            # FIXME: Should explain to the user why they're not getting
            # logged in and write some log messages and stuff.
            request.log("OpenID auth for %r, but unsure what account"
                        "goes with it and can't create one." %
                        (identityURL,))
            return (None, True)

        # I'm not entirely sure what auth_attribs is for.  But I think
        # one of their effects is that if I put some things in there,
        # the UserPrefs form won't force me to set them, which is
        # behaviour I want here.  Not sure if it's abusive.
        theuser = User(auth_attribs=("password", "email"))
        theuser.openid_url = identityURL
        # I doubt I understand what I'm responsible for in new
        # user creation...  if I just make a user object here and
        # return it, is that sufficient?  Should I call
        # create_or_update?  Is there any of the new user logic
        # in userform that I need to invoke here?
        theuser.create_or_update(True)
        request.log("Created new user %s for OpenID %r" % (theuser,
                                                           identityURL))
    return (theuser, False)


def userLookupByOpenID(request, identityURL):
    # This code is lifted right out of user.getUserId.  Can it be generalized?
    if not identityURL:
        return None
    cfg = request.cfg
    try:
        openid2id = cfg._openid2id
    except AttributeError:
        arena = 'user'
        key = 'openid2id'
        cache = caching.CacheEntry(request, arena, key)
        try:
            openid2id = pickle.loads(cache.content())
        except (pickle.UnpicklingError, IOError, EOFError, ValueError):
            openid2id = {}
        cfg._openid2id = openid2id

    id = openid2id.get(identityURL, None)
    if id is None:
        for userid in user.getUserList(request):
            uopenid = user.User(request, id=userid).openid_url
            openid2id[uopenid] = userid
        arena = 'user'
        key = 'openid2id'
        cache = caching.CacheEntry(request, arena, key)
        cache.update(pickle.dumps(openid2id, user.PICKLE_PROTOCOL))
        id = openid2id.get(identityURL, None)
    return id


def _getConsumer(request):
    # I'm assuming that since you're using MoinMoin, you're probably
    # okay with a file-based association database.
    store = FileOpenIDStore(request.cfg.openid_assoc_dir)
    return consumer.OpenIDConsumer(store)


from MoinMoin.multiconfig import DefaultConfig

def _(text): return text

class ConfigMixin:
    """Things you must define in your Config class to enable Open ID.

    One way to use this would be to define your configuration class like so::

        from MoinMoin.multiconfig import DefaultConfig
        from MoinMoin import oidauth
        class Config(oidauth.ConfigMixin, DefaultConfig):
            # ... your config values here ...

    The defaults for most of the things here are sane, but you likely should
    define L{openid_assoc_dir} with an absolute path.

    @ivar openid_assoc_dir: A directory (like C{data_dir}) in which the
        OpenID library will store its data.
    @ivar auth: To use OpenID, this list of authentication methods must
        include both the L{openid} function from the oidauth module and
        the default L{MoinMoin.auth.moin_cookie<moin_cookie>} function.
        Without the C{moin_cookie} function, you will have to re-authenticate
        for I{every} request.
    @ivar user_form_fields: For users to set their OpenID, this list must
        include C{openid_url}.
    @ivar user_form_defaults: Provide a default for the field defined in
        C{user_form_fields}.
    """

    openid_assoc_dir = './openid/'
    auth = [openid] + DefaultConfig.auth
    user_form_fields = DefaultConfig.user_form_fields + [
        ('openid_url', _('OpenID'), "text", "40",
         _("(Your OpenID)")),
        ]

    user_form_defaults = DefaultConfig.user_form_defaults.copy()
    user_form_defaults.update({
        'openid_url': '',
        })


__all__ = ['openid', 'ConfigMixin']
