Interface d’administration d’un site CakePHP avec le Composant Auth

Nous souhaitons fournir à notre client un panneau d’administration pour qu’il puisse éditer lui-même le contenu de son site, par exemple un simple blog avec des Articles. Nous nous imposons plusieurs contraintes :

  • L’administration doit être accessible à l’adresse /admin ;
  • Toutes les actions de cet espace d’administration doivent avoir une url commençant par /admin ;
  • L’administration doit avoir un layout différent de celui du site public ;
  • L’accès à l’administration requiert l’authentification d’un administrateur autorisé par un couple login / mot de passe. Nous nous limiterons ici à un seul type (ou rôle) d’utilisateur.

1. Mise en place de l’administration

1.1. Prise en compte des url commençant par /admin

Nous allons indiquer à CakePHP que nous souhaitons préfixer toutes les URL des actions de l’administration par /admin. Cela se fait très simplement en décommentant la ligne 65 du fichier {app}/config/core.php :

64
65
// {app}/config/core.php
Configure::write('Routing.admin', 'admin');

A présent, si nous appelons l’adresse /admin/articles/add dans notre navigateur, cela renvoie sur l’action admin_add que nous définirons dans le Contrôleur ArticlesController.

1.2. Page d’accueil de l’administration

La page d’accueil de notre administration affiche simplement un lien vers la création d’un nouvel article, suivi de la liste des 10 articles les plus récemment publiés. Nous allons donc créer l’action admin_index dans le Contrôleur des Articles :

1
2
3
4
5
6
// {app}/controllers/articles_controller.php
function admin_index()
{
  $articlesRecents = $this->Article->findAll(null, null, 'created DESC', 10);
  $this->set('articlesRecents', $articlesRecents);
}

Nous créons la vue correspondante :

1
2
3
4
5
6
7
8
9
10
11
12
13
// {app}/views/articles/admin_index.ctp
<h2>Les derniers articles</h2>
<a href="/admin/articles/add">Créer un article</a><br/>
<?php
foreach($articlesRecents as $article)
{
  e($html->link(
    $article['Article']['titre'],
    '/admin/articles/edit/'.$article['Article']['id'])
  );
  e('<br/>');
}
?>

Nous pouvons d’ores et déjà nous rendre à l’adresse /admin/articles/index. Nous remarquons deux problèmes : d’une part nous voulons accéder à l’administration via l’URL /admin et non pas /admin/articles/index, et d’autre part la vue que nous venons de créer s’affiche avec le même layout que le site public, alors qu’il est préférable de fournir une mise en page plus légère et plus fonctionnelle pour les actions d’administration.

1.3. Une route pour le point d’entrée dans l’administration

Nous allons créer une nouvelle route pour relier l’adresse /admin à la page d’accueil de notre administration, soit /admin/articles/index.

1
2
3
4
5
6
7
8
9
// {app}/config/routes.php
Router::connect(
  '/admin',
  array(
    'controller' => 'articles',
    'action' => 'index',
    'prefix' => 'admin'
  )
);

Notez le paramètre ‘prefix’.

1.4. Un layout réservé à l’administration

Nous allons maintenant faire en sorte que toutes les pages de l’administration aient un layout différent de celui du site public. Le meilleur endroit pour décider de cela : la méthode beforeRender du Contrôleur général de l’application :

// {app}/app_controller.php
function beforeRender()
{
  if(isset($this->params['prefix']) && $this->params['prefix'] == 'admin')
  {
    $this->layout = 'admin_default';
  }
}

Et nous créons un layout réservé à l’administration à cet emplacement : {app}/views/layouts/admin_default.ctp.

// {app}/views/layouts/admin_default.ctp
<?php e($html->docType('html4-strict')); ?>
<html>
<head>
  <?php e($html->charset('iso-8859-1')); ?>
</head>
<body>
  <h1>Administration</h1>
  <div id="main">
    <?php
    // Message d'erreur éventuel envoyé par Auth
    $session->flash('auth');
 
    // Autre message éventuel
    $session->flash();
 
    // Contenu
    e($content_for_layout);
    ?>
  </div>
