Practical Zend_ACL + Zend_Auth implementation and best practices

Context:

My questions pertain to a forum I'm developing pretty much exactly like SO, where there are:

  1. guests who have access to view threads but can't reply or vote
  2. members who, with enough rep, can edit/vote others threads, and by default they can reply and have the same privileges as guests
  3. admins who can pretty much do anything

I would want this ACL to be applied site-wide, and by default deny all resources.

I read the basics of using Zend_Acl - in that you basically create roles ( guest, member, admin ) and either deny or allow resources ( controllers, methods ) to those roles. The documentation isn't very specific on how you should actually implement the acl code in your application, so I went looking on SO..

Came across a pretty useful stackoverflow answer from marek which sheds some light on the issue, however due to my unfamiliarity I still can't fully grok how to properly implement this with best practices in mind.

The poster has a static file configAcl.php in the application root which initializes the acl object, adds roles, creates a resource out of every controller, gives admin access to everything, gives normal access to everything but the admin and stores the acl object in the registry for later use.

$acl = new Zend_Acl();

$roles  = array('admin', 'normal');

// Controller script names. You have to add all of them if credential check
// is global to your application.
$controllers = array('auth', 'index', 'news', 'admin');

foreach ($roles as $role) {
    $acl->addRole(new Zend_Acl_Role($role));
}
foreach ($controllers as $controller) {
    $acl->add(new Zend_Acl_Resource($controller));
}

// Here comes credential definiton for admin user.
$acl->allow('admin'); // Has access to everything.

// Here comes credential definition for normal user.
$acl->allow('normal'); // Has access to everything...
$acl->deny('normal', 'admin'); // ... except the admin controller.

// Finally I store whole ACL definition to registry for use
// in AuthPlugin plugin.
$registry = Zend_Registry::getInstance();
$registry->set('acl', $acl);

Question #1 - Should this code be in the bootstrap, or in a standalone file such as this? If so would it be better if it was inside say, the library directory?

The second part of it is a new class extending the Zend Controller Plugin Abstract class which allows it to be hooked into auth/login, the logic is basically if the login fails, it redirects.. otherwise it grabs the acl object from the registry, grabs the identity, and determines if the user is allowed to view this resource.

$identity = $auth->getIdentity();

$frontController->registerPlugin(new AuthPlugin());

Question #2 - How exactly would I code the auth plugin part that actually returns the identity of the user? I realize that he had some code below that generated a Auth adapter db table object which would query a database table's column by user id and credential ( hashed pass check ).. I'm confused on where this fits in with the getIdentity part.

Let's say my users table was composed of this data:

user_id    user_name    level
1          superadmin   3
2          john         2
3          example.com  1

Where level 3 = admin, 2 = member, 1 = guest.

Question #3 - where exactly is a good place to put the above auth code in? Inside of the login controller?

Question #4 - another poster replies with his article on how the acl logic should be done inside models, yet the specific method which he uses is not natively supported and requires a workaround, is this feasible? And is this really how it ideally should be done?


My implementation:

Question #1

class App_Model_Acl extends Zend_Acl
{   
    const ROLE_GUEST        = 'guest';
    const ROLE_USER         = 'user';
    const ROLE_PUBLISHER    = 'publisher';
    const ROLE_EDITOR       = 'editor';
    const ROLE_ADMIN        = 'admin';
    const ROLE_GOD          = 'god';

    protected static $_instance;

    /* Singleton pattern */
    protected function __construct()
    {
        $this->addRole(new Zend_Acl_Role(self::ROLE_GUEST));
        $this->addRole(new Zend_Acl_Role(self::ROLE_USER), self::ROLE_GUEST);
        $this->addRole(new Zend_Acl_Role(self::ROLE_PUBLISHER), self::ROLE_USER);
        $this->addRole(new Zend_Acl_Role(self::ROLE_EDITOR), self::ROLE_PUBLISHER);
        $this->addRole(new Zend_Acl_Role(self::ROLE_ADMIN), self::ROLE_EDITOR);

        //unique role for superadmin
        $this->addRole(new Zend_Acl_Role(self::ROLE_GOD));

        $this->allow(self::ROLE_GOD);

        /* Adding new resources */
        $this->add(new Zend_Acl_Resource('mvc:users'))
             ->add(new Zend_Acl_Resource('mvc:users.auth'), 'mvc:users')
             ->add(new Zend_Acl_Resource('mvc:users.list'), 'mvc:users');

        $this->allow(null, 'mvc:users', array('index', 'list'));
        $this->allow('guest', 'mvc:users.auth', array('index', 'login'));
        $this->allow('guest', 'mvc:users.list', array('index', 'list'));
        $this->deny(array('user'), 'mvc:users.auth', array('login'));


        /* Adding new resources */
        $moduleResource = new Zend_Acl_Resource('mvc:snippets');
        $this->add($moduleResource)
             ->add(new Zend_Acl_Resource('mvc:snippets.crud'), $moduleResource)
             ->add(new Zend_Acl_Resource('mvc:snippets.list'), $moduleResource);

        $this->allow(null, $moduleResource, array('index', 'list'));
        $this->allow('user', 'mvc:snippets.crud', array('create', 'update', 'delete', 'read', 'list'));
        $this->allow('guest', 'mvc:snippets.list', array('index', 'list'));

        return $this;
    }

