Authentification de Zend Framework 2

Développement des applications web avec Zend Framework 2

Ce site ne sera plus alimenté de contenu après août 2014. Tous les nouveaux articles seront redigés pour www.waitingforcode.com
Comparée à l'authentification dans Symfony2, où l'absence de documentation dans les premiers mois du framework a compliqué l'implémentation, l'authentification des utilisateurs en Zend Framework 2 est quasiment un jeu d'enfant.

A travers cet article on verra comment implémenter authentification personnaliée en Zend Framework 2. Au tout début on développera un Adapter qui va s'occuper d'aller vérifier dans la base de données si l'identifiant et le mot de passe saisis par l'utilisateur sont corrects. Ensuite on passera au fichier AuthenticationService qui se charge d'exécuter l'Adapter. Le troisième et le dernier fichier va "saler" le mot de passe pour le rendre plus sécurisé.

Adapter en Zend Framework 2
Un Adapter permet de gérer une authentification à travers des systèmes différents (fichier, base de données). Il doit implémenter l'interface Zend\Authentication\Adapter\AdapterInterface. Sa méthode authenticate() permet de vérifier si l'utilisateur peut se correctement authentifier. Le résultat de cette fonction doit être une instance de la classe Zend\Authentication\Result .

Un Adapter contient quelques méthodes :
- isValid() : retourne vrai si le résultat d'authentification est correcte.
- getCode() : retourne le code correspondant au résulta de l'authentification.
- getIdentity() : retourne l'identité de l'utilisateur authentifié.
- getMessages() : retourne les messages liés au processus d'authentification.

Voici le code d'un Adapter héritant d'un Zend\Authentication\Adapter\DbTable :

namespace Application\Auth;

use Zend\Authentication\Adapter\AdapterInterface;
use Zend\Authentication\Result;
use Zend\Authentication\Adapter\DbTable;
use Zend\Db\Adapter\Adapter as DbAdapter;
use Zend\Db\Sql\Select;
use Zend\Db\ResultSet\ResultSet;
use Application\Auth\SaltCellar;