</body>
</html>

Cette fois, si nous nous rendons à l’adresse /admin, nous arrivons bien sur la page d’accueil de l’administration, et cette page dispose d’un layout différent que celui du site public. Nous pouvons mettre en place l’authentification des utilisateurs autorisés avec le Composant Auth.

2. L’authentification

2.1. Gestion des comptes utilisateur

Nous commençons par créer la table qui va contenir les comptes utilisateur autorisés à accéder à l’administration. Nous allons en créer un par défaut, sinon nous ne pourrons jamais nous connecter à l’administration.

CREATE TABLE `users` (
`id` smallint(5) UNSIGNED NOT NULL AUTO_INCREMENT,
`login` varchar(16) NOT NULL,
`password` char(40) NOT NULL,
`disabled` tinyint(1) UNSIGNED NOT NULL,
`created` datetime NOT NULL,
PRIMARY KEY  (`id`)
) ENGINE=MyISAM;

Les champs de la table sont les suivants :

  • id : clé primaire ;
  • login : identifiant de l’administrateur, pouvant comporter jusqu’à 16 caractères ;
  • password : résultat du passage de la clé de sécurité suivie du mot de passe de l’administrateur par une méthode de hachage (SHA1 par défaut, qui produit toujours une chaine de 40 caractères) ;
  • disabled : indique si l’utilisateur est bloqué (1) ou non (0) ;
  • created : date de création du compte. Géré par CakePHP.
INSERT INTO `users` (`id`, `login`, `password`, `disabled`, `created`)
VALUES (1, 'admin', SHA1(6a10cdde80fb56150efda09365f91579ea74a944admin), 0, NOW());

Nous insérons un administrateur dont le login est admin et le mot de passe admin. Remarquez l’utilisation de la fonction SHA1 disponible directement dans MySQL, à laquelle nous passons une chaine de caractère composée de la clé de sécurité (que l’on peut trouver dans le fichier {app}/config/core.php aux alentours de la ligne 150) et du mot de passe de l’administrateur. Tout ce processus sera ensuite entièrement géré par CakePHP, mais il nous faut bien un premier compte pour se connecter à l’administration.

Maintenant que la table users est créée, nous avons besoin du Modèle User et du Contrôleur UsersController :

1
2
3
4
5
// {app}/models/user.php
class User extends AppModel
{
  var $name = 'User';
}
1
2
3
4
5
// {app}/controllers/users_controller.php
class UsersController extends AppController
{
  var $name = 'Users';
}

2.2. Initialisation du Composant Auth

CakePHP fournit un Composant dédié au processus d’authentification utilisateur : Auth. Ce Composant se charge d’autoriser ou de refuser l’accès à certaines actions en fonction de critères que nous allons lui indiquer, et de rediriger un utilisateur non autorisé sur un formulaire de connexion. Auth se charge également de gérer la fonction de connexion et de déconnexion à l’administration.

Dans la mesure où l’administration concerne des actions dans chaque Contrôleur de notre application, nous allons gérer les autorisations dans le Contrôleur général. Comme la restriction d’accès doit se faire avant tout autre traitement, nous plaçons notre logique dans la méthode beforeFilter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// {app}/app_controller.php
class AppController extends Controller
{
  var $helpers = array ('Html', 'Text', 'Form');
 
  var $components = array('Auth');
 
  function beforeFilter()
  {
    if(isset($this->Auth))
    {
      $this->Auth->userModel = 'User';
      $this->Auth->fields = array('username' => 'login', 'password' => 'password');
      $this->Auth->userScope = array('User.disabled' => 0);
      $this->Auth->loginAction = '/users/login';
      $this->Auth->loginRedirect = '/admin/articles';
      $this->Auth->loginError = "Identifiant ou mot de passe incorrects.";
      $this->Auth->logoutRedirect = '/';
      $this->Auth->authError = "Vous n'avez pas accès à cette page.";
      $this->Auth->autoRedirect = true;
      $this->Auth->authorize = 'controller';
 
      if((empty($this->params['prefix']) || $this->params['prefix'] != 'admin') && $this->action != 'login')
      {
        $this->Auth->allow();
      }
    }
  }
 
