During one of my recent consultancy I helped a customer to implement an authentication and authorization mechanism for a Zend Framework 1 application. Because this scenario is quite common in PHP business applications, I decided to write this post to present a possible solution.
The requirements of the web application were:- implement a login page to access the web application;
- authorize the usage of the application based on user's groups;
- management of the MVC architecture of ZF using modules;
- implement an access control list (ACL) to authorize specific modules, controllers and actions for user's groups;
- implement a LDAP authentication or a database authentication (based on the user profile);
- use of a database to manage the user's groups and the relative authorizations;
- good performance (the ACL can be big).
In this implementation I used the Zend_Auth class to authenticate the users and Zend_Acl to implement an access control list based on ZF modules, controllers and actions. I used a FrontController plugin to insert the authentication and the authorization check using the preDispatch() method. Moreover, I used the Zend_Auth_Adapter_DbTable and Zend_Auth_Adapter_Ldap to implement the authentication using a DB and a LDAP server.
The implementation follow the common criteria of a ZF application. The interesting part is the permission definition for the resources with the user's groups. I proposed a special syntax using the following structure: "module/controller/action" where module, controller, and action are the names of the specific components. I implemented a way to specify multiples components using the star (*) character. That means you can specify resources like: "module/*/*" to enable or disable the access to all the controllers and actions of a specific module. Using this mechanism you can provide complex rules with few definitions, for instance to enable the access of generic controllers and deny the access for some specific actions you will need only two resources. The idea is to enable or disable the access of specific resources based on the permission starting from the general to the specific one, using a top-down approach:
- */*/*
- module/*/*
- module/controller/*
- module/controller/action
A specific rule wins against the general one. Unless otherwise specified all resources are disabled by default. I used the following MySQL database to implement the data structure of the authentication and authorization system:
CREATE TABLE 'permissions' (
'id' int(11) NOT NULL auto_increment,
'id_role' int(11) NOT NULL,
'id_resource' int(11) NOT NULL,
'permission' enum('allow','deny') NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE 'resources' (
'id' int(11) NOT NULL auto_increment,
'resource' varchar(128) NOT NULL,
PRIMARY KEY ('id')
);
CREATE TABLE 'roles' (
'id' int(11) NOT NULL auto_increment,
'role' varchar(40) NOT NULL,
'id_parent' int(11) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE 'users' (
'username' varchar(40) NOT NULL,
'password' varchar(40) NOT NULL,
'id_role' int(11) NOT NULL,
'ldap' tinyint(1) NOT NULL default '0',
PRIMARY KEY USING BTREE (`username`)
);
We have 4 tables: users, roles, resources and permissions. In the table users we store the user's profile with the role of each user and the authentication mechanism, using an LDAP server (ldap=1) or a database (ldap=0). Using the role table we provide a generic solution to inheritance privileges from a parent group (using the id_parent field). In the permissions we use a field (permission) that can be used to allow or deny the access of a resource for a specific user's group.
For the performance requirement we used a caching system for the ACL object. That means the system caches the Zend Acl object based on the user's role and reuse it on different HTTP requests. In this way the access to MySQL is done only once, for each ACL user's group. The lifetime of the cache can be very long, because the ACL do not change so frequently. I used the APC backends to cache the access control list.
Notes about the example
Here you can download the source code of my implementation.
I created a ZF application with 2 modules: login and home. In the login module I put all the logic of authentication and authorization. This solution is quite good because you can just reuse it in different ZF applications without changes.
In the home module I put only 2 static pages: index and menu. This just to provide an example of different permissions. In the database provided with the example, we have two users: admin and enrico (with passwords 'admin' and 'enrico'). Admin can access all the modules, controllers, actions of the application (using the resource syntax '*/*/*'). Enrico can access all the resources in the home module, except for the action menu of the index controller (this is specified with the two following rules: allow 'home/*/*', deny 'home/index/menu').
Try to access with admin, and request the url /home/index/menu. After logout and login with enrico to try again to access the url /home/index/menu, you will see that the application will redirect you on the login page because enrico doesn't have the right to access that url.
The authorization part is implemented in Login_Plugin_SecurityCheck class. Here you can fine the logic of user authorization in the _isAllowed() function.
Below is reported the source code of this plugin:
class Login_Plugin_SecurityCheck extends Zend_Controller_Plugin_Abstract
{
const MODULE_NO_AUTH='login';
private $_controller;
private $_module;
private $_action;
private $_role;
/**
* preDispatch
*
* @param Zend_Controller_Request_Abstract $request
*/
public function preDispatch (Zend_Controller_Request_Abstract $request)
{
$this->_controller = $this->getRequest()->getControllerName();
$this->_module= $this->getRequest()->getModuleName();
$this->_action= $this->getRequest()->getActionName();
$auth= Zend_Auth::getInstance();
$redirect=true;
if ($this->_module != self::MODULE_NO_AUTH) {
if ($this->_isAuth($auth)) {
$user= $auth->getStorage()->read();
$this->_role= $user['id_role'];
$bootstrap = Zend_Controller_Front::getInstance()
->getParam('bootstrap');
$db= $bootstrap->getResource('db');
$manager = $bootstrap->getResource('cachemanager');
$cache = $manager->getCache('acl');
if (($acl= $cache->load('ACL_'.$this->_role))===false) {
$acl= new Login_Acl($db,$this->_role);
$cache->save($acl,'ACL_'.$this->_role);
}
if ($this->_isAllowed($auth,$acl)) {
$redirect=false;
}
}
} else {
$redirect=false;
}
if ($redirect) {
$request->setModuleName('login');
$request->setControllerName('index');
$request->setActionName('index');
}
}
/**
* Check user identity using Zend_Auth
*
* @param Zend_Auth $auth
* @return boolean
*/
private function _isAuth (Zend_Auth $auth)
{
if (!empty($auth) && ($auth instanceof Zend_Auth)) {
return $auth->hasIdentity();
}
return false;
}
/**
* Check permission using Zend_Auth and Zend_Acl
*
* @param Zend_Auth $auth
* @param Zend_Acl $acl
* @return boolean
*/
private function _isAllowed(Zend_Auth $auth, Zend_Acl $acl)
{
if (empty($auth) || empty($acl) ||
!($auth instanceof Zend_Auth) ||
!($acl instanceof Zend_Acl)) {
return false;
}
$resources= array (
'*/*/*',
$this->_module.'/*/*',
$this->_module.'/'.$this->_controller.'/*',
$this->_module.'/'.$this->_controller.'/'.$this->_action
);
$result=false;
foreach ($resources as $res) {
if ($acl->has($res)) {
$result= $acl->isAllowed($this->_role,$res);
}
}
return $result;
}
}