Search code examples
phpzend-frameworkzend-application

What's the best way to handle something like a login page on top of Zend Framework? (And why does my implementation explode?)


EDIT: Sorry for the large amount of code here; I'm not sure exactly what's going on so I included more to be safe.

I've currently got a login page which farms out to a central authentication service. I'd like to do a permissions check on the user. If the user is not logged in, I'd like to redirect them to the login page, and have the login page redirect them to do whatever action it was they were originally doing, running the access check again. If they don't have permission, I want to redirect them to an access denied page.

Here's what I've done so far:

Added this line to my application.ini:

resources.frontController.actionHelperPaths.Cas_Controller_Action_Helper = APPLICATION_PATH "/controllers/helpers"

Created the file $/application/controllers/helpers/PermissionRequire.php:

<?php
/**
 * This class is used in order to require that a user have a given privilege before continuing.
 *
 * @copyright 2011 Case Western Reserve University, College of Arts and Sciences
 * @author Billy O'Neal III ([email protected])
 */

class Cas_Controller_Action_Helper_PermissionRequire extends Zend_Controller_Action_Helper_Abstract
{
    /**
     * Cleans up the supplied list of privileges. Strings are turned into the real privilege objects (Based on name),
     * privilege objects are left alone.
     *
     * @static
     * @param array|Privilege|string $privileges
     * @return array
     */
    private static function CleanPrivileges($privileges)
    {
        if (!is_array($privileges))
        {
            $privileges =
                    array
                    (
                        $privileges
                    );
        }
        $strings = array_filter($privileges, 'is_string');
        $objects = array_filter($privileges, function($o)
        {
            return $o instanceof Privilege;
        });
        $databaseObjects = PrivilegeQuery::create()->filterByName($strings)->find();
        return array_combine($objects, $databaseObjects);
    }

    /**
     * Generic implementation for checking whether a user can visit a page.
     * @param Privilege|string|array $privileges Any number of privileges which are required to access the given
     *                                           page. If ANY privilege is held by the user, access is allowed.
     * @param AccessControlList The acl which is being checked. Defaults to the application.
     */
    public function direct($privileges, $acl = null)
    {
        $privileges = self::CleanPrivileges($privileges);
        if ($acl === null)
        {
            $acl = AccessControlListQuery::getApplication();
        }
        $redirector = $this->getActionController()->getHelper('redirector');
        /** @var Zend_Controller_Action_Helper_Redirector $redirector */
        $redirector->setCode(307);
        if (Cas_Model_CurrentUser::IsLoggedIn() && (!Cas_Model_CurrentUser::AccessCheck($acl, $privileges)))
        {
            $redirector->gotoSimple('accessdenied', 'login');
        }
        else
        {
            $returnData = new Zend_Session_Namespace('Login');
            $returnData->params = $this->getRequest()->getParams();
            $redirector->setGotoSimple('login', 'login');
            $redirector->redirectAndExit();
        }
    }
}

And here's the LoginController:

<?php

/**
 * LoginController - Controls login access for users
 */

require_once 'CAS.php';

class LoginController extends Zend_Controller_Action
{
    /**
     * Logs in to the system, and redirects to the calling action.
     *
     * @return void
     */
    public function loginAction()
    {
        //Authenticate with Login.Case.Edu.
        phpCAS::client(CAS_VERSION_2_0, 'login.case.edu', 443, '/cas', false);
        phpCAS::setNoCasServerValidation();
        phpCAS::forceAuthentication();

        $user = CaseIdUser::createFromLdap(phpCAS::getUser());
        Cas_Model_CurrentUser::SetCurrentUser($user->getSecurityIdentifier());

        $returnData = new Zend_Session_Namespace('Login');
        /** @var array $params */
        $redirector = $this->_helper->redirector;
        /** @var Zend_Controller_Action_Helper_Redirector $redirector */
        $redirector->setGotoRoute($returnData->params, 'default', true);
        $returnData->unsetAll();
        $redirector->redirectAndExit();
    }

    /**
     * Logs the user out of the system, and redirects them to the index page.
     *
     * @return void
     */
    public function logoutAction()
    {
        Cas_Model_CurrentUser::Logout();
        $this->_helper->redirector->gotoRoute('index','index', 'default', true);
    }

    /**
     * Returns an access denied view.
     *
     * @return void
     */
    public function accessdeniedAction()
    {
        //Just display the view and punt.
    }
}

The problem is that in the login controller when it's preparing the URL to redirect the user to, it seems "params" is null. Also, this won't work when there's POST data to the controller calling $this->_helper->permissionRequire(SOME PRIVILEGE).

Is there a better way of storing the entire state of a request, and coughing up a redirect which exactly matches that request?

P.S. Oh, and here's an example controller using that helper:

