##############################################################################
#
# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
# 
# This software is subject to the provisions of the Zope Public License,
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE.
# 
##############################################################################
""" Cookie Crumbler: Enable cookies for non-cookie user folders.

$Id: CookieCrumbler.py 76996 2007-06-24 00:18:49Z hannosch $
"""

from base64 import encodestring, decodestring
from urllib import quote, unquote

from Acquisition import aq_inner, aq_parent
from DateTime import DateTime
from AccessControl import ClassSecurityInfo, Permissions
from ZPublisher import BeforeTraverse
import Globals
from Globals import HTMLFile
from ZPublisher.HTTPRequest import HTTPRequest
from OFS.Folder import Folder
from zExceptions import Redirect
from zope.interface import implements
from zope.app.container.interfaces import IObjectMovedEvent
from OFS.interfaces import IObjectWillBeMovedEvent

from interfaces import ICookieCrumbler


# Constants.
ATTEMPT_NONE = 0       # No attempt at authentication
ATTEMPT_LOGIN = 1      # Attempt to log in
ATTEMPT_RESUME = 2     # Attempt to resume session

ModifyCookieCrumblers = 'Modify Cookie Crumblers'
ViewManagementScreens = Permissions.view_management_screens


class CookieCrumblerDisabled(Exception):

    """Cookie crumbler should not be used for a certain request.
    """


