A free and open-source book on ZF3 for beginners


16.7. Implementing User Authentication

Authentication is the process performed when a user provides his login and password and you decide whether these credentials are correct. Authentication typically means you check your database for the given login, and if such login exists, you check if the hash calculated by the given password matches the hash of the password stored in the database.

You typically do not store raw passwords in database. Instead, you store a hash of the password. This is done for security reasons.

Once the authentication algorithm determines that the login and password are correct, it returns user identity - a unique ID of the user. The identity is typically stored to session, so the visitor doesn't need to pass authentication for every HTTP request.

In ZF3, there is a special component allowing you to implement user authentication - Zend\Authentication. You can install this component with Composer by typing the following command:

php composer.phar require zendframework/zend-authentication

For authentication to work, you also need to have Zend\Session component installed and session manager configured. For information on how to do that, refer to Working with Sessions chapter.

16.7.1. AuthenticationService

The Zend\Authentication component provides the special service class called AuthenticationService living in Zend\Authentication namespace. Most useful methods of this service are shown in table 16.1 below.

Table 16.1. Methods of AuthenticationService class
Method Description
authenticate() Performs user authentication using the adapter.
getAdapter() Gets authentication adapter.
setAdapter() Sets authentication adapter implementing the actual authentication algorithm.
getStorage() Returns storage handler.
setStorage() Sets storage handler.
hasIdentity() Returns true if user identity is already stored in session.
getIdentity() Retrieves user identity from session.
clearIdentity() Removes user identity from session.

As you can see from the table, you can use the authenticate() method to perform user authentication. Besides that you can use hasIdentity(), getIdentity() and clearIdentity() methods for testing, retrieving and clearing user identity, respectively.

However, the AuthenticationService service is very generic - it knows nothing about how to actually match login and password against the database. It also knows nothing about how to save the user identity to session. This design allows you to implement any suitable authentication algorithm and any suitable storage.

The Zend\Authentication component provides several authentication adapters implementing some standard authentication algorithms (see figure 16.9), and several storage handlers allowing you to save and retrieve the user identity (see figure 16.10).

Figure 16.9 Standard authentication adapters Figure 16.9 Standard authentication adapters

Figure 16.10 Standard storage handlers Figure 16.10 Standard storage handlers

For our purposes, we can use Session storage handler without needing to change any code. However, standard authentication adapters are not suitable for us, because we use Doctrine ORM. We will have to write our custom authentication adapter. Luckily, this is rather simple to do.

16.7.2. Writing Authentication Adapter

An authentication adapter must implement the AdapterInterface interface, which has the single method authenticate(). This method should check the user email and password against the database. We will do this as follows:

The authenticate() method returns an instance of the Zend\Authentication\Result class. The Result class contains the authentication status, the error message and the user identity.

The adapter can also have additional methods. For example, we will add the setEmail() and setPassword() methods that we will use to pass user email and password to the adapter.

To create the authentication adapter, add the file AuthAdapter.php to the Service directory of the module's source directory.

In the User Demo sample, we create a separate module called User and add functionality related to authentication and user management to that module.

Put the following code into that file:

<?php
namespace User\Service;

use Zend\Authentication\Adapter\AdapterInterface;
use Zend\Authentication\Result;
use Zend\Crypt\Password\Bcrypt;
use User\Entity\User;

/**
 * Adapter used for authenticating user. It takes login and password on input
 * and checks the database if there is a user with such login (email) and password.
 * If such user exists, the service returns his identity (email). The identity
 * is saved to session and can be retrieved later with Identity view helper provided
 * by ZF3.
 */
class AuthAdapter implements AdapterInterface
{
    /**
     * User email.
     * @var string 
     */
    private $email;
    
    /**
     * Password
     * @var string 
     */
    private $password;
    
    /**
     * Entity manager.
     * @var Doctrine\ORM\EntityManager 
     */
    private $entityManager;
        
    /**
     * Constructor.
     */
    public function __construct($entityManager)
    {
        $this->entityManager = $entityManager;
    }
    
    /**
     * Sets user email.     
     */
    public function setEmail($email) 
    {
        $this->email = $email;        
    }
    
    /**
     * Sets password.     
     */
    public function setPassword($password) 
    {
        $this->password = (string)$password;        
    }
    
    /**
     * Performs an authentication attempt.
     */
    public function authenticate()
    {                
        // Check the database if there is a user with such email.
        $user = $this->entityManager->getRepository(User::class)
                ->findOneByEmail($this->email);
        
        // If there is no such user, return 'Identity Not Found' status.
        if ($user==null) {
            return new Result(
                Result::FAILURE_IDENTITY_NOT_FOUND, 
                null, 
                ['Invalid credentials.']);        
        }   
        
        // If the user with such email exists, we need to check if it is active or retired.
        // Do not allow retired users to log in.
        if ($user->getStatus()==User::STATUS_RETIRED) {
            return new Result(
                Result::FAILURE, 
                null, 
                ['User is retired.']);        
        }
        
        // Now we need to calculate hash based on user-entered password and compare
        // it with the password hash stored in database.
        $bcrypt = new Bcrypt();
        $passwordHash = $user->getPassword();
        
        if ($bcrypt->verify($this->password, $passwordHash)) {
            // Great! The password hash matches. Return user identity (email) to be
            // saved in session for later use.
            return new Result(
                    Result::SUCCESS, 
                    $this->email, 
                    ['Authenticated successfully.']);        
        }             
        
        // If password check didn't pass return 'Invalid Credential' failure status.
        return new Result(
                Result::FAILURE_CREDENTIAL_INVALID, 
                null, 
                ['Invalid credentials.']);        
    }
}