  function isAuthorized()
  {
    return true;
  }
 
  function beforeRender()
  {
    if(isset($this->params['prefix']) && $this->params['prefix'] == 'admin')
    {
      $this->layout = 'admin_default';
    }
  }
}

Nous avons ici volontairement défini toutes les variables possibles du Composant Auth. Quelques explications :

  • $this->Auth->userModel = 'User';
    Nom du Modèle qui gère les comptes utilisateur.
  • $this->Auth->fields = array('username' => 'login', 'password' => 'password');
    Nous indiquons au Composant les champs du Modèle qui doivent être vérifiés lors d’une identifiation, ici login et password.
  • $this->Auth->userScope = array('User.disabled' => 0);
    Nous pouvons ici définir une condition supplémentaire pour valider une identification : dans notre cas nous ne validons pas un utilisateur dont le compte est bloqué.
  • $this->Auth->loginAction = '/users/login'; ou $this->Auth->loginAction = array('controller' => 'users', 'action' => 'login');
    Nom du contrôleur et de l’action qui va afficher le formulaire de connexion.
  • $this->Auth->loginRedirect = '/admin/articles/index'; ou $this->Auth->loginRedirect = array('controller' => 'articles', 'action' => 'index', 'prefix' => 'admin');
    Si l’utilisateur a essayé d’atteindre une page nécessitant une authentification, il est aussitôt redirigé vers le login mais l’adresse de la page refusée est gardée dans la session. Si l’utilisateur se connecte, il est renvoyé vers l’adresse connue dans la session, sinon il est renvoyé sur la page définie ici.
  • $this->Auth->loginError = "Identifiant ou mot de passe incorrects.";
    Message d’erreur affiché si le couple login / mot de passe saisi dans le formulaire de connexion n’est pas connu.
  • $this->Auth->logoutRedirect = '/';
    Page de destination en cas de déconnexion volontaire, dans notre cas la page d’accueil du site public.
  • $this->Auth->authError = "Vous n'avez pas accès à cette page.";
    Message d’erreur affiché en cas de tentative d’accès à une page qui nécessite une authentification.
  • $this->Auth->autoRedirect = true;
    Définit si Auth doit automatiquement rediriger sur $this->Auth->loginRedirect ou non en cas d’identification correcte. Cela peut être utile de mettre cette variable à false si nous avons un traitement à effectuer juste après la connexion (gestion d’un cookie ou autre). Ce traitement devra se faire dans l’action login du Contrôleur UsersController.
  • $this->Auth->authorize = 'controller';
    Lorsque cette variable vaut ‘controller’, Auth va chercher une méthode isAuthorized dans le AppController et/ou dans le Contrôleur en cours. Cette méthode pourra accueillir des traitements supplémentaire pour déterminer si l’utilisateur enregistré en session a bien le droit d’aller plus loin. Elle doit renvoyer true ou false.

En l’état, toutes les actions sont bloquées, sauf /users/login et /users/logout (merci à Avairet pour avoir soulevé le problème). Nous définissons donc les critères qui ne déclenchent pas une demande d’authentification.

1
2
3
4
if((empty($this->params['prefix']) || $this->params['prefix'] != 'admin') && $this->action != 'login')
{
  $this->Auth->allow();
}

Si la variable $this->params['prefix'] n’existe pas ou qu’elle ne vaut pas ‘admin’, et que l’action demandée n’est pas le login, alors on autorise l’accès.

2.3. Les actions login et logout

Dans le Contrôleur UsersController :

// {app}/controllers/users_controller.php
function login()
{
  $this->layout = 'admin_default';
}

Nous définissons uniquement le layout, tout le traitement sera pris en charge par le Composant (vérification du couple login / mot de passe).

La vue correspondante :

