Quantcast
Channel: Richard Knop » Hashes and salts
Viewing all articles
Browse latest Browse all 2

User login and authentication with Zend_Auth and Zend_Acl

$
0
0

Whether you are working on a content management system or almost any other type of application with member or admin area, user login and authentication is one of the most important tasks you will encounter. In this post I will show you how to combine Zend_Auth and Zend_Acl to create a flexible user system with different access levels.

Scenario is simple. Your application has three modules and each module needs to be accessible to users with different priviledges:

  1. ‘default’ module – accessible to all users and guests
  2. ‘member’ module – accessible only to registered users
  3. ‘admin’ module – accessible only to registered users with administrator priviledges

I will go step by step and explain all parts in depth:

  • database table and model
  • registration form
  • registration controller action
  • login form
  • login controller action
  • logout controller action
  • authentication controller plugin
  • recommended reading

Database table and model

Let’s create a database table that will store all users:

CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(255) NOT NULL,
status VARCHAR(255) NOT NULL,
INDEX (first_name),
INDEX (last_name),
INDEX (username),
INDEX (email),
PRIMARY KEY (id)
) ENGINE = INNODB;

The ‘role’ column will hold a user access level, for example: ‘user’, ‘administrator’ etc. The ‘status’ column could have ‘pending’ and ‘approved’ possible values which would allow administrators to approve freshly registered users before they could access private parts of the website.

I put INDEX on ‘first_name’, ‘last_name’, ‘username’ and ‘email’ fields because are often be used instead of the ‘id’ to fetch a single user.

You will also need a model representing the table. Here is a very simple model, in a real-life application you will certainly need more methods (for editing and removing users etc):

class Users extends Zend_Db_Table_Abstract
{
    public function add(array $data)
    {
        $data['password_hash'] = $data['salt'] . sha1($data['salt'] . $data['password']);
        unset($data['password'], $data['salt']);
        return $this->insert($data);
    }

    public function getSingleWithUsername($username)
    {
        $select = $this->select();
        $where = $this->getAdapter()->quoteInto('username = ?', $username);
        $select->where($where);
        return $this->fetchRow($select);
    }

    public function getSingleWithEmail($email)
    {
        $select = $this->select();
        $where = $this->getAdapter()->quoteInto('email = ?', $email);
        $select->where($where);
        return $this->fetchRow($select);
    }

    public function getSingleWithEmailHash($hash)
    {
        $select = $this->select();
        $where = $this->getAdapter()->quoteInto('SHA1(email) = ?', $hash);
        $select->where($where);
        return $this->fetchRow($select);
    }
}

Notice this line in the add() method:

$data['password_hash'] = $data['salt'] . sha1($data['salt'] . $data['password']);

What it does is it first prepends the salt to the plain text password and then create an sha1 hash which can be safely stored in the database.

Registration form

Unless you want to create user accounts manually you should allow guests to register. The registration form:

class Register extends Zend_Form
{
    private $elementDecorators = array(
        'ViewHelper',
        array(array('data' => 'HtmlTag'), array('tag' => 'div', 'class' => 'element')),
        'Label',
        array(array('row' => 'HtmlTag'), array('tag' => 'li')),
    );

    private $buttonDecorators = array(
        'ViewHelper',
        array(array('data' => 'HtmlTag'), array('tag' => 'div', 'class' => 'button')),
        array(array('row' => 'HtmlTag'), array('tag' => 'li')),
    );