16.7.3. Creating the Factory for AuthenticationService

Once we've implemented the adapter, we can actually create the AuthenticationService. ZF3's AuthenticationService should be registered in the service manager before you can use it. First of all, we will create a factory for it. Add the AuthenticationServiceFactory.php file under the Service/Factory directory and put the following code there:

<?php
namespace User\Service\Factory;

use Interop\Container\ContainerInterface;
use Zend\Authentication\AuthenticationService;
use Zend\ServiceManager\Factory\FactoryInterface;
use Zend\Session\SessionManager;
use Zend\Authentication\Storage\Session as SessionStorage;
use User\Service\AuthAdapter;

/**
 * The factory responsible for creating of authentication service.
 */
class AuthenticationServiceFactory implements FactoryInterface
{
    /**
     * This method creates the Zend\Authentication\AuthenticationService service 
     * and returns its instance. 
     */
    public function __invoke(ContainerInterface $container, 
                    $requestedName, array $options = null)
    {
        $sessionManager = $container->get(SessionManager::class);
        $authStorage = new SessionStorage('Zend_Auth', 'session', $sessionManager);
        $authAdapter = $container->get(AuthAdapter::class);

        // Create the service and inject dependencies into its constructor.
        return new AuthenticationService($authStorage, $authAdapter);
    }
}

In the factory we do the following: First, we create an instance of the session manager (you should have set up the session manager already) and create an instance of Session storage handler. Then we create an instance of AuthAdapter. Finally, we instantiate the AuthenticationService and inject dependencies (storage handler and adapter) into it.

Register the AuthenticationService in your module.config.php config file as follows:

<?php 
return [
    'service_manager' => [
        'factories' => [
            \Zend\Authentication\AuthenticationService::class 
                => Service\Factory\AuthenticationServiceFactory::class,
            // ...
        ],
    ],
];

16.7.4. Adding AuthController

The AuthController class will have two actions:

Figure 16.11 Login page Figure 16.11 Login page

Figure 16.12 Login page - Invalid Credentials Figure 16.12 Login page - Invalid Credentials

The code of the AuthController controller class is presented below:

<?php

namespace User\Controller;

use Zend\Mvc\Controller\AbstractActionController;
use Zend\View\Model\ViewModel;
use Zend\Authentication\Result;
use Zend\Uri\Uri;
use User\Form\LoginForm;
use User\Entity\User;

/**
 * This controller is responsible for letting the user to log in and log out.
 */
class AuthController extends AbstractActionController
{
    /**
     * Entity manager.
     * @var Doctrine\ORM\EntityManager 
     */
    private $entityManager;
    
    /**
     * Auth manager.
     * @var User\Service\AuthManager 
     */
    private $authManager;
    
    /**
     * Auth service.
     * @var \Zend\Authentication\AuthenticationService
     */
    private $authService;
    
    /**
     * User manager.
     * @var User\Service\UserManager
     */
    private $userManager;
    
    /**
     * Constructor.
     */
    public function __construct($entityManager, $authManager, $authService, $userManager)
    {
        $this->entityManager = $entityManager;
        $this->authManager = $authManager;
        $this->authService = $authService;
        $this->userManager = $userManager;
    }
    
    /**
     * Authenticates user given email address and password credentials.     
     */
    public function loginAction()
    {
        // Retrieve the redirect URL (if passed). We will redirect the user to this
        // URL after successfull login.
        $redirectUrl = (string)$this->params()->fromQuery('redirectUrl', '');
        if (strlen($redirectUrl)>2048) {
            throw new \Exception("Too long redirectUrl argument passed");
        }
        
        // Check if we do not have users in database at all. If so, create 
        // the 'Admin' user.
        $this->userManager->createAdminUserIfNotExists();
        
        // Create login form
        $form = new LoginForm(); 
        $form->get('redirect_url')->setValue($redirectUrl);
        
        // Store login status.
        $isLoginError = false;
        
        // Check if user has submitted the form
        if ($this->getRequest()->isPost()) {
            
            // Fill in the form with POST data
            $data = $this->params()->fromPost();            
            
            $form->setData($data);
            
            // Validate form
            if($form->isValid()) {
                
                // Get filtered and validated data
                $data = $form->getData();
                
                // Perform login attempt.
                $result = $this->authManager->login($data['email'], 
                        $data['password'], $data['remember_me']);
                
                // Check result.
                if ($result->getCode()==Result::SUCCESS) {
                    
                    // Get redirect URL.
                    $redirectUrl = $this->params()->fromPost('redirect_url', '');
                    
                    if (!empty($redirectUrl)) {
                        // The below check is to prevent possible redirect attack 
                        // (if someone tries to redirect user to another domain).
                        $uri = new Uri($redirectUrl);
                        if (!$uri->isValid() || $uri->getHost()!=null)
                            throw new \Exception('Incorrect redirect URL: ' . $redirectUrl);
                    }

                    // If redirect URL is provided, redirect the user to that URL;
                    // otherwise redirect to Home page.
                    if(empty($redirectUrl)) {
                        return $this->redirect()->toRoute('home');
                    } else {
                        $this->redirect()->toUrl($redirectUrl);
                    }
                } else {
                    $isLoginError = true;
                }                
            } else {
                $isLoginError = true;
            }           
        } 
        
        return new ViewModel([
            'form' => $form,
            'isLoginError' => $isLoginError,
            'redirectUrl' => $redirectUrl
        ]);
    }
    