class CookieCrumbler(Folder):

    """Reads cookies during traversal and simulates the HTTP auth headers.
    """

    implements(ICookieCrumbler)

    meta_type = 'Cookie Crumbler'

    security = ClassSecurityInfo()
    security.declareProtected(ModifyCookieCrumblers, 'manage_editProperties')
    security.declareProtected(ModifyCookieCrumblers, 'manage_changeProperties')
    security.declareProtected(ViewManagementScreens, 'manage_propertiesForm')

    # By default, anonymous users can view login/logout pages.
    _View_Permission = ('Anonymous',)


    _properties = ({'id':'auth_cookie', 'type': 'string', 'mode':'w',
                    'label':'Authentication cookie name'},
                   {'id':'name_cookie', 'type': 'string', 'mode':'w',
                    'label':'User name form variable'},
                   {'id':'pw_cookie', 'type': 'string', 'mode':'w',
                    'label':'User password form variable'},
                   {'id':'persist_cookie', 'type': 'string', 'mode':'w',
                    'label':'User name persistence form variable'},
                   {'id':'auto_login_page', 'type': 'string', 'mode':'w',
                    'label':'Login page ID'},
                   {'id':'logout_page', 'type': 'string', 'mode':'w',
                    'label':'Logout page ID'},
                   {'id':'unauth_page', 'type': 'string', 'mode':'w',
                    'label':'Failed authorization page ID'},
                   {'id':'local_cookie_path', 'type': 'boolean', 'mode':'w',
                    'label':'Use cookie paths to limit scope'},
                   {'id':'cache_header_value', 'type': 'string', 'mode':'w',
                    'label':'Cache-Control header value'},
                   {'id':'log_username', 'type':'boolean', 'mode': 'w',
                    'label':'Log cookie auth username to access log'}
                   )

    auth_cookie = '__ac'
    name_cookie = '__ac_name'
    pw_cookie = '__ac_password'
    persist_cookie = '__ac_persistent'
    auto_login_page = 'login_form'
    unauth_page = ''
    logout_page = 'logged_out'
    local_cookie_path = False
    cache_header_value = 'private'
    log_username = True

    security.declarePrivate('delRequestVar')
    def delRequestVar(self, req, name):
        # No errors of any sort may propagate, and we don't care *what*
        # they are, even to log them.
        try: del req.other[name]
        except: pass
        try: del req.form[name]
        except: pass
        try: del req.cookies[name]
        except: pass
        try: del req.environ[name]
        except: pass

    security.declarePublic('getCookiePath')
    def getCookiePath(self):
        if not self.local_cookie_path:
            return '/'
        parent = aq_parent(aq_inner(self))
        if parent is not None:
            return '/' + parent.absolute_url(1)
        else:
            return '/'

    # Allow overridable cookie set/expiration methods.
    security.declarePrivate('getCookieMethod')
    def getCookieMethod(self, name, default=None):
        return getattr(self, name, default)

    security.declarePrivate('defaultSetAuthCookie')
    def defaultSetAuthCookie(self, resp, cookie_name, cookie_value):
        # XXX: this method violates the rules for tools/utilities:
        # it depends on self.REQUEST
        kw = {}
        req = getattr(self, 'REQUEST', None)
        if req is not None and req.get('SERVER_URL', '').startswith('https:'):
            # Ask the client to send back the cookie only in SSL mode
            kw['secure'] = 'y'
        resp.setCookie(cookie_name, cookie_value,
                       path=self.getCookiePath(), **kw)

    security.declarePrivate('defaultExpireAuthCookie')
    def defaultExpireAuthCookie(self, resp, cookie_name):
        resp.expireCookie(cookie_name, path=self.getCookiePath())
    
    def _setAuthHeader(self, ac, request, response):
        """Set the auth headers for both the Zope and Medusa http request
        objects.
        """
        request._auth = 'Basic %s' % ac
        response._auth = 1
        if self.log_username:
            # Set the authorization header in the medusa http request
            # so that the username can be logged to the Z2.log
            try:
                # Put the full-arm latex glove on now...
                medusa_headers = response.stdout._request._header_cache
            except AttributeError:
                pass
            else:
                medusa_headers['authorization'] = request._auth

    security.declarePrivate('modifyRequest')
    def modifyRequest(self, req, resp):
        """Copies cookie-supplied credentials to the basic auth fields.

        Returns a flag indicating what the user is trying to do with
        cookies: ATTEMPT_NONE, ATTEMPT_LOGIN, or ATTEMPT_RESUME.  If
        cookie login is disabled for this request, raises
        CookieCrumblerDisabled.
        """
        if (req.__class__ is not HTTPRequest
            or not req['REQUEST_METHOD'] in ('HEAD', 'GET', 'PUT', 'POST')
            or req.environ.has_key('WEBDAV_SOURCE_PORT')):
            raise CookieCrumblerDisabled

        # attempt may contain information about an earlier attempt to
        # authenticate using a higher-up cookie crumbler within the
        # same request.
        attempt = getattr(req, '_cookie_auth', ATTEMPT_NONE)

        if attempt == ATTEMPT_NONE:
            if req._auth:
                # An auth header was provided and no cookie crumbler
                # created it.  The user must be using basic auth.
                raise CookieCrumblerDisabled

            if req.has_key(self.pw_cookie) and req.has_key(self.name_cookie):
                # Attempt to log in and set cookies.
                attempt = ATTEMPT_LOGIN
                name = req[self.name_cookie]
                pw = req[self.pw_cookie]
                ac = encodestring('%s:%s' % (name, pw)).rstrip()
                self._setAuthHeader(ac, req, resp)
                if req.get(self.persist_cookie, 0):
                    # Persist the user name (but not the pw or session)
                    expires = (DateTime() + 365).toZone('GMT').rfc822()
                    resp.setCookie(self.name_cookie, name,
                                   path=self.getCookiePath(),
                                   expires=expires)
                else:
                    # Expire the user name
                    resp.expireCookie(self.name_cookie,
                                      path=self.getCookiePath())
                method = self.getCookieMethod( 'setAuthCookie'
                                             , self.defaultSetAuthCookie )
                method( resp, self.auth_cookie, quote( ac ) )
                self.delRequestVar(req, self.name_cookie)
                self.delRequestVar(req, self.pw_cookie)

            elif req.has_key(self.auth_cookie):
                # Attempt to resume a session if the cookie is valid.
                # Copy __ac to the auth header.
                ac = unquote(req[self.auth_cookie])
                if ac and ac != 'deleted':
                    try:
                        decodestring(ac)
                    except:
                        # Not a valid auth header.
                        pass
                    else:
                        attempt = ATTEMPT_RESUME
                        self._setAuthHeader(ac, req, resp)
                        self.delRequestVar(req, self.auth_cookie)
                        method = self.getCookieMethod(
                            'twiddleAuthCookie', None)
                        if method is not None:
                            method(resp, self.auth_cookie, quote(ac))

        req._cookie_auth = attempt
        return attempt


    def __call__(self, container, req):
        '''The __before_publishing_traverse__ hook.'''
        resp = req['RESPONSE']
        try:
            attempt = self.modifyRequest(req, resp)
        except CookieCrumblerDisabled:
            return
        if req.get('disable_cookie_login__', 0):
            return

        if (self.unauth_page or
            attempt == ATTEMPT_LOGIN or attempt == ATTEMPT_NONE):
            # Modify the "unauthorized" response.
            req._hold(ResponseCleanup(resp))
            resp.unauthorized = self.unauthorized
            resp._unauthorized = self._unauthorized
        if attempt != ATTEMPT_NONE:
            # Trying to log in or resume a session
            if self.cache_header_value:
                # we don't want caches to cache the resulting page
                resp.setHeader('Cache-Control', self.cache_header_value)
                # demystify this in the response.
                resp.setHeader('X-Cache-Control-Hdr-Modified-By',
                               'CookieCrumbler')
            phys_path = self.getPhysicalPath()
            if self.logout_page:
                # Cookies are in use.
                page = getattr(container, self.logout_page, None)
                if page is not None:
                    # Provide a logout page.
                    req._logout_path = phys_path + ('logout',)
            req._credentials_changed_path = (
                phys_path + ('credentialsChanged',))

    security.declarePublic('credentialsChanged')
    def credentialsChanged(self, user, name, pw):
        # XXX: this method violates the rules for tools/utilities:
        # it depends on self.REQUEST
        ac = encodestring('%s:%s' % (name, pw)).rstrip()
        method = self.getCookieMethod( 'setAuthCookie'
                                       , self.defaultSetAuthCookie )
        resp = self.REQUEST['RESPONSE']
        method( resp, self.auth_cookie, quote( ac ) )

    def _cleanupResponse(self):
        # XXX: this method violates the rules for tools/utilities:
        # it depends on self.REQUEST
        resp = self.REQUEST['RESPONSE']
        # No errors of any sort may propagate, and we don't care *what*
        # they are, even to log them.
        try: del resp.unauthorized
        except: pass
        try: del resp._unauthorized
        except: pass
        return resp

    security.declarePrivate('unauthorized')
    def unauthorized(self):
        resp = self._cleanupResponse()
        # If we set the auth cookie before, delete it now.
        if resp.cookies.has_key(self.auth_cookie):
            del resp.cookies[self.auth_cookie]
        # Redirect if desired.
        url = self.getUnauthorizedURL()
        if url is not None:
            raise Redirect, url
        # Fall through to the standard unauthorized() call.
        resp.unauthorized()

    def _unauthorized(self):
        resp = self._cleanupResponse()
        # If we set the auth cookie before, delete it now.
        if resp.cookies.has_key(self.auth_cookie):
            del resp.cookies[self.auth_cookie]
        # Redirect if desired.
        url = self.getUnauthorizedURL()
        if url is not None:
            resp.redirect(url, lock=1)
            # We don't need to raise an exception.
            return
        # Fall through to the standard _unauthorized() call.
        resp._unauthorized()

    security.declarePublic('getUnauthorizedURL')
    def getUnauthorizedURL(self):
        '''
        Redirects to the login page.
        '''
        # XXX: this method violates the rules for tools/utilities:
        # it depends on self.REQUEST
        req = self.REQUEST
        resp = req['RESPONSE']
        attempt = getattr(req, '_cookie_auth', ATTEMPT_NONE)
        if attempt == ATTEMPT_NONE:
            # An anonymous user was denied access to something.
            page_id = self.auto_login_page
            retry = ''
        elif attempt == ATTEMPT_LOGIN:
            # The login attempt failed.  Try again.
            page_id = self.auto_login_page
            retry = '1'
        else:
            # An authenticated user was denied access to something.
            page_id = self.unauth_page
            retry = ''
        if page_id:
            page = self.restrictedTraverse(page_id, None)
            if page is not None:
                came_from = req.get('came_from', None)
                if came_from is None:
                    came_from = req.get('VIRTUAL_URL', None)
                    if came_from is None:
                        came_from = '%s%s%s' % ( req['SERVER_URL'].strip(),
                                                 req['SCRIPT_NAME'].strip(),
                                                 req['PATH_INFO'].strip() )
                    query = req.get('QUERY_STRING')
                    if query:
                        # Include the query string in came_from
                        if not query.startswith('?'):
                            query = '?' + query
                        came_from = came_from + query
                url = '%s?came_from=%s&retry=%s&disable_cookie_login__=1' % (
                    page.absolute_url(), quote(came_from), retry)
                return url
        return None

    # backward compatible alias
    getLoginURL = getUnauthorizedURL

    security.declarePublic('logout')
    def logout(self):
        '''
        Logs out the user and redirects to the logout page.
        '''
        # XXX: this method violates the rules for tools/utilities:
        # it depends on self.REQUEST
        req = self.REQUEST
        resp = req['RESPONSE']
        method = self.getCookieMethod( 'expireAuthCookie'
                                     , self.defaultExpireAuthCookie )
        method( resp, cookie_name=self.auth_cookie )
        if self.logout_page:
            page = self.restrictedTraverse(self.logout_page, None)
            if page is not None:
                resp.redirect('%s?disable_cookie_login__=1'
                              % page.absolute_url())
                return ''
        # We should not normally get here.
        return 'Logged out.'

    security.declarePublic('propertyLabel')
    def propertyLabel(self, id):
        """Return a label for the given property id
        """
        for p in self._properties:
            if p['id'] == id:
                return p.get('label', id)
        return id