    public function init()
    {
        $this->setMethod('post');

        $firstName = new Zend_Form_Element_Text('first_name', array(
            'decorators' => $this->elementDecorators,
            'label' => 'First name',
            'required' => true,
            'filters' => array(
                'StringTrim'
            ),
            'validators' => array(
                array('StringLength', false, array(2, 50))
            ),
            'class' => 'input-text'
        ));

        $lastName = new Zend_Form_Element_Text('last_name', array(
            'decorators' => $this->elementDecorators,
            'label' => 'First name',
            'required' => true,
            'filters' => array(
                'StringTrim'
            ),
            'validators' => array(
                array('StringLength', false, array(2, 50))
            ),
            'class' => 'input-text'
        ));

        $email = new Zend_Form_Element_Text('email', array(
            'decorators' => $this->elementDecorators,
            'label' => 'Email',
            'required' => true,
            'filters' => array(
                'StringTrim'
            ),
            'validators' => array(
                'EmailAddress'
            ),
            'class' => 'input-text'
        ));

        $emailAgain = new Zend_Form_Element_Text('emailAgain', array(
            'decorators' => $this->elementDecorators,
            'label' => 'Email again',
            'required' => true,
            'filters' => array(
                'StringTrim'
            ),
            'validators' => array(
                'EmailAddress'
            ),
            'class' => 'input-text'
        ));

        $username = new Zend_Form_Element_Text('username', array(
            'decorators' => $this->elementDecorators,
            'label' => 'Username',
            'required' => true,
            'filters' => array(
                'StringTrim'
            ),
            'validators' => array(
                array('StringLength', false, array(3, 50))
            ),
            'class' => 'input-text'
        ));

        $password = new Zend_Form_Element_Password('password', array(
            'decorators' => $this->elementDecorators,
            'label' => 'Password',
            'required' => true,
            'filters' => array(
                'StringTrim'
            ),
            'validators' => array(
                array('StringLength', false, array(6, 50))
            ),
            'class' => 'input-password'
        ));

        $passwordAgain = new Zend_Form_Element_Password('passwordAgain', array(
            'decorators' => $this->elementDecorators,
            'label' => 'Password again',
            'required' => true,
            'filters' => array(
                'StringTrim'
            ),
            'validators' => array(
                array('StringLength', false, array(6, 50))
            ),
            'class' => 'input-password'
        ));

        $submit = new Zend_Form_Element_Submit('register', array(
            'decorators' => $this->buttonDecorators,
            'label' => 'Register',
            'class' => 'input-submit'
        ));

        $this->addElements(array(
            $firstName,
            $lastName,
            $email,
            $emailAgain,
            $username,
            $password,
            $passwordAgain,
            $submit
        ));
    }

    public function loadDefaultDecorators()
    {
        $this->setDecorators(array(
            'FormErrors',
            'FormElements',
            array('HtmlTag', array('tag' => 'ol')),
            'Form'
        ));
    }
}

The form is straightforward – a single form element per table column (except ‘id’, ‘role’ and ‘status’). I always use my custom decorators which produce the markup I want.

Registration controller action

