简体   繁体   中英

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 (bro4@case.edu)
 */

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?

PS 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');
        }
    }
}

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

When it's a GET request, it's pretty straightforward: On missing auth, save the request url in the session and redirect to login. After login, if there is a saved request url, send him there. For successful auth, but failed access privileges, send him right away to access-denied.

The sticky point is a POST request. Saving only the request url misses the data in the post. Even if you also save those params and the request type ('POST') in the session, is it possible for a post-login redirect to operate as a POST request? To me, that's the essence of the question.

One solution could be to do a post-login redirect to a transition page containing a form (with method="post" ) with all the original post data stashed away in hidden fields. Then, run some on-load javascript that hides the submit button and submits the form. In the absence of javascript, it degrades - relatively - gracefully: the user simply has to click the submit button.

Would that work?

This isn't a coded answer, but something which may help. I faced a similar problem when I implemented using Zend_ACL and Zend_Auth using a MySQL back-end. I came across a few times where I needed a user who didn't normally have access to a controller action to be granted it for a situation. Zend have thought of this, and so you can use Assertions to allow access where access would normally have been denied.

The problem I then faced was, I set a login timeout limit - so if the user left the computer and someone came along they couldn't do anything as the system would be logged out.

The problem being if they had filled out a form (there are some huge forms) and taken more than the allowed login time, when they submitted the form, they would land at the login page - very annoying if it had taken 30 mins to complete the form. Hence I needed the user to be redirected to the action which accepted the original form, but somehow post the original data as a POST request. My original question on this.

So how I have solved this, and maybe not the best solution but it works. Inspiration came from here

  • When the user arrives at the login page check if its a POST
  • Serialize the POST data and place it in a session with the requested URL .
  • Once successfully auth'd FORWARD the request to the URL saved in the session.
  • Now in the correct place, check for any serialized data and un-serialize it setting $_POST to the data.

    The reason this works for me is, when the user submits the login form, you check the users details, perform the actions you need to log them in, then forward them instead of redirecting them, this way the request is still a POST - so when you change the $_POST data to the original which you have un-serialized the page will work as normal, even checking $this->getRequest()->isPost() .
    Where I do find a problem is, and I haven't tried tackling it yet, if the user presses the back button, they would get to the login form, which they aren't allowed to as they are now logged in so get a message advising they aren't allowed there, it would be better if they got back to the original form - so it ices over the login.

    If you think this will help, I can try and cut up some of the code I use to give an idea exactly how I do this?

  • I have posted what I am using in my project to protect one controller based on whether the user is logged. The following code can be used to check for special permissions also while checking for login. If the user is not having appropriate privileges, redirect to error controller or display error views.

    This code has example for what is asked in question - Keeping the current URL in param when redirecting to another controller and redirecting back to the same URL on success.

    class ProtectedController extends Zend_Controller_Action {
    
        public function indexAction() {
    
        if (Zend_Auth::getInstance()->hasIdentity()) {
                   //Show the content
           } else {
                    $this->_redirect('user/login/?success=' . $this->getRequest()->getRequestUri());
                }
            }
    
        }
    

    And the UserController

    class UserController extends Zend_Controller_Action {
    
        public function loginAction() {
                $returnURL = $this->_getParam('success', '/');
                if (checkAuthenticity()) {
                    $this->_redirect($returnURL);
                }
          }
    }
    

    The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

     
    粤ICP备18138465号  © 2020-2024 STACKOOM.COM