class Adapter extends DbTable
{
/**
* Instance of salt cellar.
* @access protected
* @var Application\Auth\SaltCellar
*/
protected $saltCellar;
/**
* Column with user register date.
* @access protected
* @var String
*/
protected $dateColumn;
/**
* Checks if current authentication is the first attempt of the user.
* @access protected
* @var boolean
*/
protected $firstLogin;
/**
* Array with relations between database columns and desired authentication columns in returner array.
* For exemple : array("database_login" => "login", "database_access" => "access")
* @access protected
* @var array
*/
protected $modelArray;
/**
* Values added to generated fingerprinting hash. Must contain "start" and "end" keys.
* @access protected
* @var array
*/
protected $fingerHash = array('start' => '', 'end' => '');
/**
* User's fingerprinting proof. It's generated on every Adapter's call.
* @access protected
* @var String
*/
protected $fingerprinting = '';
/**
* Fingerprinting taken from authentication storage (for exemple : session storage).
* @access protected
* @var String
*/
protected $sessionFingerprinting = '.';
/**
* Session's expiration time (in seconds).
* @access protected
* @var int
*/
protected $sessionTimeout = 0;
/**
* Maximal time (in seconds) of session persistance.
* @access protected
* @var int
*/
protected $sessionLimit;
/**
* Columns which are serialized in the database.
* @access protected
* @var array
*/
protected $serializedCols = array();

public function __construct(DbAdapter $zendDb, $tableName = null, $identityColumn = null,
$credentialColumn = null, $credentialTreatment = null, $dateColumn = null, $firstLogin = true)
{
parent::__construct($zendDb, $tableName, $identityColumn, $credentialColumn, $credentialTreatement);
if($dateColumn !== null) $this->setDateColumn($dateColumn);
if(isset($firstLogin)) $this->setFirstLogin($firstLogin);
}

// @Override
protected function _authenticateCreateSelect()
{
$dbSelect = clone $this->getDbSelect();
$dbSelect->from($this->tableName);
$dbSelect->columns(array('*'));
$dbSelect->where($this->zendDb->getPlatform()->quoteIdentifier($this->identityColumn).' = ?');
return $dbSelect;
}

// @Override
protected function _authenticateValidateResult($resultIdentity)
{
if(!($comparedSession = $this->compareSession()) || !$this->checkSaltPassword($resultIdentity) ||
$resultIdentity[$this->identityColumn] != $this->identity
)
{
$this->authenticateResultInfo['code'] = Result::FAILURE_CREDENTIAL_INVALID;
$this->authenticateResultInfo['messages'][] = 'Supplied credential is invalid.';
return $this->_authenticateCreateAuthResult();
}
$this->resultRow = $resultIdentity;
$this->makeFingerprinting();
$this->authenticateResultInfo['identity'] = $this->createIdentityArray($resultIdentity);
$this->authenticateResultInfo['code'] = Result::SUCCESS;
$this->authenticateResultInfo['messages'][] = 'Authentication successful.';
return $this->_authenticateCreateAuthResult();
}

// @Override
protected function _authenticateQuerySelect(Select $dbSelect)
{
$statement = $this->zendDb->createStatement();
$dbSelect->prepareStatement($this->zendDb, $statement);
$resultSet = new ResultSet();
try {
$resultSet->initialize($statement->execute(array($this->identity)));
$resultIdentities = $resultSet->toArray();
} catch (\Exception $e) {
throw new Exception\RuntimeException(
'The supplied parameters to DbTable failed to '
. 'produce a valid sql statement, please check table and column names '
. 'for validity.', 0, $e
);
}
return $resultIdentities;
}

/**
* Generates user's fingerpriting proof.
* @access private
* @return void
*/
private function makeFingerprinting()
{
$this->fingerprinting = sha1($this->fingerHash['start'].$_SERVER['HTTP_USER_AGENT']."".$_SERVER['SERVER_ADDR']."".$_SERVER['SERVER_PROTOCOL']."rtrhvcs".$_SERVER['HTTP_ACCEPT_ENCODING'].$this->fingerHash['end']);
}

/**
* Checks salled password.
* @access protected
* @param array $resultIdentity Result of authentication process.
* @return boolea True if password is correct, false otherwise.
*/
protected function checkSaltPassword($resultIdentity)
{
if($this->firstLogin)
{
if(!isset($this->saltCellar) || !($this->saltCellar instanceof SaltCellar)) return false;
$createdDate = strtotime($resultIdentity[$this->dateColumn]);
$salt = $this->saltCellar->getSalt(date('Y-m-d', $createdDate));
$passSalt = sha1($this->saltCellar->setHash(array('salt' => $salt, 'mdp' => $this->credential, 'login' => $this->identity), date('n', $createdDate)));
return $passSalt == $resultIdentity[$this->credentialColumn];
}
else
{
return $resultIdentity[$this->credentialColumn] == $this->credential;
}
}

/**
* Checks if session's values are correct.
* @access protected
* @return boolean True if they are correct, false otherwise.
*/
protected function compareSession()
{
if($this->firstLogin) return true;
if($this->fingerprinting == "") $this->makeFingerprinting();
if(!$this->firstLogin && ($this->fingerprinting != $this->sessionFingerprinting) || $this->sessionIsTimeout())
{
return false;
}
return true;
}

/**
* Checks if session is expired.
* @access public
* @return boolean True if session is expired, false otherwise.
*/
public function sessionIsTimeout()
{
return time() > $this->sessionLimit;
}

/**
* Creates an identity array based on $this->modelArray values.
* @access protected
* @param array $identity Result of authentication process.
* @return array Identity array
*/
protected function createIdentityArray($identity)
{
// if no session timeout specified, set 30 minutes by default
if($this->sessionTimeout == 0) $this->setSessionTimeout(1800);
if(count($this->modelArray) > 0)
{
foreach($this->modelArray as $key => $value)
{
if(in_array($key, $this->serializedCols))
{
$identity[$key] = unserialize($identity[$value]);
}
else
{
$identity[$key] = $identity[$value];
}
}
}
$identity['signature'] = $this->fingerprinting;
$identity['timeout'] = time()+$this->sessionTimeout;
return $identity;
}

/**
* Setters
*/
public function setSerializedCols($cols)
{
$this->serializedCols = $cols;
}

public function setSaltCellar(SaltCellar $cellar)
{
$this->saltCellar = $cellar;
}
public function setDateColumn($d)
{
$this->dateColumn = $d;
}

public function setFirstLogin($f)
{
$this->firstLogin = $f;
}

public function setFingerHash($hash)
{
if(!array_key_exists('start', $hash))
{
throw new \Exception('Fingerhash array must contain "start" key');
}
if(!array_key_exists('end', $hash))
{
throw new \Exception('Fingerhash array must contain "end" key');
}
$this->fingerHash = $hash;
}

public function setSessionFingerprinting($s)
{
$this->sessionFingerprinting = $s;
}

public function setSessionTimeout($t)
{
$this->sessionTimeout = $t;
}

public function setSessionLimit($l)
{
$this->sessionLimit = $l;
}

public function setModelArray($model)
{
$this->modelArray = $model;
}

/**
* Getters.
*/
public function getSerializedCols()
{
return $this->serializedCols;
}

public function getSaltCellar()
{
return $this->saltCellar;
}
}