Globals.InitializeClass(CookieCrumbler)


def handleCookieCrumblerEvent(ob, event):
    """ Event subscriber for (un)registering a CC as a before traverse hook.
    """
    if not ICookieCrumbler.providedBy(ob):
        return

    if IObjectMovedEvent.providedBy(event):
        if event.newParent is not None:
            # register before traverse hook
            handle = ob.meta_type + '/' + ob.getId()
            nc = BeforeTraverse.NameCaller(ob.getId())
            BeforeTraverse.registerBeforeTraverse(event.newParent, nc, handle)
    elif IObjectWillBeMovedEvent.providedBy(event):
        if event.oldParent is not None:
            # unregister before traverse hook
            handle = ob.meta_type + '/' + ob.getId()
            BeforeTraverse.unregisterBeforeTraverse(event.oldParent, handle)

class ResponseCleanup:
    def __init__(self, resp):
        self.resp = resp

    def __del__(self):
        # Free the references.
        #
        # No errors of any sort may propagate, and we don't care *what*
        # they are, even to log them.
        try: del self.resp.unauthorized
        except: pass
        try: del self.resp._unauthorized
        except: pass
        try: del self.resp
        except: pass


manage_addCCForm = HTMLFile('dtml/addCC', globals())
manage_addCCForm.__name__ = 'addCC'

def _create_forms(ob):
    ''' Create default forms inside ob '''
    import os
    from OFS.DTMLMethod import addDTMLMethod
    dtmldir = os.path.join(os.path.dirname(__file__), 'dtml')
    for fn in ('index_html', 'logged_in', 'logged_out', 'login_form',
                'standard_login_footer', 'standard_login_header'):
        filename = os.path.join(dtmldir, fn + '.dtml')
        f = open(filename, 'rt')
        try: data = f.read()
        finally: f.close()
        addDTMLMethod(ob, fn, file=data)

def manage_addCC(dispatcher, id, create_forms=0, REQUEST=None):
    ' '
    ob = CookieCrumbler()
    ob.id = id
    dispatcher._setObject(ob.getId(), ob)
    ob = getattr(dispatcher.this(), ob.getId())
    if create_forms:
        _create_forms(ob)
    if REQUEST is not None:
        return dispatcher.manage_main(dispatcher, REQUEST)