    protected static $_user;

    public static function setUser(Users_Model_User $user = null)
    {
        if (null === $user) {
            throw new InvalidArgumentException('$user is null');
        }

        self::$_user = $user;
    }

    /**
     * 
     * @return App_Model_Acl
     */
    public static function getInstance()
    {
        if (null === self::$_instance) {
            self::$_instance = new self();
        }
        return self::$_instance;
    }

    public static function resetInstance()
    {
        self::$_instance = null;
        self::getInstance();
    }
}



class Smapp extends Bootstrap // class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
{
    /**
     * @var App_Model_User
     */
    protected static $_currentUser;

    public function __construct($application)
    {
        parent::__construct($application);
    }

    public static function setCurrentUser(Users_Model_User $user)
    {
        self::$_currentUser = $user;
    }

    /**
     * @return App_Model_User
     */
    public static function getCurrentUser()
    {
        if (null === self::$_currentUser) {
            self::setCurrentUser(Users_Service_User::getUserModel());
        }
        return self::$_currentUser;
    }

    /**
     * @return App_Model_User
     */
    public static function getCurrentUserId()
    {
        $user = self::getCurrentUser();
        return $user->getId();
    }

}

in class bootstrap

protected function _initUser()
{
    $auth = Zend_Auth::getInstance();
    if ($auth->hasIdentity()) {
        if ($user = Users_Service_User::findOneByOpenId($auth->getIdentity())) {
            $userLastAccess = strtotime($user->last_access);
            //update the date of the last login time in 5 minutes
            if ((time() - $userLastAccess) > 60*5) {
                $date = new Zend_Date();
                $user->last_access = $date->toString('YYYY-MM-dd HH:mm:ss');
                $user->save();
            }
            Smapp::setCurrentUser($user);
        }
    }
    return Smapp::getCurrentUser();
}

protected function _initAcl()
{
    $acl = App_Model_Acl::getInstance();
    Zend_View_Helper_Navigation_HelperAbstract::setDefaultAcl($acl);
    Zend_View_Helper_Navigation_HelperAbstract::setDefaultRole(Smapp::getCurrentUser()->role);
    Zend_Registry::set('Zend_Acl', $acl);
    return $acl;
}

and Front_Controller_Plugin

class App_Plugin_Auth extends Zend_Controller_Plugin_Abstract
{
    private $_identity;

    /**
     * the acl object
     *
     * @var zend_acl
     */
    private $_acl;

    /**
     * the page to direct to if there is a current
     * user but they do not have permission to access
     * the resource
     *
     * @var array
     */
    private $_noacl = array('module' => 'admin',
                             'controller' => 'error',
                             'action' => 'no-auth');

    /**
     * the page to direct to if there is not current user
     *
     * @var unknown_type
     */
    private $_noauth = array('module' => 'users',
                             'controller' => 'auth',
                             'action' => 'login');


    /**
     * validate the current user's request
     *
     * @param zend_controller_request $request
     */
    public function preDispatch(Zend_Controller_Request_Abstract $request)
    {
        $this->_identity = Smapp::getCurrentUser();
        $this->_acl = App_Model_Acl::getInstance();

        if (!empty($this->_identity)) {
            $role = $this->_identity->role;
        } else {
            $role = null;
        }

        $controller = $request->controller;
        $module = $request->module;
        $controller = $controller;
        $action = $request->action;

        //go from more specific to less specific
        $moduleLevel = 'mvc:'.$module;
        $controllerLevel = $moduleLevel . '.' . $controller;
        $privelege = $action;


        if ($this->_acl->has($controllerLevel)) {
            $resource = $controllerLevel;
        } else {
            $resource = $moduleLevel;
        }

        if ($module != 'default' && $controller != 'index') {
            if ($this->_acl->has($resource) && !$this->_acl->isAllowed($role, $resource, $privelege)) {
                if (!$this->_identity) {
                    $request->setModuleName($this->_noauth['module']);
                    $request->setControllerName($this->_noauth['controller']);
                    $request->setActionName($this->_noauth['action']);
                    //$request->setParam('authPage', 'login');
                } else {
                   $request->setModuleName($this->_noacl['module']);
                   $request->setControllerName($this->_noacl['controller']);
                   $request->setActionName($this->_noacl['action']);
                   //$request->setParam('authPage', 'noauth');
               }
               throw new Exception('Access denied. ' . $resource . '::' . $role);
            }
        }
    }
}