public function registerAction()
{
    // redirect logged in users
    if (Zend_Registry::getInstance()->get('auth')->hasIdentity()) {
        $this->_redirect('/');
    }

    $request = $this->getRequest();
    $users = $this->_getTable('Users');

    $form = $this->_getForm('Register',
                            $this->_helper->url('register'));
    // if POST data has been submitted
    if ($request->isPost()) {
        // if the Register form has been submitted and the submitted data is valid
        if (isset($_POST['register']) && $form->isValid($_POST)) {

            $data = $form->getValues();

            if ($users->getSingleWithEmail($data['email']) != null) {
                // if the email already exists in the database
                $this->view->error = 'Email already taken';
            } else if ($users->getSingleWithUsername($data['username']) != null) {
                // if the username already exists in the database
                $this->view->error = 'Username already taken';
            } else if ($data['email'] != $data['emailAgain']) {
                // if both emails do not match
                $this->view->error = 'Both emails must be same';
            } else if ($data['password'] != $data['passwordAgain']) {
                // if both passwords do not match
                $this->view->error = 'Both passwords must be same';
            } else {

                // everything is OK, let's send email with a verification string
                // the verifications string is an sha1 hash of the email
                $mail = new Zend_Mail();
                $mail->setFrom('your@name.com', 'Your Name');
                $mail->setSubject('Thank you for registering');
                $mail->setBodyText('Dear Sir or Madam,

Thank You for registering at yourwebsite.com. In order for your account to be
activated please click on the following URI:

http://yourwebsite.com/admin/login/email-verification?str=' . sha1($data['email'])
. '
Best Regards,
Your Name and yourwebsite.com staff');
                $mail->addTo($data['email'],
                             $data['first_name'] . ' ' . $data['last_name']);

                if (!$mail->send()) {
                    // email sending failed
                    $this->view->error = 'Failed to send email to the address you provided';
                } else {

                    // email sent successfully, let's add the user to the database
                    unset($data['emailAgain'], $data['passwordAgain'],
                          $data['register']);
                    $data['salt'] = $this->_helper->RandomString(40);
                    $data['role'] = 'user';
                    $data['status'] = 'pending';
                    $users->add($data);
                    $this->view->success = 'Successfully registered';

                }

            }

        }
    }

    $this->view->form = $form;
}

First of all, in order to eliminate duplicate rows in the database we check for users with the same username or email before continuing. We also make sure a user correctly filled in both email input fields and both password input fields.

Afterwards, an email with a verification string (which happens to be an sha1 hash of the user email) is sent to the user and a new account is created. I use a handy action helper to generate a random string (in this case used as a salt):

class My_Controller_Action_Helper_RandomString extends Zend_Controller_Action_Helper_Abstract
{
    public function direct($length = 32,
                           $chars = '1234567890abcdef') {
        // length of character list
        $charsLength = (strlen($chars) - 1);

        // start our string
        $string = $chars{rand(0, $charsLength)};

        // generate random string
        for ($i = 1; $i < $length; $i = strlen($string)) {
            // grab a random character from our list
            $r = $chars{rand(0, $charsLength)};
            // make sure the same two characters don't appear next to each other
            if ($r != $string{$i - 1}) {
                $string .=  $r;
            } else {
                $i--;
            }
        }

        // return the string
        return $string;
    }
}

By clicking on the link the user is taken to the email verification page:

public function emailVerificationAction()
{
    $request = $this->getRequest();
    $users = $this->_getTable('Users');

    // get the verification string from the URI
    $str = $request->getParam('str');

    // check if the user corresponding to the string exists
    $user = $users->getSingleWithEmailHash($str);
    if ($user == null) {
        $this->view->error = 'Invalid verification string';
    } else {
        if ($user->status == 'approved') {
            // user account has already been activated
            $this->view->error = 'Account has already been activated';
        } else {

            // the user exists and the verification string is correct
            // let's approve the user
            if ($users->edit($user->id, array('status' => 'approved')) != false) {
                $this->view->success = 'Hello, '
                . $user->username . '! Your account has been activated';
            }

        }
    }
}

Login form

The login form is quite simple. It contains just two input fields (for username and password) and a “Remember me?” checkbox:

class Login extends Zend_Form
{
    private $elementDecorators = array(
        'ViewHelper',
        array(array('data' => 'HtmlTag'), array('tag' => 'div', 'class' => 'element')),
        'Label',
        array(array('row' => 'HtmlTag'), array('tag' => 'li')),
    );

    private $buttonDecorators = array(
        'ViewHelper',
        array(array('data' => 'HtmlTag'), array('tag' => 'div', 'class' => 'button')),
        array(array('row' => 'HtmlTag'), array('tag' => 'li')),
    );

    private $checkboxDecorators = array(
        'Label',
        'ViewHelper',
        array(array('data' => 'HtmlTag'), array('tag' => 'div', 'class' => 'checkbox')),
        array(array('row' => 'HtmlTag'), array('tag' => 'li')),
    );

    public function init()
    {
        $this->setMethod('post');

        $username = new Zend_Form_Element_Text('username', array(
            'decorators' => $this->elementDecorators,
            'label' => 'Username',
            'required' => true,
            'filters' => array(
                'StringTrim'
            ),
            'validators' => array(
                array('StringLength', false, array(3, 50))
            ),
            'class' => 'input-text'
        ));

        $password = new Zend_Form_Element_Password('password', array(
            'decorators' => $this->elementDecorators,
            'label' => 'Password',
            'required' => true,
            'filters' => array(
                'StringTrim'
            ),
            'validators' => array(
                array('StringLength', false, array(6, 50))
            ),
            'class' => 'input-password'
        ));

        $rememberMe = new Zend_Form_Element_Checkbox('rememberMe', array(
            'decorators' => $this->checkboxDecorators,
            'label' => 'Remember me?',
            'required' => true,
            'class' => 'input-checkbox'
        ));

        $submit = new Zend_Form_Element_Submit('login', array(
            'decorators' => $this->buttonDecorators,
            'label' => 'Login',
            'class' => 'input-submit'
        ));

        $this->addElements(array(
            $username,
            $password,
            $rememberMe,
            $submit
        ));
    }

    public function loadDefaultDecorators()
    {
        $this->setDecorators(array(
            'FormErrors',
            'FormElements',
            array('HtmlTag', array('tag' => 'ol')),
            'Form'
        ));
    }
}

Login controller action

public function indexAction()
{
    // redirect logged in users
    if (Zend_Registry::getInstance()->get('auth')->hasIdentity()) {
        $this->_redirect('/');
    }

    $request = $this->getRequest();
    $users = $this->_getTable('Users');

    $form = $this->_getForm('Login',
                            $this->_helper->url('login'));
    // if POST data has been submitted
    if ($request->isPost()) {
        // if the Login form has been submitted and the submitted data is valid
        if (isset($_POST['login']) && $form->isValid($_POST)) {

            // prepare a database adapter for Zend_Auth
            $adapter = new Zend_Auth_Adapter_DbTable($this->_getDb());
            $adapter->setTableName('users');
            $adapter->setIdentityColumn('username');
            $adapter->setCredentialColumn('password_hash');
            $adapter->setCredentialTreatment('CONCAT(SUBSTRING(password_hash, 1, 40), SHA1(CONCAT(SUBSTRING(password_hash, 1, 40), ?)))');
            $adapter->setIdentity($form->getValue('username'));
            $adapter->setCredential($form->getValue('password'));

            // try to authenticate a user
            $auth = Zend_Registry::get('auth');
            $result = $auth->authenticate($adapter);

            // is the user valid one?
            switch ($result->getCode()) {

                case Zend_Auth_Result::FAILURE_IDENTITY_NOT_FOUND:
                    $this->view->error = 'Identity not found';
                    break;

                case Zend_Auth_Result::FAILURE_CREDENTIAL_INVALID:
                    $this->view->error = 'Invalid credential';
                    break;

                case Zend_Auth_Result::SUCCESS:
                    // get user object (ommit password_hash)
                    $user = $adapter->getResultRowObject(null, 'password_hash');
                    // check if the user is approved
                    if ($user->status != 'approved') {
                        $this->view->error = 'User not approved';
                    } else {
                        // to help thwart session fixation/hijacking
                        if ($form->getValue('rememberMe') == 1) {
                            // remember the session for 604800s = 7 days
                            Zend_Session::rememberMe(604800);
                        } else {
                            // do not remember the session
                            Zend_Session::forgetMe();
                        }
                        // store user object in the session
                        $authStorage = $auth->getStorage();
                        $authStorage->write($user);
                        $this->_redirect('/admin');
                    }
                    break;

                default:
                    $this->view->error = 'Wrong username and/or password';
                    break;
            }

        }
    }

    $this->view->form = $form;
}

Notice this line:

$adapter->setCredentialTreatment('CONCAT(SUBSTRING(password_hash, 1, 40), SHA1(CONCAT(SUBSTRING(password_hash, 1, 40), ?)))');

The argument of the setCredentialTreatment() method is probably confusing, it’s a little more complex SQL expression. What it does is it compares the entered password with the password_hash from the database (substitute the entered password for the question mark). CONCAT() is a MySQL function for concating strings (in PHP it’s done with the concatenation operator – ‘.’), SUBSTRING() is very similar to the PHP’s substr() (the only difference is the first character has index 1 in MySQL and 0 in PHP), SHA1() is the same as sha1() in PHP.

Logout controller action

public function logoutAction()
{
    Zend_Registry::get('auth')->clearIdentity();
    $this->_redirect('/');
}

Authentication controller plugin

I always use a controller plugin for authentication. You could possibly do it in controllers (let’s say in the init() method) but I find the controller plugin to be an ideal for this purpose:

class My_Controller_Plugin_Auth extends Zend_Controller_Plugin_Abstract
{
    public function preDispatch(Zend_Controller_Request_Abstract $request)
    {
        $auth = Zend_Registry::getInstance()->get('auth');
        $acl = new Zend_Acl();

        // for default module
        if ($request->getModuleName() == 'default') {

            // access resources (controllers)
            // usually there will be more access resources
            $acl->add(new Zend_Acl_Resource('index'));
            $acl->add(new Zend_Acl_Resource('error'));

            // access roles
            $acl->addRole(new Zend_Acl_Role('guest'));
            $acl->addRole(new Zend_Acl_Role('user'));
            $acl->addRole(new Zend_Acl_Role('administrator'));

            // access rules
            $acl->allow('guest'); // allow guests everywhere
            $acl->allow('user'); // allow users everywhere
            $acl->allow('administrator'); // allow administrators everywhere

            $role = ($auth->getIdentity() && $auth->getIdentity()->status = 'approved')
            ? $auth->getIdentity()->role : 'guest';
            $controller = $request->getControllerName();
            $action = $request->getActionName();

            if (!$acl->isAllowed($role, $controller, $action)) {
                $redirector = Zend_Controller_Action_HelperBroker::getStaticHelper('Redirector');
                $redirector->gotoUrlAndExit('error/denied');
            }

        }
        // for member module
        else if ($request->getModuleName() == 'member') {

            // access resources (controllers)
            // usually there will be more access resources
            $acl->add(new Zend_Acl_Resource('index'));
            $acl->add(new Zend_Acl_Resource('error'));

            // access roles
            $acl->addRole(new Zend_Acl_Role('guest'));
            $acl->addRole(new Zend_Acl_Role('user'));
            $acl->addRole(new Zend_Acl_Role('administrator'));

            // access rules
            $acl->allow('user'); // allow users everywhere
            $acl->allow('administrator'); // allow administrators everywhere

            $role = ($auth->getIdentity() && $auth->getIdentity()->status = 'approved')
            ? $auth->getIdentity()->role : 'guest';
            $controller = $request->getControllerName();
            $action = $request->getActionName();

            if (!$acl->isAllowed($role, $controller, $action)) {
                $redirector = Zend_Controller_Action_HelperBroker::getStaticHelper('Redirector');
                $redirector->gotoUrlAndExit('error/denied');
            }

        }
        // for admin module
        else if ($request->getModuleName() == 'admin') {

            // access resources (controllers)
            // usually there will be more access resources
            $acl->add(new Zend_Acl_Resource('index'));
            $acl->add(new Zend_Acl_Resource('error'));

            // access roles
            $acl->addRole(new Zend_Acl_Role('guest'));
            $acl->addRole(new Zend_Acl_Role('user'));
            $acl->addRole(new Zend_Acl_Role('administrator'));

            // access rules
            $acl->allow('administrator'); // allow administrators everywhere

            $role = ($auth->getIdentity() && $auth->getIdentity()->status = 'approved')
            ? $auth->getIdentity()->role : 'guest';
            $controller = $request->getControllerName();
            $action = $request->getActionName();

            if (!$acl->isAllowed($role, $controller, $action)) {
                $redirector = Zend_Controller_Action_HelperBroker::getStaticHelper('Redirector');
                $redirector->gotoUrlAndExit('error/denied');
            }

        }
    }
}

As you can see, there are different access rules for each module. It’s not terribly difficult to create more comples access rules. The one thing you must understand is that (quoted from the Zend Framework documentation):

In general, Zend_Acl obeys a given rule if and only if a more specific rule does not apply.

For instance, let’s say you want to disallow logged in users from accessing the login form:

// allow guest to access the login controller
// this still doesn't mean they can access any other controller unless specified
$acl->allow('guest', 'login')
// allow users to access all controllers
$acl->allow('user');
// disallow users from accessing specifically the login controller
$acl->deny('user', 'login');
// allow administrators to access all controllers
$acl->allow('administrator');
// disallow administrators from accessing specifically the login controller
$acl->deny('administrator', 'login');

You can also specify actions in the access rules:

// disallow users from accessing action1, action2 and action3 of the index controller
$acl->deny('user', 'index', array('action1', 'action2', 'action3'));

One more thing, you need to register the plugin so it works. I usually do it in the bootstrap file:

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

You need to add the ‘My_’ namespace to the application config file, too:

autoloaderNamespaces[] = "My_"

Recommended reading


Viewing all articles
Browse latest Browse all 2

Latest Images

Trending Articles





Latest Images