I. Introduction▲
symfony est un framework PHP assez répandu et utilisé par de grandes firmes, comme Yahoo pour son service Bookmarks. Vous pouvez en consulter la liste sur le site de symfony.
Cependant, la gestion des utilisateurs et de la sécurité n'est pas parfaite ni très poussée dans la version de base du framework. En effet, de telles fonctionnalités requièrent généralement une base de données, alors que symfony n'est pas prévu pour en avoir besoin pour tous les usages. C'est pourquoi toute cette gestion avancée a été déportée dans un plug-in, sfGuardPlugin à la base, pour l'ORM Propel, qui fut présent dès les débuts de symfony. Cependant, Sensio Labs, éditeur de symfony, a débuté le développement de Doctrine, un autre ORM, maintenant conseillé pour symfony. Un port du plug-in a donc eu lieu, donnant naissance à sfDoctrineGuardPlugin. D'abord un port, celui-ci a continué d'avancer, étant, au moment de la rédaction, à la version 5.0.0, alors que la version Propel est restée à la 4.0.1.
Dans cet article, nous utiliserons symfony en version 1.4.8 et sfDoctrineGuardPlugin en version 5.0.0, les dernières en ce moment. Il devrait rester valable pour toute la série 1.4.x de symfony et 5.x de sfDoctrineGuardPlugin. Je partirai du principe que vous connaissez les bases de symfony et de la gestion des droits avec ce framework, un peu l'ORM Doctrine. Il n'est pas nécessaire de déjà connaître sfDoctrineGuardPlugin, nous n'écrirons aucune ligne d'aucun module qui l'appelle directement.
Les deux plug-ins partageant une même base, il est probable que le contenu de ce tutoriel s'applique aisément à sfGuardPlugin. Cela n'est pour autant pas garanti, vous devrez probablement modifier un peu les fichiers proposés.
II. Installation▲
sfDoctrineGuardPlugin ne peut pas être inclus d'office dans symfony, car il a besoin d'une base de données pour fonctionner. Il est donc nécessaire de l'installer.
Commençons par le télécharger. Plusieurs techniques sont possibles : par la ligne de commande, par checkout Subversion ou manuellement.
symfony plugin:install sfDoctrineGuardPlugin
svn co http//svn.symfony-project.com/plugins/sfDoctrineGuardPlugin/trunk plugins/sfDoctrineGuardPlugin
Manuellement, vous devrez télécharger l'archive du plug-in sur sa page et la décompresser dans le dossier plugins/sfDoctrineGuardPlugin de votre projet (seul le dossier sfDoctrineGuardPlugin-5.0.0 contenu dans cette archive nous intéresse, le fichier package.xml ne servant que pour l'installation en ligne de commande, il est requis par le module d'installation PEAR que symfony utilise).
Ensuite, il faut activer ce plug-in dans symfony. Pour ce faire, ouvrez le fichier de configuration ProjectConfiguration.class.php de votre projet (dans le dossier config). Vous y trouverez une méthode setup() avec, généralement, un plug-in déjà activé, votre ORM. Cette fonction ressemble donc à ceci :
Complétez cette fonction avec le nom du plug-in. Deux méthodes sont possibles : soit ajouter un paramètre à enablePlugins, soit ajouter un appel à cette même méthode.
public function setup
()
{
$this
->
enablePlugins('
sfDoctrinePlugin
'
);
$this
->
enablePlugins('
sfDoctrineGuardPlugin
'
);
}
public function setup
()
{
$this
->
enablePlugins('
sfDoctrinePlugin
'
,
'
sfDoctrineGuardPlugin
'
);
}
Vérifiez bien que l'ORM Doctrine est bien aussi chargé ! Sinon, sfDoctrineGuardPlugin ne pourra pas fonctionner ! Vérifiez aussi que vous n'utilisez pas Propel, auquel cas il vous faudra utiliser sfGuardPlugin.
Maintenant, il faut impérativement régénérer votre modèle. Sinon, vous aurez droit à des erreurs 500 indiquant qu'une classe n'a pas été trouvée lorsque vous tenterez d'utiliser sfDoctrineGuardPlugin. Pour ce faire, cette ligne dans un prompt devrait suffire :
symfony doctrine:build --all --and-load
Notez qu'elle modifiera aussi votre base de données, rendant de fait les modèles utiles.
Ceci clôt la première partie de l'installation, celle qui est commune à tous les plug-ins (ou presque). Maintenant, il faut ajouter les modules que fournit sfDoctrineGuardPlugin, contenant notamment les formulaires de connexion et les interfaces d'administration auto générées. Aussi, un peu de configuration supplémentaire est requise pour que le tout fonctionne bien, notamment la fonctionnalité "Se souvenir de moi", et que symfony utilise bien les formulaires de sfDoctrineGuardPlugin.
Pour toute application de type backend, nécessitant d'éditer les tables du plug-in, activez les modules sfGuardGroup, sfGuardUser et sfGuardPermission. Éditez donc le fichier settings.yml de votre application comme suit :
all
:
.settings
:
enabled_modules
:
[
default, sfGuardGroup, sfGuardUser, sfGuardPermission]
Par contre, dans votre application frontend, seul sfGuardAuth sera nécessaire pour gérer l'authentification :
all
:
.settings
:
enabled_modules
:
[
default, sfGuardAuth]
Vous pouvez additionnellement ajouter l'option "Se souvenir de moi" en ajoutant dans votre fichier filters.yml ce qui suit :
remember_me
:
class
:
sfGuardRememberMeFilter
Maintenant, il faut que symfony pense à utiliser sfDoctrineGuardPlugin pour les formulaires de connexion. À cette fin, ajoutez ces quelques lignes à votre fichier settings.yml :
login_module
:
sfGuardAuth
login_action
:
signin
secure_module
:
sfGuardAuth
secure_action
:
secure
sfDoctrineGuardPlugin ajoute des fonctionnalités, évidemment, à l'objet utilisateur que vous manipulerez dans votre application. Pour que ces ajouts puissent avoir lieu, il suffit d'ajouter une classe en parent de votre myUyser :
class myUser extends
sfGuardSecurityUser
{
}
Si vous ne le faites pas, vous risquez de recevoir une erreur du style Call to undefined method myUser::isAnonymous..
Si le module sfGuardAuth est activé et que vous ne demandez pas explicitement lors de la configuration dans le fichier app.yml que les routes nécessaires ne soient pas crées, deux routes seront automatiquement ajoutées à l'exécution :
sf_guard_signin
:
url
:
/login
param
:
{
module
:
sfGuardAuth, action
:
signin }
sf_guard_signout
:
url
:
/logout
param
:
{
module
:
sfGuardAuth, action
:
signout }
Par défaut, l'action signout renvoie l'utilisateur sur @homepage, vérifiez donc bien qu'une telle route est définie.
Toute dernière étape, définir les applications ou les modules ou les actions à sécuriser. Il suffit de modifier le fichier security.yml au niveau à sécuriser comme suit pour sécuriser toute l'application ou tout un module :
default
:
is_secure
:
true
Vous pouvez activer la sécurisation depuis le fichier security.yml de l'application :
module_a_securiser
:
is_secure
:
true
De même, une action peut être sécurisée sans impacter les autres actions du module, à condition d'utiliser ce genre de code dans le security.yml du module concerné :
action_a_securiser
:
is_secure
:
true
III. Authentification externe▲
sfDoctrineGuardPlugin dispose d'une méthode relativement simple mais limitée de vérification des mots de passe, c'est généralement ce que l'on veut. Remarquez qu'elle ne fait que changer la méthode de vérification des mots de passe, les colonnes associées du modèle de sfDoctrineGuardPlugin ne seront juste plus utilisées, vos utilisateurs devront toujours être en base. Nous verrons un peu plus loin comment dépasser cette limitation.
L'implémentation de la fonction est extrêmement simple : à partir d'un nom d'utilisateur et d'un mot de passe donnés, il suffit de renvoyer un booléen dont la fonction est, ô miracle, de déterminer si le mot de passe associé à l'utilisateur est bien celui attendu. Entre les deux, vous pouvez faire ce que vous voulez : vous connecter à un annuaire LDAP, vérifier dans une base de données externe... ou renvoyer true dans tous les cas (par exemple, si vous voulez faire une démonstration d'une application).
Stockez donc cette méthode dans une classe myLogin, qui sera hébergée dans le dossier lib de votre application sous le nom de myLogin.class.php :
<?php
class
myLogin extends
sfGuardSecurityUser
{
public
static
function
checkPassword($username
,
$password
)
{
if
($username
&&
$password
)
return
true
;
}
}
Réimplémentez donc cette méthode pour qu'elle convienne à vos besoins et desiderata.
Maintenant, il faut demander à sfDoctrineGuardPlugin d'utiliser cette méthode pour connecter vos utilisateurs. Cela se fait très simplement dans le fichier de configuration app.yml de votre application :
all
:
sf_guard_plugin
:
check_password_callable
:
[
myLogin, checkPassword]
Maintenant, étudions un peu ce qui se passe derrière le capot, pourquoi justement cette modification ne suffit pas pour que sfDoctrineGuardPlugin introduise l'utilisateur en base à la connexion.
Tout se passe dans la classe sfGuardValidatorUser, dans l'une des trois méthodes qu'elle implémente : doClean(). Voici ce qu'elle contient, dans les versions 4.0 du plug-in :
protected function doClean($values
)
{
$username
=
isset($values
[
$this
->
getOption('
username_field
'
)]
) ?
$values
[
$this
->
getOption('
username_field
'
)]
:
''
;
$password
=
isset($values
[
$this
->
getOption('
password_field
'
)]
) ?
$values
[
$this
->
getOption('
password_field
'
)]
:
''
;
$allowEmail
=
sfConfig::
get('
app_sf_guard_plugin_allow_login_with_email
'
,
true);
$method
=
$allowEmail
?
'
retrieveByUsernameOrEmailAddress
'
:
'
retrieveByUsername
'
;
// don't allow to sign in with an empty username
if ($username
)
{
// user exists?
if ($username
&&
$user
=
Doctrine::
getTable('
sfGuardUser
'
)->
retrieveByUsername($username
))
{
// password is ok?
if ($user
->
getIsActive() &&
$user
->
checkPassword($password
))
{
return array_merge($values
,
array('
user
'
=>
$user
));
}
}
}
if ($this
->
getOption('
throw_global_error
'
))
{
throw new sfValidatorError($this
,
'
invalid
'
);
}
throw new sfValidatorErrorSchema($this
,
array($this
->
getOption('
username_field
'
) =>
new sfValidatorError($this
,
'
invalid
'
)));
}
La version 5.0.0 ajoute une petite subtilité justement au niveau de la vérification de l'existence en base de l'utilisateur. Cette ligne est remplacée par les suivantes :
if ($username
&&
$user
=
Doctrine::
getTable('
sfGuardUser
'
)->
retrieveByUsername($username
))
if ($callable
=
sfConfig::
get('
app_sf_guard_plugin_retrieve_by_username_callable
'
))
{
$user
=
call_user_func_array($callable
,
array($username
));
}
else {
$user
=
$this
->
getTable()->
retrieveByUsername($username
);
}
Ce qui laisse présupposer qu'il est possible de surcharger uniquement cette partie. Cette partie est absolument non documentée, je n'ai trouvé personne qui avait essayé cette méthode ; mes essais n'ayant pas été fructueux, j'ai préféré utiliser une autre méthode pour arriver à mes fins et oublier cette subtilité.
La dernière partie, vérifiant le mot de passe, appelle effectivement votre fonction de vérification. Cependant, auparavant, cette méthode vérifie que l'utilisateur est en base, ce qui est le comportement le plus normal attendu dans le cas général d'utilisation du plug-in - l'utilisation de l'authentification de base -, qui n'est pas forcément ce qui est voulu (système de SSO, Intranet, etc.).
IV. Remplir automatiquement la table des utilisateurs▲
Le problème, c'est que vous n'avez pas forcément toujours tous vos utilisateurs dans votre base. Et que ce système refuse de tenter d'authentifier des utilisateurs qui n'y sont pas. Que faire ? Réimplémenter un nouveau formulaire qui s'occupe de créer l'enregistrement le cas échéant puis retourne à l'implémentation de base ? Ce n'est pas la voie choisie ici, j'ai préféré utiliser un validateur personnalisé, s'occupant de toute la grasse besogne, y compris l'authentification elle-même (ce qui signifie que vous n'aurez plus besoin du code de la partie précédente sous cette forme, il sera intégré dans le nouveau validateur : vous pourrez très bien y faire appel si vous préférez).
Tout d'abord, il faut utiliser un autre formulaire, héritant de l'actuel mais ajoutant notre validateur. On aurait très bien pu modifier le plug-in lui-même, cette approche étant évitée car très peu propre et nécessite de remodifier le plug-in à chaque mise à jour. Dans la configuration de l'application app.yml, notifions symfony qu'il ne faut pas utiliser le formulaire de base :
all
:
sf_guard_plugin_signin_form
:
myAuthForm
Créons ensuite ce nouveau formulaire, ajoutant notre validateur (lib/myAuthForm.class.php).
<?php
class
myAuthForm extends
sfGuardFormSignin
{
public
function
configure()
{
parent
::
configure();
$this
->
validatorSchema->
setPostValidator(new
sfGuardValidatorCustomUser());
}
}
En fait, il ne fait presque rien : tout est délégué au parent, il ne fait qu'ajouter le validateur personnalisé. Implémentons-le maintenant. Seule la méthode dont le fonctionnement nous ennuyait sera réimplémentée, doClean().
Que faut-il y faire ? Vérifier que l'utilisateur est en base : s'il n'y est pas, il faut l'ajouter avec toutes les informations utiles. Ensuite, vérifier son mot de passe. (1)
Ici, le comportement de la fonction de recherche est beaucoup changé et permet aux utilisateurs de changer de nom d'utilisateur. En effet, la méthode externe nous renvoie un identifiant qui, lui, ne changera jamais et identifiera toujours de manière unique un utilisateur.
Le placement de ce fichier est aussi un peu particulier : modules/sfGuardAuth/lib/sfGuardValidatorCustomUser.class.php. En effet, il vient remplacer des fonctionnalités du module sfGuardAuth, d'où son placement.
<?php
class
sfGuardValidatorCustomUser extends
sfGuardValidatorUser
{
protected
function
doClean($values
)
{
$username
=
isset($values
[
$this
->
getOption('username_field'
)]
) ?
$values
[
$this
->
getOption('username_field'
)]
:
''
;
$password
=
isset($values
[
$this
->
getOption('password_field'
)]
) ?
$values
[
$this
->
getOption('password_field'
)]
:
''
;
// don't allow to sign in with an empty username
if
($username
&&
$password
)
{
// votre méthode de connexion, renvoie $connected si l'utilisateur est connecté
// ainsi que toute la série de variables utilisées si l'utilisateur n'est pas déjà en base
// on récupère notamment $connected et $id
if
($connected
)
{
// récupérons manuellement si l'utilisateur est déjà en base, grâce à son identifiant
$query
=
Doctrine_Core::
getTable('sfGuardUser'
)->
createQuery('u'
)->
where('u.id = ?'
,
$id
);
$user
=
$query
->
fetchOne();
// s'il n'est pas en base, ça ne tardera pas
if
(!
$user
)
{
$user
=
new
sfGuardUser();
$user
->
setId($id
);
$user
->
setFirstName($prenom
);
$user
->
setLastName($nom
);
$user
->
setEmailAddress($email
);
$user
->
setUsername($pseudo
);
$user
->
setIsActive(true
);
$user
->
setIsSuperAdmin(false
);
$user
->
save();
// et l'utilisateur est dans la table !
}
// on renvoie cet objet utilisateur avec les values passées en paramètre, comme l'original
return
array_merge($values
,
array
('user'
=>
$user
));
}
}
// si tout ne s'est pas bien passé (pas de nom d'utilisateur, de mot de passe ou d'enregistrement dans la méthode externe)
// on fait comme sfDoctrineGuardPlugin et on renvoie une belle exception
if
($this
->
getOption('throw_global_error'
))
{
throw
new
sfValidatorError($this
,
'invalid'
);
}
throw
new
sfValidatorErrorSchema($this
,
array
($this
->
getOption('username_field'
) =>
new
sfValidatorError($this
,
'invalid'
)));
}
}
Maintenant, une question reste en suspens : que se passe-t-il si l'utilisateur décide de changer de pseudo ? Ou de nom ? Ou de prénom ? Ou s'il est banni du système ?
V. Mettre à jour les informations des utilisateurs▲
C'est là que l'on remarque l'utilité de référencer les utilisateurs par leur ID, ce qui permet de s'affranchir de leur nom d'utilisateur. Ils pourraient très bien ne pas en avoir que notre système fonctionnerait à merveille.
La méthode employée sera extrêmement simple : il suffit de vérifier que les informations n'ont pas changé. Le cas échéant, l'objet utilisateur sera modifié et envoyé en base de données.
Très légère optimisation de logique : si un utilisateur vient d'être créé, ses paramètres ne peuvent pas avoir changé depuis que les informations ont été récupérées. Si c'était le cas, il faudrait recharger les informations depuis le système d'authentification, ce qui serait extrêmement lourd et foncièrement inutile.
Voici donc la portion de code ajoutée, elle se passe de commentaire supplémentaire :
if($user
->
getFirstName() !=
$prenom
)
{
$user
->
setFirstName($prenom
);
$user
->
save();
}
if($user
->
getLastName() !=
$nom
)
{
$user
->
setLastName($nom
);
$user
->
save();
}
if($user
->
getUsername() !=
$pseudo
)
{
$user
->
setUsername($pseudo
);
$user
->
save();
}
if($user
->
getEmailAddress() !=
$email
)
{
$user
->
setEmailAddress($email
);
$user
->
save();
}
if($user
->
getIsActive() !=
$active
)
{
$user
->
setIsActive($active
);
$user
->
save();
}
Notre validateur ressemble donc au final à ceci :
<?php
class
sfGuardValidatorCustomUser extends
sfGuardValidatorUser
{
protected
function
doClean($values
)
{
$username
=
isset($values
[
$this
->
getOption('username_field'
)]
) ?
$values
[
$this
->
getOption('username_field'
)]
:
''
;
$password
=
isset($values
[
$this
->
getOption('password_field'
)]
) ?
$values
[
$this
->
getOption('password_field'
)]
:
''
;
// don't allow to sign in with an empty username
if
($username
&&
$password
)
{
// votre méthode de connexion, renvoie $connected si l'utilisateur est connecté
// ainsi que toute la série de variables utilisées si l'utilisateur n'est pas déjà en base
// on récupère notamment $connected et $id
if
($connected
)
{
// récupérons manuellement si l'utilisateur est déjà en base, grâce à son identifiant
$query
=
Doctrine_Core::
getTable('sfGuardUser'
)->
createQuery('u'
)->
where('u.id = ?'
,
$id
);
$user
=
$query
->
fetchOne();
// s'il n'est pas en base, ça ne tardera pas
if
(!
$user
)
{
$user
=
new
sfGuardUser();
$user
->
setId($id
);
$user
->
setFirstName($prenom
);
$user
->
setLastName($nom
);
$user
->
setEmailAddress($email
);
$user
->
setUsername($pseudo
);
$user
->
setIsActive(true
);
$user
->
setIsSuperAdmin(false
);
$user
->
save();
// et l'utilisateur est dans la table !
}
else
{
if
($user
->
getFirstName() !=
$prenom
)
{
$user
->
setFirstName($prenom
);
$user
->
save();
}
if
($user
->
getLastName() !=
$nom
)
{
$user
->
setLastName($nom
);
$user
->
save();
}
if
($user
->
getUsername() !=
$pseudo
)
{
$user
->
setUsername($pseudo
);
$user
->
save();
}
if
($user
->
getEmailAddress() !=
$email
)
{
$user
->
setEmailAddress($email
);
$user
->
save();
}
if
($user
->
getIsActive() !=
$active
)
{
$user
->
setIsActive($active
);
$user
->
save();
}
}
return
array_merge($values
,
array
('user'
=>
$user
));
}
}
if
($this
->
getOption('throw_global_error'
))
{
throw
new
sfValidatorError($this
,
'invalid'
);
}
throw
new
sfValidatorErrorSchema($this
,
array
($this
->
getOption('username_field'
) =>
new
sfValidatorError($this
,
'invalid'
)));
}
}
Évidemment, si vous ne récupérez pas certaines informations de votre identificateur externe, vous ne pourrez pas les inventer ; dans ce cas, les passages du code associés pourront être simplement supprimés (et les valeurs par défaut définies par sfDoctrineGuardPlugin seront utilisées).
VI. Gérer les droits▲
Il suffit d'enregistrer l'utilisateur dans un groupe ou l'autre lors de la connexion pour qu'il ait les droits de ce groupe. Vous pouvez aussi ajouter droit par droit, en prenant l'utilisateur comme base ; étant donné que cela complexifie fortement la gestion des droits (il faut alors regarder utilisateur par utilisateur, il ne suffit plus de changer les droits d'un groupe), je déconseille cette méthode. Elle ne sera d'ailleurs pas montrée ici (mais vous pourrez adapter le code très facilement).
Tout d'abord, il vous faut un groupe auquel ajouter les utilisateurs. Dans une application backend, allez voir ce que propose le module sfGuardGroup : http://localhost/project/sfGuardGroup/index ou http://localhost/project/guard/groups, en fonction de votre configuration des routes. Là, retour direct à l'admin generator, bien connu des panels d'administration en tous genres. Créez-y simplement un groupe. Pour qu'il ait un effet, il faut qu'il donne accès à des permissions, cela n'est pourtant pas nécessaire pour le reste du script. Vous pouvez gérer les permissions dans http://localhost/project/sfGuardPermission/index ou http://localhost/project/guard/permissions ; il faudra retourner dans le module précédent pour associer un groupe à une ou plusieurs permissions.
Dans ce bout de code, nous considérerons que le module d'authentification externe renvoie une variable booléenne $r qui, vraie, indique que l'utilisateur fait partie du groupe d'identifiant 42.
if($r
)
{
$perm
=
new sfGuardUserGroup();
$perm
->
setUserId($id
);
$perm
->
setGroupId(42
);
$perm
->
save();
}
Et voilà ! Inutile d'en dire plus, on ne fait que créer l'enregistrement en base qui lie l'utilisateur d'identifiant $id au groupe d'identifiant 42.
Maintenant, une question reste en suspens... Que se passe-t-il si l'utilisateur n'appartient plus à ce groupe lors de sa connexion ? Il serait bon de l'en retirer. Quelle méthode allons-nous utiliser ?
if(!
$r
)
{
$q
=
Doctrine_Query::
create()
->
delete('
sfGuardUserGroup u
'
)
->
where('
u.user_id = ?
'
,
$id
)
->
andWhere('
u.group_id
'
);
$r
->
execute
();
}
Pour plus de raffinement, votre méthode d'authentification externe pourrait vous renvoyer une liste de groupes auxquels l'utilisateur appartient. Il suffit de récupérer les enregistrements de groupe correspondant et de créer les enregistrements. Considérons que sfDoctrineGuardPlugin a à sa disposition l'entièreté des groupes disponibles et que nous récupérons un tableau $groups avec les noms des groupes auxquels il faut associer l'utilisateur.
foreach($groups
as $group
)
{
$q
=
Doctrine_Query::
create()
->
select('
g.id
'
)
->
from('
sfGuardGroup g
'
)
->
where('
g.name = ?
'
,
$group
);
$grp
=
$r
->
fecthOne();
$perm
=
new sfGuardUserGroup();
$perm
->
setUser($user
);
$perm
->
setGroup($grp
);
$perm
->
save();
}
Remarquez les méthodes setUser() et setGroup() au lieu de setUserId() et setGroupId() précédemment : ici, nous avons des objets sfGuardUser et sfGuardGroup, nous pouvons donc les passer en entier en paramètres et non seulement leur identifiant. Ces deux méthodes sont strictement équivalentes.
VII. Conclusion▲
Ce plug-in de symfony, sfDoctrineGuardPlugin, est un plug-in-clé de symfony, il est très fréquent de le voir installé dans un projet tellement ses services peuvent être indispensables. Il ne fait pas tout : le plug-in sfForkedDoctrineApplyPlugin vient répondre à certaines lacunes. En effet, sfDoctrineGuardPlugin ne fait que la gestion des utilisateurs et des droits, ainsi que de la connexion. Pour le reste (inscription, confirmation par email des inscriptions, CAPTCHA lors de l'inscription, redirection après la connexion, etc.). Remarquez aussi que plug-in a proposé avant sfDoctrineGuardPlugin un champ email dans la base des utilisateurs, manque qui n'a été comblé qu'avec la version 5.0.0.
Un tout grand merci à Yogui pour m'avoir poussé à écrire cet article, ainsi qu'à Mahefasoa !