1
2
3
4
5
6
7
8
// {app}/views/users/login.ctp
$this->pageTitle = "Identification requise";
 
// Formulaire de connexion
e($form->create('User', array('action' => 'login')));
e($form->input('login', array('label' => 'Identifiant :')));
e($form->input('password', array('label' => 'Mot de passe :')));
e($form->end("Connexion"));

Voyons l’action de déconnexion :

1
2
3
4
5
6
// {app}/controllers/users_controller.php
function logout()
{
  $this->Session->setFlash("Vous êtes maintenant déconnecté.");
  $this->redirect($this->Auth->logout());
}

Pierre-Emmanuel Fringant

Commentaires

Encore un article pertinent et clair !
Juste ce dont j’avais besoin ce jour… quelle coïncidence !

Bon, juste quelques questions quand même, parce que mes test n’ont pas été concluants :

1) le modèle utilisé doit-il forcément s’appeler “User” ?

2) peut-on avoir les “loginAction” et “loginRedirect” dans des contrôleurs différents ?

3) ton accueil principal de l’admin est en fait lié à un contrôleur métier “Articles”, mais j’ai besoin de faire un accueil Admin général, indépendant de toute action d’un contrôleur métier. Dans cet accueil général je souhaiterai avoir le formulaire de login si l’utilisateur n’est pas connecté, puis le menu de mon interface d’admin (avec cette fois des liens vers des actions admin de mes contrôleurs métiers) quand l’authentification est validée. est-ce possible ?

1) le modèle utilisé doit-il forcément s’appeler “User” ?
Pas du tout. Il suffit de définir dans le AppController quel modèle doit être utilisé :
$this->Auth->userModel = ‘Exemple’;
$this->Auth->fields = array(‘username’ => ‘champ1’, ‘password’ => ‘champ2’);

2) peut-on avoir les “loginAction” et “loginRedirect” dans des contrôleurs différents ?
Je n’ai pas testé mais je pense que oui, il suffit là aussi de l’indiquer à Auth dans le AppController.
3) ton accueil principal de l’admin est en fait lié à un contrôleur métier “Articles”, mais j’ai besoin de faire un accueil Admin général, indépendant de toute action d’un contrôleur métier.
Aucun problème, il suffit de créer un Contrôleur pour le menu (MenusController par exemple) qui contient une seule action vide admin_index, et une vue dans views/menus/admin_index.ctp qui affiche le menu de l’admin. Il ne reste ensuite qu’à changer la route /admin pour qu’elle pointe sur /admin/menu/index, et changer la ligne :
$this->Auth->loginRedirect = ‘/admin/users/menu’;
en :
$this->Auth->loginRedirect = ‘/admin/menus/index’;
dans le AppController.

Merci pour tes réponses. J’avais bien analysé les choses et elles ne me surprennent pas… Cependant, cela ne fonctionne pas chez moi.

Le plus bizarre, c’est que je suis redirigé vers ma page de login, même quand je saisis une url sans le préfix “admin” ?!

Voilà ce que j’ai mis dans Routes.php :

Router::connect(’/admin’, array(’controller’ => ‘admins’, ‘action’ => ‘index’, ‘prefix’ => ‘admin’));

Oui, je sais, le nom de mon contrôleur général d’admin n’est pas très joli (admins), mais bon cela ne devrait pas gêner pour les url “non-admin”.

Tant que je ne suis pas loggué, toute page demandée (même sans le préfix admin) m’envoie automatiquement sur la page de login !?
Ainsi, si je lance mon site à la racine : “http://www.monsite.com/”,
je suis redirigé automatiquement sur “http://www.monsite.com/admins/login”

Est-ce que cela peut venir de : “$this->Auth->authorize = ‘controller’;” ?

Tu as entièrement raison.

En réalité la fonction isAuthorized n’est appelée que si l’utilisateur est déjà autorisé. Elle sert à ajouter des traitements pour savoir si on autorise ou pas l’utilisateur enregistré à continuer. Il faut donc placer la logique de déclenchement du composant Auth directement dans le beforeFilter.
L’article a été mis à jour.
Merci pour ta perspicacité !