<?php

/**
 * Serves as the index page; does nothing but display views.
 */

class IndexController extends Zend_Controller_Action
{
    public function indexAction()
    {
        $renderer = $this->getHelper('ViewRenderer');
        /** @var $renderer Zend_Controller_Action_Helper_ViewRenderer */
        if (Cas_Model_CurrentUser::IsLoggedIn())
        {
            $this->_helper->permissionRequire(Cas_Model_Privilege::GetLogin());
            $this->render('loggedin');
        }
        else
        {
            $this->render('loggedout');
        }
    }
}

Solution

  • Since you are so keen on saving the POST state of the request, and because I've been playing around with this same idea myself to for a long time, how about something like the following. It's still untested though, so I'ld love to hear the outcome of whether setting the saved request like this actually works as expected. (To lazy to test this at the moment, sorry).

    In your config ini:

    resources.frontController.plugins[] = "Cas_Controller_Plugin_Authenticator"
    

    Here's the plugin:

    class Cas_Controller_Plugin_Authenticator
        extends Zend_Controller_Plugin_Abstract
    {
        public function routeStartup( Zend_Controller_Request_Abstract $request )
        {
            if( Zend_Auth::getInstance()->hasIdentity() )
            {
                if( null !== $request->getParam( 'from-login', null ) && Zend_Session::namespaceIsset( 'referrer' ) )
                {
                    $referrer = new Zend_Session_Namespace( 'referrer' );
                    if( isset( $referrer->request ) && $referrer->request instanceof Zend_Controller_Request_Abstract )
                    {
                        Zend_Controller_Front::getInstance()->setRequest( $referrer->request );
                    }
                    Zend_Session::namespaceUnset( 'referrer' );
                }
            }
            else
            {
                $referrer = new Zend_Session_Namespace( 'referrer' );
                $referrer->request = $this->getRequest();
                return $this->_redirector->gotoRoute(
                    array(
                        'module' => 'default',
                        'controller' => 'user',
                        'action' => 'login'
                    ),
                    'default',
                    true
                );
            }
        }
    }
    

    The plugin should check on routeStartup whether the user is authenticated;

    • If the user IS NOT: it saves the current request object in the session and redirects to the UserController::loginAction(). (see below)
    • If the user IS: it retrieves the saved request object from the session (if available, AND if user has just logged in) and replaces the current request object in the frontController (which proxies to the router I should think).

    All in all, if you want some more flexibility for determining what module/controller/action params need authentication and authorization (which I imagine you want) you probably want to move some of the checking to another hook than routeStartup: namely routeShutdown, dispatchLoopStartup or preDispatch. Because by then the action params should be known. As an extra security measure you may also want to compare the action params (module/controller/action) of the original request and the replacing request to determine if your dealing with the correct saved request.

    Furthermore, you may need to set $request->setDispatched( false ) on the new request object, in some or all of the hooks. Not entirely sure though: see the docs.

    And here is an example login controller:

    class UserController
        extends Zend_Controller_Action
    {
        public function loginAction()
        {
            $request = $this->getRequest();
            if( $request->isPost() )
            {
                if( someAuthenticationProcessIsValid() )
                {
                    if( Zend_Session::namespaceIsset( 'referrer' ) )
                    {
                        $referrer = new Zend_Session_Namespace( 'referrer' );
                        if( isset( $referrer->request ) && $referrer->request instanceof Zend_Controller_Request_Abstract )
                        {
                            return $this->_redirector->gotoRoute(
                                array(
                                    'module' => $referrer->request->getModuleName(),
                                    'controller' => $referrer->request->getControllerName(),
                                    'action' => $referrer->request->getActionName(),
                                    'from-login' => '1'
                                ),
                                'default',
                                true
                            );
                        }   
                    }
    
                    // no referrer found, redirect to default page
                    return $this->_redirector->gotoRoute(
                        array(
                            'module' => 'default',
                            'controller' => 'index',
                            'action' => 'index'
                        ),
                        'default',
                        true
                    );
                }
            }
    
            // GET request or authentication failed, show login form again
        }
    }
    

    For security reasons, you might want to set a session variable with an expiration hop of 1 in stead of the 'from-login' querystring variable though.

    Finally, having said all this; you might want to thoroughly think about whether you really want this behaviour in the first place. POST requests, as you of course know, generally inhibit sensitive state changing operations (creating, deleting, etc.). I'm not sure users generally expect this behaviour right after logging in (after their session had just expired). Also, you might want to think about possible scenarios where this can lead to unexpected behaviour for the application itself. I can't think of any specifics right now, but if I gave it more thought, I'm sure I could come up with some.

    HTH

    EDIT
    Forgot to add the correct redirect actions after the login process