
# python
from xml.dom import minidom as minidom
from urllib import quote
from logging import getLogger

# zope
from AccessControl import ClassSecurityInfo
from Acquisition import aq_base
from Globals import InitializeClass
from OFS.Cache import Cacheable
from zope.interface import implements
from Products.PageTemplates.PageTemplateFile import PageTemplateFile
import transaction

# cmf
from Products.PluggableAuthService.plugins.BasePlugin import BasePlugin
from Products.PluggableAuthService.utils import classImplements
from Products.PluggableAuthService.interfaces.plugins import \
        IExtractionPlugin, IChallengePlugin, IAuthenticationPlugin, \
        ICredentialsResetPlugin, ICredentialsUpdatePlugin

from anz.casclient.interfaces import IAnzCASClient
from anz.casclient.assertion import Assertion
from anz.casclient.principal import Principal
from anz.casclient.proxygrantingticketstorage import ProxyGrantingTicketStorage
from anz.casclient.sessionmappingstorage import SessionMappingStorage
from anz.casclient.validationspecification import Cas10TicketValidator, \
     Cas20ServiceTicketValidator, Cas20ProxyTicketValidator
from anz.casclient.exceptions import BaseException
from anz.casclient.utils import retrieveResponseFromServer

LOG = getLogger( 'anz.casclient' )

addAnzCASClientForm = PageTemplateFile(
    'www/add_anzcasclient_form.pt', globals() )

def manage_addAnzCASClient( self, id, title=None, REQUEST=None ):
    ''' Add an instance of anz cas client to PAS. '''
    obj = AnzCASClient( id, title )
    self._setObject( obj.getId(), obj )
    
    if REQUEST is not None:
        REQUEST['RESPONSE'].redirect(
            '%s/manage_workspace'
            '?manage_tabs_message='
            'AnzCentralAuthService+added.'
            % self.absolute_url()
            )