Il n’y a pas de quoi ;o) Je suis un peu opiniâtre quand je ne comprends pas quelque chose…

J’avais commencé à réfléchir à $this->Auth->allow() ou $this->Auth->deny() en regardant à nouveau en détail le Cookbook au sujet du Composant Auth et je me disais bien que cette méthode isAuthorized ne servait à rien en l’état…

Merci en retour de ta confiance et de ta réactivité pour mettre à jour le tutoriel.

Je passe aux Acos/Aros maintenant…

Pour ceux qui veulent faire plus “PHP 5″ et éviter de surcharger de lignes de code la méthode beforeFilter() de AppController (on aura peut-être besoin d’y ajouter d’autres logiques…), j’ai créé une méthode privée “setAuthStuff()” qui contient la logique du Component Auth et qui est donc simplement appelée par beforeFilter().

Je garde à l’esprit que je pourrais ensuite affiner mes droits d’accès via la méthode isAuthorized(), que je déclarerai dans le(s) contrôleur(s) concerné(s).

Enfin, je n’utilise pas $this->params['prefix'] dans les tests, mais $this->params['admin'] qui peut-être false ou true… mais bon, là je chipote !

“je n’utilise pas $this->params[’prefix’] dans les tests, mais $this->params[’admin’] qui peut-être false ou true”
> Attention ça ne marchera pas pour le menu de l’administration car le Routeur, via la fonction connect(), ne renvoie que le prefix = ‘admin’ et pas admin = true.

Ah bon ? Pourtant, tous mes var_dump dans beforeFilter semblaient me renvoyer un “admin = true” lorsque j’étais dans une page d’admin… mais effectivement, j’ai quelques soucis pour que mon process d’authentification fonctionne parfaitement, peut-être est-ce du à celà ?

Ok, je viens de vérifier et effectivement, le “$params[admin] = true” n’existe que si l’url contient ‘admin’… mais alors quelle différence y a-t-il entre “prefix = ‘admin’ ” et “admin = true ” ?

Ca marche ! Enfin, j’ai le comportement adéquat et mon authentification fonctionne parfaitement. Je suis donc revenu à $this->params['prefix'] pour tester le layout et le Auth->allow() !

A l’occasion, si tu comprends l’intérêt du $params['admin']…

Je reviens sur ce processus d’Authentification, car j’ai un souci lorsque je crée de nouveaux utilisateurs dans mon back-office : je souhaite valider le mot de passe saisi (vide, minLength…) et afficher un message d’erreur approprié, or il semble que le Component Auth ajoute automatiquement le “security salt” dans l’input qui correspond au mot de passe. Du coup, si une autre règle de validation ne passe pas, au réaffichage de mmon formulaire je vois apparaître la string de hashage dans le champ !?
As-tu une astuce pour valider les mots de passe et afficher des messages d’erreur adéquat sans réafficher le mot de passe, ni le hash ?

Dans le formulaire de création d’un utilisateur, utilise un autre nom de champ pour le mot de passe que celui de ta table. Si la validation passe, tu l’ajoutes à la main à $this->data sous le vrai nom du champ mot de passe, et tu sauves.

Merci, cela fonctionne pour l’affichage du message d’erreur et cela ne réaffiche pas le hash dans le champ. Mais la sauvegarde est mauvaise : il ne me crée pas le SHA1 du mot de passe saisi, mais me met une série de “”…
Peut-être que je m’y prends mal ? Voilà ce que j’ai mis dans mon Modèle :

public function beforeSave() {
$this->Administrateur->data['passe'] = $this->Administrateur->data['mot_passe'];
unset($this->Administrateur->data['mot_passe']);
return true;
}

$this->Administrateur->data[’passe’] = $this->Auth->password($this->Administrateur->data[’mot_passe’]);

Désolé, cela ne fonctionne pas…

“Fatal error: Call to a member function password() on a non-object”