    /**
     * The "logout" action performs logout operation.
     */
    public function logoutAction() 
    {        
        $this->authManager->logout();
        
        return $this->redirect()->toRoute('login');
    }
}

The loginAction() method accepts the redirectUrl GET parameter. The "redirect URL" is a convenience feature working with the access filter that we will describe later in this chapter. When the site visitor tries to access a web page, the access filter forbids access, and he/she is redirected to the "Login" page, passing the URL of the original page as the "redirect URL". When the user logs in, he/she is redirected back to the original page automatically, improving user experience.

16.7.5. Adding View Template for Login Page

The view template (.phtml file) for our Login page looks as follows:

<?php
$this->headTitle('Sign in');

$this->mainMenu()->setActiveItemId('login');

$form->get('email')->setAttributes([
    'class'=>'form-control', 
    'placeholder'=>'Email address',
    'required' => true,
    'autofocus' => true
    ])
    ->setLabelAttributes([
        'class' => 'sr-only'
    ]);

$form->get('password')->setAttributes([
    'class'=>'form-control', 
    'placeholder'=>'Password',
    'required' => true,
    ])
    ->setLabelAttributes([
        'class' => 'sr-only'
    ]);
?>

<div class="row">
    <div class="col-md-offset-4 col-md-3">
        <form class="form-signin" method="post">
            <h2 class="form-signin-heading">Please sign in</h2>
            <?php if ($isLoginError): ?>
            <div class="alert alert-warning" role="alert">
                Incorrect login and/or password. 
                <a href="<?= $this->url('reset-password') ?>">Forgot password?</a>
            </div>
            <?php endif; ?>
            <?= $this->formLabel($form->get('email')); ?>
            <?= $this->formElement($form->get('email')); ?>
            <?= $this->formLabel($form->get('password')); ?>
            <?= $this->formElement($form->get('password')); ?>
            <div class="checkbox">
                <label>
                    <?= $this->formElement($form->get('remember_me')); ?> Remember me
                </label>
            </div>
            <?= $this->formElement($form->get('redirect_url')); ?>
            <?= $this->formElement($form->get('csrf')) ?>
            <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
        </form>
    </div>
</div>

The view template uses the Sign In page template provided by Bootstrap CSS Framework. You can find the original template here.

16.7.6. Adding AuthManager Service

The AuthController works with the AuthManager service. The main business logic behind the authentication is implemented in the service. Let's describe the AuthManager in detail.

The AuthManager service has the following methods responsible for authentication:

The login() method (see below) uses the ZF3's AuthenticationService and the AuthAdapter we wrote earlier for performing user authentication. The method additionally accepts the $rememberMe argument which extends the session cookie lifetime to 30 days.

/**
 * Performs a login attempt. If $rememberMe argument is true, it forces the session
 * to last for one month (otherwise the session expires on one hour).
 */
public function login($email, $password, $rememberMe)
{   
    // Check if user has already logged in. If so, do not allow to log in 
    // twice.
    if ($this->authService->getIdentity()!=null) {
        throw new \Exception('Already logged in');
    }
        
    // Authenticate with login/password.
    $authAdapter = $this->authService->getAdapter();
    $authAdapter->setEmail($email);
    $authAdapter->setPassword($password);
    $result = $this->authService->authenticate();

    // If user wants to "remember him", we will make session to expire in 
    // one month. By default session expires in 1 hour (as specified in our 
    // config/global.php file).
    if ($result->getCode()==Result::SUCCESS && $rememberMe) {
        // Session cookie will expire in 1 month (30 days).
        $this->sessionManager->rememberMe(60*60*24*30);
    }
    
    return $result;
}

The logout() method removes the user identity from session, so the visitor becomes unauthenticated.

/**
 * Performs user logout.
 */
public function logout()
{
    // Allow to log out only when user is logged in.
    if ($this->authService->getIdentity()==null) {
        throw new \Exception('The user is not logged in');
    }
    
    // Remove identity from session.
    $this->authService->clearIdentity();               
}

Top