Qu'est-ce que fait l'Adapter dans l'authentification Zend Framework 2 ? Tout d'abord, son constructeur prend les paramètres qui serviront à se connecter à la base de données et à vérifier la validité des identifiants renseignés. La méthode surchargée _authenticateCreateSelect() construit une requête SQL qui va récupérer la ligne correspondant au login de l'utilisateur. Une autre fonction surchargée, _authenticateValidateResult(), crée le résultat de l'authentification. Quant à _authenticateQuerySelect(), également surchargée, elle s'occupe d'aller questionner la base de données et de retourner le tableau avec la ligne correspondant au login de l'utilisateur. Les autres fonctions vérifient la cohérences des données (pour éviter par exemple problèmes de session en PHP) ainsi que construisent l'identité qui va persister dans la session.

AuthenticationService en Zend Framework 2
Le résultat de l'authentification peut persister dans la session. Pour cela, il est nécessaire de définir un moyen de persistance dans AuthenticationService. Ce système doit implémenter l'interface Zend\Authentication\Storage\StorageInterface qui définit des méthodes suivantes :
- isEmpty() : vérifie si le système de persistance contient des valeurs.
- read() : lit le contenu inclus dans le système de persistance.
- write() : sauvegarde un nouveau contenu dans le système de persistance.
- clear() : vide le contenu du système de persistance.

Voici le code de notre AuthenticationService :

namespace Application\Auth;

use Zend\Authentication\AuthenticationService as BaseAuthenticationService;
use Zend\Authentication\Adapter\AdapterInterface;
use Zend\Authentication\Result;