En fait c’est normal, dans mon modèle, il ne connait pas le component Auth !

Je suis allé trop vite :
$this->Administrateur->data[’passe’] = $this->Administrateur->Auth->password($this->Administrateur->data[’mot_passe’]);

Désolé, c’est toujours pas bon !

“Undefined property: Administrateur::$Administrateur ”

et

“Trying to get property of non-object”

Mais tout cela me semble logique : on ne peut pas accéder à un component depuis le modèle, en tout cas pas de manière aussi simple…

Arf, je n’avais pas lu que tu faisais cela dans le beforeSave… Fais plutôt tout ça dans le contrôleur. Si tu tiens vraiment à le faire dans le beforeSave du modèle, il faut que tu hash le password à la main :
$this->data['passe'] = sha1(Configure::read(’Security.salt’).$this->data['mot_passe']);

Ben oui j’avais bien précisé être dans beforeSave() ;o))

Je ne souhaite pas faire trop de cuisine de validation dans le contrôleur, j’estime que ce n’est pas le meilleur endroit pour le faire (sinon, pourquoi mettre le tableau $validate dans le modèle ?).

Bon, même avec ta solution sha1, cela ne fonctionne pas ! Je n’ai plus de message d’erreur PHP, mais le passe sauvegardé est encore une suite de “” !?

Mes antislashs ne passent pas… donc je disais que la chaîne sauvegardé était une suite de \ \ \ (antislash0 antislash0 antislash0)

Bon, j’ai réussi en faisant dans mon contrôleur :

[code]
$errors = $this->validateErrors($this->Administrateur);
if ($errors === false) {
$this->data['Administrateur']['passe'] = $this->Auth->password($this->data['Administrateur']['mot_passe']);
}
$this->Administrateur->create();
if ($this->Administrateur->save($this->data))
[/code]

La description dans l’Api de Controller::validateErrors() n’est pas claire et je ne pensais pas qu’on pouvait faire cette vérif avant $this->Model->save()

PB DE DECONNEXION !

Superbe tutoriel. D’autant plus utile que l’information est si difficile à trouver à travers tous ces blogs de bakers.

Je me suis construit une mini appli en suivant pas à pas ton article. Tout fonctionne à merveille sauf la déconnexion. Impossible de réobtenir la page d’accueil par défaut.

Dans le beforeFilter, j’ai bien spécifié :
$this->Auth->logoutRedirect = ‘/’;

et ma fonction logout est la même que la tienne.

Mais j’obtiens une page blanche avec le message de déconnection (de la fonction logout), mais avec un plantage de la redirection :

Vous êtes maintenant déconnecté.

Warning (2): Cannot modify header information - …

Une expérience à ce sujet de ton côté ?

Ce genre d’erreur vient souvent d’un espace qui traîne avant ou après un < ?php ... ?>

Est-ce que tu obtiens aussi cette erreur quand tu vas à la racine ‘/’ directement, sans passer par la déconnexion ?

BINGO ! J’ai trouvé.

Tout d’abord, pas d’erreur en retournant directement à la racine.

Et j’ai tourné en rond pendant 1 heure ce matin, avant de réaliser. C’était dû à un stupide problème d’internationalisation.

En effet, j’ai pris l’habitude d’utiliser au maximum le script bake pour produire mon code. Je laisse les textes tels quels dans les fichiers produits et je traduis tout dans /locale/fre/LC_MESSAGES/default.po.
C’est très pratique.

Donc, dans la fonction logout, j’ai écrit :

$this->Session->setFlash(__('Logged out.'));

que j’ai traduit dans le fichier .po

Mal m’en a pris, c’est ça qui a m’a planté. Auth ne sait apparemment pas gérer ça, et renvoie la traduction AVANT le layout, et non pas DANS le layout, à l’endroit du <?php $session->flash('auth'); ?>

Morale de l’histoire :
Toujours reproduire fidèlement un tuto, au caractère près. Une fois que ça marche, on peut s’amuser à modifier. Bien fait pour moi !

Participez