class AnzCASClient( BasePlugin, Cacheable ):
    ''' Anz CAS client, Implement as a PAS plugin. '''
    
    implements( IAnzCASClient )
    
    meta_type = 'Anz CAS Client'
    
    # Session variable use to save assertion
    CAS_ASSERTION = '__cas_assertion'
    
    # The start of the CAS server URL
    casServerUrlPrefix = ''
    
    # An identify of current service.
    # CAS will redirects to here after login.
    # Set this explicitly but not determine it automatically from request makes
    # us get more security assurance.
    # https://wiki.jasig.org/display/CASC/CASFilter
    serviceUrl = ''
    
    # Whether to store the Assertion in session or not.
    # If sessions are not used, proxy granting ticket will be required for
    # each request. Default set to True.
    useSession = True
    
    # If set to True, CAS will ask user for credentials again to authenticate,
    # this may be used for high-security applications. Default set to False.
    renew = False
    
    # If set to True, CAS will not ask the user for credentials. If the
    # user has a pre-existing single sign-on session with CAS, or if a single
    # sign-on session can be established through non-interactive means
    # (i.e. trust authentication), CAS MAY redirect the client to the URL
    # specified by the "service" parameter, appending a valid service ticket.
    # (CAS also MAY interpose an advisory page informing the client that a CAS
    # authentication has taken place.) If the client does not have a single
    # sign-on session with CAS, and a non-interactive authentication cannot be
    # established, CAS MUST redirect the client to the URL specified by the
    # "service" parameter with no "ticket" parameter appended to the URL. If
    # the "service" parameter is not specified and "gateway" is set, the
    # behavior of CAS is undefined. It is RECOMMENDED that in this case, CAS
    # request credentials as if neither parameter was specified.
    # This parameter is not compatible with the "renew" parameter. Behavior
    # is undefined if both are set to True.
    # See details here: http://www.jasig.org/cas/client-integration/gateway
    gateway = False
    
    # Use which CAS protocol to validate ticket
    # one of ['CAS 1.0','CAS 2.0']
    ticketValidationSpecification = 'CAS 1.0'
    ticketValidationSpecification_values = ['CAS 1.0','CAS 2.0']
    
    # The start of the proxy callback url.
    # You should set it point to an instance of this class with protocol 'https'. 
    # The result url will be '{proxyCallbackUrlPrefix}/proxyCallback'.
    # If set, means this service will be used as a proxier to access
    # back-end service on behalf of a particular user.
    proxyCallbackUrlPrefix = ''
    
    # If you provide either the acceptAnyProxy or the allowedProxyChains
    # parameters, a Cas20ProxyTicketValidator will be constructed. Otherwise
    # a Cas20ServiceTicketValidator will be constructed that does not accept
    # proxy tickets.
    
    # Whether any proxy is OK
    acceptAnyProxy = False
    
    # Allowed proxy chains.
    # Each acceptable proxy chain should include a space-separated list of URLs.
    # These URLs are proxier's proxyCallbackUrl.
    allowedProxyChains = []
    
    security = ClassSecurityInfo()
    
    _properties = (
        {
            'id': 'serviceUrl',
            'lable': 'Service URL',
            'type': 'string',
            'mode': 'w'
            },
        {
            'id': 'casServerUrlPrefix',
            'lable': 'CAS Server URL Prefix',
            'type': 'string',
            'mode': 'w'
            },
        {
            'id': 'useSession',
            'lable': 'Use Session',
            'type': 'boolean',
            'mode': 'w'
            },
        {
            'id': 'renew',
            'lable': 'Renew',
            'type': 'boolean',
            'mode': 'w'
            },
        {
            'id': 'gateway',
            'lable': 'Gateway',
            'type': 'boolean',
            'mode': 'w'
            },
        {
            'id': 'ticketValidationSpecification',
            'lable': 'Ticket Validation Specification',
            'select_variable': 'ticketValidationSpecification_values',
            'type': 'selection',
            'mode': 'w'
            },
        {
            'id': 'proxyCallbackUrlPrefix',
            'lable': 'Proxy Callback URL Prefix',
            'type': 'string',
            'mode': 'w'
            },
        {
            'id': 'acceptAnyProxy',
            'lable': 'Accept Any Proxy',
            'type': 'boolean',
            'mode': 'w'
            },
        {
            'id': 'allowedProxyChains',
            'label': 'Allowed Proxy Chains',
            'type': 'lines',
            'mode': 'w'
            },
        )
    
    def __init__( self, id, title ):
        self._id = self.id = id
        self.title = title
        self._pgtStorage = ProxyGrantingTicketStorage()
        self._sessionStorage = SessionMappingStorage()
    
    security.declarePrivate( 'extractCredentials' )
    def extractCredentials( self, request ):
        ''' Extract credentials from session or 'request'. '''
        creds = {}
        
        # Do logout if logout request found
        logoutRequest = request.form.get( 'logoutRequest', '' )
        if logoutRequest:
            self.logoutCallback()
            return creds
        
        sdm = getattr( self, 'session_data_manager', None )
        assert sdm is not None, 'No session data manager found!'
        
        session = sdm.getSessionData( create=0 )
        assertion = self.getAssertion( session )
        if not assertion:
            # Not already authenticated. Is there a ticket in the URL?
            ticket = request.form.get( 'ticket', None )
            if not ticket:
                return None # No CAS authentification
            
            service = self.getService()
            assertion = self.validateServiceTicket( service, ticket )
            
            # Save current user's session to be used by 'Single Sign Out'
            if not session:
                session = sdm.getSessionData( create=1 )
            
            # Get session token as id, it is more reliable
            sessionId = session.getContainerKey()
            self._sessionStorage.addSession( ticket, sessionId )
            
            # Save assertion into session
            if self.useSession:
                # During ticket validation process, the server with this client
                # installed will be callback several times by CAS, this will
                # makes the session data object we get before stale, so here
                # we get it again.
                session = sdm.getSessionData()
                session.set( self.CAS_ASSERTION, assertion )
        
        creds['login'] = assertion.getPrincipal().getId()
        return creds
    
    security.declarePrivate( 'authenticateCredentials' )
    def authenticateCredentials( self, credentials ):
        if credentials['extractor'] != self.getId():
            return None
        
        login = credentials['login']
        return ( login, login )
    
    security.declarePrivate( 'challenge' )
    def challenge( self, request, response, **kw ):
        ''' Challenge the user for credentials. '''
        # Remove current credentials.
        session = request.SESSION
        session[self.CAS_ASSERTION] = None
        
        # Redirect to CAS login URL.
        if self.casServerUrlPrefix:
            url = self.getLoginURL() + '?service=' + self.getService()
            if self.renew:
                url += '&renew=true'
            if self.gateway:
                url += '&gateway=true'
            
            response.redirect( url, lock=1 )
            return 1
        
        # Fall through to the standard unauthorized() call.
        return 0
    
    security.declarePrivate( 'resetCredentials' )
    def resetCredentials( self, request, response ):
        ''' Clears credentials and redirects to CAS logout page. '''
        session = request.SESSION
        session.clear()
        
        if self.casServerUrlPrefix:
            return response.redirect( self.getLogoutURL(), lock=1 )
    
    security.declarePublic( 'proxyCallback' )
    def proxyCallback( self, pgtId=None, pgtIou=None ):
        ''' See interfaces.IAnzCASClient. '''
        ret = 'success'
        if pgtId and pgtIou:
            self._pgtStorage.add( pgtIou, pgtId )
            ret = '<?xml version=\"1.0\"?>'
            ret += '<casClient:proxySuccess xmlns:casClient="http://www.yale.edu/tp/casClient" />'
        
        return ret
    
    security.declarePublic( 'logoutCallback' )
    def logoutCallback( self ):
        ''' See interfaces.IAnzCASClient. '''
        msg = 'No session id found.'
        dom = minidom.parseString( self.REQUEST.form.get('logoutRequest','') )
        SAMLP_NS = 'urn:oasis:names:tc:SAML:2.0:protocol'
        elements = dom.getElementsByTagNameNS( SAMLP_NS,
                                               'SessionIndex' )
        if elements:
            mappingId = elements[0].firstChild.data
            sessionId = self._sessionStorage.getSessionId( mappingId )
            self._sessionStorage.removeByMappingId( mappingId )
            
            sdm = getattr( self, 'session_data_manager', None )
            assert sdm is not None, 'No session data manager found!'
            
            session = sdm.getSessionDataByKey( sessionId )
            if session:
                session.clear()
                
                # We must commit here to make sure the session will be cleared.
                transaction.commit()
            
            msg = 'Logout seccess.'
        
        return msg
    
    security.declarePublic( 'validateProxyTicket' )
    def validateProxyTicket( self, ticket ):
        ''' See interfaces.IAnzCASClient. '''
        validator = Cas20ProxyTicketValidator(
            self.casServerUrlPrefix,
            self._pgtStorage,
            acceptAnyProxy=self.acceptAnyProxy,
            allowedProxyChains=self.allowedProxyChains,
            renew=self.renew )
        
        try:
            assertion = validator.validate( ticket, self.getService() )
        except BaseException, e:
            LOG.warning( e )
            return False, None
        except Exception, e:
            LOG.warning( e )
            return False, None
        else:
            return True, assertion
    
    def getLoginURL( self ):
        ''' See interfaces.IAnzCASClient. '''
        return self.casServerUrlPrefix + '/login'
    
    def getLogoutURL( self ):
        ''' See interfaces.IAnzCASClient. '''
        return self.casServerUrlPrefix + '/logout?url=%s' % self.getService()
    
    def getService( self ):
        ''' See interfaces.IAnzCASClient. '''
        serviceUrl = ''
        
        if self.serviceUrl:
            # use explicitly setted service url
            serviceUrl = self.serviceUrl
        else:
            # extract service URL from REQUEST and remove the ticket parameters.
            request = self.REQUEST
            
            # get query string but strip ticket parameter
            query_string = request.get( 'QUERY_STRING', '' )
            parts = [ p for p in query_string.split('&') if \
                      (p and p.find( 'ticket=' )!=0) ]
            
            url = request.get( 'ACTUAL_URL', request['URL'] )
            
            serviceUrl = '%s?%s' % ( url, '&'.join(parts) )
        
        return quote( serviceUrl )
    
    def getProxyCallbackUrl( self ):
        ''' See interfaces.IAnzCASClient. '''
        return self.proxyCallbackUrlPrefix and \
               '%s/proxyCallback' % self.proxyCallbackUrlPrefix or ''
    
    def getAssertion( self, session ):
        ''' See interfaces.IAnzCASClient. '''
        assertion = None
        
        if self.useSession and session:
            sessionValue = session.get( self.CAS_ASSERTION )
            if isinstance( sessionValue, Assertion ):
                assertion = sessionValue
        
        return assertion
    
    def validateServiceTicket( self, service, ticket ):
        ''' See interfaces.IAnzCASClient. '''
        if self.ticketValidationSpecification == 'CAS 1.0':
            validator = Cas10TicketValidator(
                self.casServerUrlPrefix, self.renew )
        else:
            if self.acceptAnyProxy or self.allowedProxyChains:
                validator = Cas20ProxyTicketValidator(
                    self.casServerUrlPrefix,
                    self._pgtStorage,
                    acceptAnyProxy=self.acceptAnyProxy,
                    allowedProxyChains=self.allowedProxyChains,
                    renew=self.renew )
            else:
                validator = Cas20ServiceTicketValidator(
                    self.casServerUrlPrefix, self._pgtStorage, self.renew )
        
        return validator.validate(
            ticket, service, self.getProxyCallbackUrl() )

classImplements( AnzCASClient, 
                 IExtractionPlugin, 
                 IChallengePlugin, 
                 ICredentialsResetPlugin, 
                 IAuthenticationPlugin )

InitializeClass( AnzCASClient )