and finnaly - Auth_Controller` :)

class Users_AuthController extends Smapp_Controller_Action 
{   
    //sesssion
    protected $_storage;

    public function getStorage()
    {
        if (null === $this->_storage) {
            $this->_storage = new Zend_Session_Namespace(__CLASS__);
        }
        return $this->_storage;
    }

    public function indexAction()
    {
        return $this->_forward('login');
    }

    public function loginAction()
    {   
        $openId = null;
        if ($this->getRequest()->isPost() and $openId = ($this->_getParam('openid_identifier', false))) {
            //do nothing
        } elseif (!isset($_GET['openid_mode'])) {
            return; 
        }

        //$userService = $this->loadService('User');

        $userService = new Users_Service_User();

        $result = $userService->authenticate($openId, $this->getResponse());

        if ($result->isValid()) {
            $identity = $result->getIdentity();
            if (!$identity['Profile']['display_name']) {
                return $this->_helper->redirector->gotoSimpleAndExit('update', 'profile');
            }
            $this->_redirect('/');
        } else {
            $this->view->errorMessages = $result->getMessages();
        }
    }

    public function logoutAction()
    {
        $auth = Zend_Auth::getInstance();
        $auth->clearIdentity();
        //Zend_Session::destroy();
        $this->_redirect('/');
    }
}

Question #2

keep it inside Zend_Auth.

after succesfull auth - write identity in storage. $auth->getStorage()->write($result->getIdentity());

the identity - is simply user_id

DB design

CREATE TABLE `user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `open_id` varchar(255) NOT NULL,
  `role` varchar(20) NOT NULL,
  `last_access` datetime NOT NULL,
  `created_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `open_id` (`open_id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8

CREATE TABLE `user_profile` (
  `user_id` bigint(20) NOT NULL,
  `display_name` varchar(100) DEFAULT NULL,
  `email` varchar(100) DEFAULT NULL,
  `real_name` varchar(100) DEFAULT NULL,
  `website_url` varchar(255) DEFAULT NULL,
  `location` varchar(100) DEFAULT NULL,
  `birthday` date DEFAULT NULL,
  `about_me` text,
  `view_count` int(11) NOT NULL DEFAULT '0',
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

some sugar

/**
 * SM's code library
 * 
 * @category    
 * @package     
 * @subpackage  
 * @copyright   Copyright (c) 2009 Pavel V Egorov
 * @author      Pavel V Egorov
 * @link        http://epavel.ru/
 * @since       08.09.2009
 */


class Smapp_View_Helper_IsAllowed extends Zend_View_Helper_Abstract
{
    protected $_acl;
    protected $_user;

    public function isAllowed($resource = null, $privelege = null)
    {
        return (bool) $this->getAcl()->isAllowed($this->getUser(), $resource, $privelege);
    }

    /**
     * @return App_Model_Acl
     */
    public function getAcl()
    {
        if (null === $this->_acl) {
            $this->setAcl(App_Model_Acl::getInstance());
        }
        return $this->_acl;
    }

    /**
     * @return App_View_Helper_IsAllowed
     */
    public function setAcl(Zend_Acl $acl)
    {
        $this->_acl = $acl;
        return $this;
    }

    /**
     * @return Users_Model_User
     */
    public function getUser()
    {
        if (null === $this->_user) {
            $this->setUser(Smapp::getCurrentUser());
        }
        return $this->_user;
    }

    /**
     * @return App_View_Helper_IsAllowed
     */
    public function setUser(Users_Model_User $user)
    {
        $this->_user = $user;
        return $this;
    }

}

for things like this in any view script

 <?php if ($this->isAllowed('mvc:snippets.crud', 'update')) : ?>
    <a title="Edit &laquo;<?=$this->escape($snippetInfo['title'])?>&raquo; snippet">Edit</a>
 <?php endif?>

Questions? :)