class AuthenticationService extends BaseAuthenticationService
{
/**
* Determines if we have to regenerate session id.
* @access protected
* @var boolean
*/
protected $regenerateId = false;
/**
* Array with columns containing login and password in personalized identity response of Adapter.
* If not empty, must contain "login" and "password" keys.
* @access protected
* @var array
*/
protected $modelArrayColumns = array();
/**
* Array with columns containing login and password in default identity response of Adapter.
* If not empty, must contain "login" and "password" keys.
* @access protected
* @var array
*/
protected $defaultColumns = array();

// @Override
public function authenticate(AdapterInterface $adapter = null)
{
if (!$adapter) {
if (!$adapter = $this->getAdapter()) {
throw new \Exception('An adapter must be set or passed prior to calling authenticate()');
}
}
if($this->hasIdentity())
{
$identity = $this->getIdentity();
// if some of fields is empty, put '.' - otherwise DbTable will return a RuntimeException
if(!isset($identity[$this->getIdentityColumn('login')])) $identity[$this->getIdentityColumn('login')] = '.';
if(!isset($identity[$this->getIdentityColumn('password')])) $identity[$this->getIdentityColumn('password')] = '.';
if(!isset($identity['signature'])) $identity['signature'] = '.';
if(!isset($identity['timeout'])) $identity['timeout'] = '.';
$adapter->setFirstLogin(false);
$adapter->setIdentity($identity[$this->getIdentityColumn('login')]);
$adapter->setCredential($identity[$this->getIdentityColumn('password')]);
$adapter->setSessionFingerprinting($identity['signature']);
$adapter->setSessionLimit($identity['timeout']);
}
$result = parent::authenticate($adapter);
if(Result::SUCCESS == $result->getCode() && ($this->regenerateId || time()%2 == 0))
{
session_regenerate_id(true);
}
return $result;
}

/**
* Setters.
*/
public function setRegenerateId($r)
{
$this->regenerateId = $r;
}
public function setModelArrayColumns($col)
{
$this->modelArrayColumns = $col;
}
public function setDefaultColumns($col)
{
$this->defaultColumns = $col;
}

/**
* Gets identity column. If $modelArray was set in Adapter, the columns must correspond to it.
* @access protected
* @param String $field Field name.
* @return String Default field name or $modelArray's field name.
*/
protected function getIdentityColumn($field)
{
if(array_key_exists($field, $this->modelArrayColumns)) return $this->modelArrayColumns[$field];
return $this->defaultColumns[$field];
}
}


Le code n'a pas l'air d'être compliqué. Dans la méthode surchargée, authenticate(), on vérifie d'abord si une identité existe pour l'utilisateur donné. Si c'est le cas, on récupère le login, le mot de passe, le temps d'expiration de la session et la signature. Ensuite on passe ces données à l'Adapter. Ensuite on exécute l'authentification. Si son résultat est correct, de temps en temps, on régenère l'identifiant de la session.

Salière en Zend Framework 2
La salière doit améliorer la sécurité du mot de passe stocké. Elle intervient d'abord au moment où l'utilisateur définit son mot de passe (par exemple, pendant l'inscription au site). Ce dernier est alors sauvegardé "salé" dans la base de données. Cela veut dire que le hash n'est pas le résultat du SHA1 sur le mot de passe saisi. Il est le résultat du SHA1 sur le mot de passe saisi enrichi avec des données générées par la salière. En ocurrence, ce sera la date de l'enregistrement.

Cette technique rajoute un niveau de sécurité supplémentaire, très utile dans la défense contre les attaques du type brute force (par exemple avec l'utilisation des rainbow tables).

Regardons comment se présente le code de la salière :

namespace Application\Auth;

class SaltCellar
{

/**
* Protected arrays used to salt a password.
* @access protected
* @type array
*/
protected
$years = array(),
$months = array(),
$days = array();

/**
* Constructor used to fill up protected $_years, $_months and $_days arrays.
* @access public
* @param array $data Data to protected arrays.
* @return void
*/
public function __construct($data)
{
if(isset($data['years'])) $this->years = $data['years'];
if(isset($data['months'])) $this->months = $data['months'];
if(isset($data['days'])) $this->days = $data['days'];
}

/**
* Set new years
* @access public
* @params array $years Years data.
* @return void
*/
public function setYears($years)
{
$this->years = $years;
}

/**
* Set new months
* @access public
* @params array $months Months data.
* @return void
*/
public function setMonths($months)
{
$this->months = $months;
}

/**
* Set new days
* @access public
* @params array $days Days data.
* @return void
*/
public function setDays($das)
{
$this->days = $days;
}

/**
* Gets salt of passed date.
* @access public
* @params string $date Date.
* @return string String with the salt.
*/
public function getSalt($date)
{
$date = strtotime($date);
$year = date('Y', $date);
$month = date('n', $date);
$day = date('j', $date);

return $this->days[$day].$this->months[$month].$this->years[$year];
}

/**
* Sets a hash used as an user password.
* @access public
* @params array $elements Array with elements used to generate the new password.
* @params int $month Number representation of the current month.
* @return string New password.
*/
public function setHash($elements, $month)
{
if($month%2 == 0) {
$hash = $elements['mdp'].$elements['login'][0].$elements['salt'];
}
else {
$hash = $elements['mdp'].$elements['login'][mb_strlen($elements['login'], 'UTF-8')-1].$elements['salt'];
}
return $hash;
}

/**
* Getter for login letter used by setHash($elements, $month) to generate a new password.
* @access public
* @param string $login User login.
* @param int $month Month of user registration.
* @return string Bean used in the new password.
*/
public function getSaltBean($login, $month)
{
if($month%2 == 0) {
$bean = $login[0];
}
else {
$bean = $login[mb_strlen($elements['login'], 'UTF-8')-1];
}
return $bean;
}
}


Au tout début de cette classe, on passe en paramètre les valeurs possibles pour le jour, le mois et l'année du sel. Ensuite, on récupère le sel avec la méthode getSalt(). Ce résultat sera utilisé par la fonction setHash() pour générér le mot de passe sallé et le sauvegarder dans la base.

Bien sûr, il s'agit ici d'un exemple simplifié de la salière. On peut utiliser un code plus complexe, sur les données plus variables que la date d'enregistrement. Cet exemple simplifié est là juste pour expliquer le concept et permettre une implémentation rapide dans le système d'authentification Zend Framework 2.

Authentication personnalisée en Zend Framework 2
Maintenant, après avoir écrit le code pour les trois composants de l'authentification, on peut passer au code du contrôleur qui s'occupera de vérifier l'utilisateur. Le voici :

namespace Application\Controller;

use Zend\Mvc\Controller\AbstractActionController;
use Zend\View\Model\ViewModel;
use Zend\Authentication\Result;
use Zend\Authentication\Storage\Session;

use Application\Auth\Adapter;
use Application\Auth\AuthenticationService;
use Application\Auth\SaltCellar;

class AuthController extends AbstractActionController
{
protected $saltData = array(
'years' => array(2011 => 'teg', 2012 => 'e', 2013 => 'qze'),
'months' => array(1 => 'fsq', 2 => '02', 3 => 'q', 4 => 'gdsg', 5 => '(3339',
6 => 'fdERs', 7 => 'FDS45$q', 8 => '[sxdi', 9 => 'Fdsdf', 10 => 'FDSDfeh', 11 => 'FDFDSFSDFSs',
12 => 'FDSFDSF5423Q'
),
'days' => array(1 => 'vcxv', 2 => 'gfvc"', 3 => 'RG', 4 => 'FDSFvcxz', 5 => 'gfdgfg', 6 => 'vd', 7 => 'ret', 8 => 'dfs:', 9 => './DF./DSF',
10 => 'flgdoh', 11 => 'cvxcryk', 12 => 'VVVv', 13 => 'tokykoh', 14 => 'fglfljutj', 15 => 'yujkh,', 16 => 'dnnbi', 17 => 'fdvb;mfdg', 18 => '_JS2---2UIne', 19 => 'fhopsdf', 20 => '43IT54IG9,nvb', 21 => '34594TK?GD',
22 => 'jlhjlteyrt', 23 => 'Fdvbb,n', 24 => 'bvcgjytutuy', 25 => 'Fdbvcbv,nb,', 26 => 'ythgbdfgfdg', 27 => 'vcgfyty', 28 => 'Gdfsgfdg48', 29 => 'fdsgtree', 30 => 'fgpdtdpoytre99', 31 => 'FDSP04Rofgfdng'
)
);
protected $fingerHash = array('start' => 'st$^ar', 'end' => 'tend');
protected $model = array('login' => 'login_admin', 'password' => 'password_admin',
'mail' => 'vail_admin', 'role' => 'role_admin', 'access' => 'access_admin'
);
protected $serializedCols = array('access');
protected $defaultColumns = array('login' => 'login_admin', 'password' => 'password_admin');
protected $modelColumns = array('login' => 'login', 'password' => 'password');

/**
* Log user.
*/
public function loginAction()
{
// Set up the authentication adapter
$authAdapter = new Adapter($this->getServiceLocator()->get("zenddbadapteradapter"), "admin", $this->defaultColumns['login'], $this->defaultColumns['password'], "SHA1(?)", "created_admin", true);
$authAdapter->setSerializedCols($this->serializedCols);
$authAdapter->setIdentity('bartosz');
$authAdapter->setCredential('bartosz');
$authAdapter->setSaltCellar(new SaltCellar($this->saltData));
$authAdapter->setModelArray($this->model);
$authAdapter->setFingerHash($this->fingerHash);
$authAdapter->setSessionTimeout(1800); // 30 minutes

// instantiate the authentication service
$auth = new AuthenticationService();
$auth->setStorage(new Session('front'));
$auth->setModelArrayColumns($this->modelColumns);
$result = $auth->authenticate($authAdapter);

switch($result->getCode())
{
// identity doesn't exist
case Result::FAILURE_IDENTITY_NOT_FOUND:
echo "Identity not found";
break;
// invalid credentials
case Result::FAILURE_CREDENTIAL_INVALID:
echo "Credentials invalid";
break;
// OK
case Result::SUCCESS:
echo "OK";
break;
// default action;
default:
echo "Default";
break;
}
if($result->isValid())
{
echo "Logged correctly";
}
else
{
echo "Logged incorrectly";
}
die();
}

/**
* Checks user by storage data.
*/
public function checkAction()
{
$authAdapter = new Adapter($this->getServiceLocator()->get("zenddbadapteradapter"), "admin", $this->defaultColumns['login'], $this->defaultColumns['password'], "SHA1(?)", "created_admin", true);
$authAdapter->setSerializedCols($this->serializedCols);
$authAdapter->setSaltCellar(new SaltCellar($this->saltData));
$authAdapter->setModelArray($this->model);
$authAdapter->setFingerHash($this->fingerHash);
$authAdapter->setSessionTimeout(1800);

$auth = new AuthenticationService();
$auth->setRegenerateId(true);
$auth->setStorage(new Session('front'));
$auth->setModelArrayColumns($this->modelColumns);
$result = $auth->authenticate($authAdapter);
$identity = $auth->getIdentity();

if($auth->hasIdentity())
{
echo "has identity";
}
else
{
echo "hasn't identity";
}
die();
}

}


Le code ne nécessite pas un commentaire très long. Il est bassé sur les setters et getters qui font le travail derrière dans Adapter et AuthenticationService adaptés.

La mise en place d'une authentification personnalisée en Zend Framework 2 est très intuitive. Et la multitude d'Adapters permet de l'adapter à chaque méthode d'identification.
Bartosz KONIECZNY 13-10-2012 12:15 Zend Framework 2
Moi

Développeur d'applications Internet et journaliste passionné par l'adjectif français. Un aigle polonais orienté vers la progression, volant très haut et écoutant du zouk après les matches du foot français.

Vous appréciez mon travail ?

Pour contribuer au développement de ce site, ou pour remercier pour des articles rédigés, vous pouvez faire un don.

Un conseil Doctrine

Comment exécuter une requête WHERE IN() ?

Doctrin2 rend possible l'exécution de la requête contenant la condition IN(). On peut soit construire les requêtes SQL en natif, soit utiliser les expressions. Ici on va voir comment faire cela avec cette deuxième méthode. Dans notre exemple on voudra récupérer les informations correspondant aux tags cachés sous variable $tags.

    $qb = $this->getEntityManager()->createQueryBuilder();
    $qb->add('select', 't.tagId, t.tagName')
    ->add('from', 'PagesNewsBundle:Tags t');
    $qb->add('where', $qb->expr()->in('t.tagName', $tags));
    $query = $qb->getQuery();
    return $query->getResult();