Contrôle d'accès (autorisation)

L'autorisation vérifie qu'un utilisateur dispose de permissions suffisantes, par exemple pour accéder à une ressource précise ou effectuer une action. L'autorisation suppose une authentification préalable réussie, autrement dit que l'utilisateur est connecté.

Installation et prérequis

Dans les exemples, nous utiliserons un objet de la classe Nette\Security\User, qui représente l'utilisateur courant et que vous obtenez en vous le faisant passer par injection de dépendances. Dans les presenters, il suffit d'appeler $user = $this->getUser().

Pour les sites très simples dotés d'une administration où les permissions des utilisateurs ne sont pas différenciées, la méthode déjà connue isLoggedIn() peut servir de critère d'autorisation. Autrement dit : dès qu'un utilisateur est connecté, il a toutes les permissions, et inversement.

if ($user->isLoggedIn()) { // l'utilisateur est-il connecté ?
	deleteItem(); // alors il a la permission d'effectuer l'opération
}

Rôles

Le but des rôles est d'offrir un contrôle plus précis des permissions tout en restant indépendant du nom d'utilisateur. Dès qu'un utilisateur se connecte, un ou plusieurs rôles lui sont attribués, dans lesquels il agira. Les rôles peuvent être de simples chaînes, par exemple admin, member, guest, etc. Ils s'indiquent en deuxième argument du constructeur de SimpleIdentity, sous forme de chaîne ou de tableau de chaînes – de rôles.

Comme critère d'autorisation, nous utiliserons maintenant la méthode isInRole(), qui révèle si l'utilisateur agit dans le rôle donné :

if ($user->isInRole('admin')) { // l'utilisateur est-il dans le rôle admin ?
	deleteItem(); // alors il a la permission d'effectuer l'opération
}

Comme vous le savez déjà, déconnecter l'utilisateur ne supprime pas forcément son identité. La méthode getIdentity() renvoie donc toujours l'objet SimpleIdentity, avec tous les rôles accordés. Nette Framework défend le principe “moins de code, plus de sécurité”, où écrire moins conduit à un code plus sûr. Lors de la vérification des rôles, vous n'avez donc pas besoin de contrôler que l'utilisateur est connecté. La méthode isInRole() travaille avec les rôles effectifs : si l'utilisateur est connecté, elle se fonde sur les rôles indiqués dans l'identité ; s'il ne l'est pas, il a automatiquement le rôle particulier guest (ou les rôles de l'identité invité, si elle est fournie).

Autorisateur

Outre les rôles, nous introduirons les termes ressource et opération :

  • un rôle est une propriété de l'utilisateur : modérateur, éditeur, visiteur, utilisateur enregistré, administrateur…
  • une ressource est une unité logique du site : article, page, utilisateur, entrée de menu, sondage, presenter, …
  • une opération est une activité précise que l'utilisateur peut ou ne peut pas effectuer sur la ressource : voir, modifier, supprimer, voter, …

Un autorisateur est un objet qui décide si le rôle donné a la permission d'effectuer une certaine opération sur une ressource précise. C'est un objet implémentant l'interface Nette\Security\Authorizator avec une seule méthode isAllowed() :

class MyAuthorizator implements Nette\Security\Authorizator
{
	public function isAllowed($role, $resource, $operation): bool
	{
		if ($role === 'admin') {
			return true;
		}
		if ($role === 'user' && $resource === 'article') {
			return true;
		}

		// ...

		return false;
	}
}

Nous ajoutons l'autorisateur à la configuration comme service du conteneur DI :

services:
	- MyAuthorizator

Et voici un exemple d'utilisation. Remarquez que nous appelons cette fois la méthode Nette\Security\User::isAllowed(), et non celle de l'autorisateur : le premier paramètre $role manque donc. Cette méthode appelle MyAuthorizator::isAllowed() successivement pour tous les rôles de l'utilisateur et renvoie true si au moins l'un d'eux a la permission.

if ($user->isAllowed('file')) { // l'utilisateur peut-il faire quoi que ce soit avec la ressource 'file' ?
	useFile();
}

if ($user->isAllowed('file', 'delete')) { // l'utilisateur peut-il effectuer 'delete' sur la ressource 'file' ?
	deleteFile();
}

Les deux arguments sont facultatifs ; la valeur par défaut null signifie n'importe quoi.

Permission ACL

Nette est livré avec une implémentation intégrée de l'autorisateur, la classe Nette\Security\Permission, qui offre au programmeur une couche ACL (Access Control List) légère et souple pour gérer les permissions et les accès. Le travail avec elle consiste à définir des rôles, des ressources et des permissions. Les rôles et les ressources permettent de créer des hiérarchies. Pour expliquer, prenons l'exemple d'une application web :

  • guest : un visiteur non enregistré, qui peut lire et parcourir la partie publique du site, c'est-à-dire lire les articles, les commentaires et voter dans les sondages.
  • registered : un utilisateur enregistré et connecté, qui peut en plus publier des commentaires.
  • admin : il peut gérer les articles, les commentaires et les sondages.

Nous avons défini certains rôles (guest, registered et admin) et évoqué des ressources (article, comment, poll), auxquelles les utilisateurs d'un certain rôle peuvent accéder ou sur lesquelles ils peuvent effectuer certaines opérations (view, vote, add, edit).

Nous créons une instance de la classe Permission et définissons les rôles. Il est possible d'utiliser ce qu'on appelle l'héritage de rôles, qui garantit qu'un utilisateur ayant le rôle admin, par exemple, peut aussi faire ce que peut faire un visiteur ordinaire du site (et bien sûr davantage).

$acl = new Nette\Security\Permission;

$acl->addRole('guest');
$acl->addRole('registered', 'guest'); // 'registered' hérite de 'guest'
$acl->addRole('admin', 'registered'); // 'admin' hérite de 'registered'

Nous définissons maintenant la liste des ressources auxquelles les utilisateurs peuvent accéder.

$acl->addResource('article');
$acl->addResource('comment');
$acl->addResource('poll');

Les ressources peuvent elles aussi utiliser l'héritage ; il serait par exemple possible d'écrire $acl->addResource('perex', 'article').

Et voici maintenant le plus important. Nous définissons entre eux les règles déterminant qui peut faire quoi avec quoi :

// au départ, personne ne peut rien faire

// laissons guest voir les articles, les commentaires et les sondages
$acl->allow('guest', ['article', 'comment', 'poll'], 'view');
// et aussi voter dans les sondages
$acl->allow('guest', 'poll', 'vote');

// registered hérite des permissions de guest, donnons-lui en plus le droit de commenter
$acl->allow('registered', 'comment', 'add');

// l'administrateur peut tout voir et tout modifier
$acl->allow('admin', $acl::All, ['view', 'edit', 'add']);

Et si nous voulons empêcher quelqu'un d'accéder à une certaine ressource ?

// l'administrateur ne peut pas modifier les sondages, ce serait antidémocratique
$acl->deny('admin', 'poll', 'edit');

Maintenant que nous avons créé le jeu de règles, nous pouvons simplement poser des questions d'autorisation :

// guest peut-il voir les articles ?
$acl->isAllowed('guest', 'article', 'view'); // true

// guest peut-il modifier les articles ?
$acl->isAllowed('guest', 'article', 'edit'); // false

// guest peut-il voter dans les sondages ?
$acl->isAllowed('guest', 'poll', 'vote'); // true

// guest peut-il commenter ?
$acl->isAllowed('guest', 'comment', 'add'); // false

Il en va de même pour un utilisateur enregistré, mais il peut en plus commenter :

$acl->isAllowed('registered', 'article', 'view'); // true
$acl->isAllowed('registered', 'comment', 'add'); // true
$acl->isAllowed('registered', 'comment', 'edit'); // false

L'administrateur peut tout modifier, sauf les sondages :

$acl->isAllowed('admin', 'poll', 'vote'); // true
$acl->isAllowed('admin', 'poll', 'edit'); // false
$acl->isAllowed('admin', 'comment', 'edit'); // true

Les permissions peuvent aussi être évaluées dynamiquement et nous pouvons laisser la décision à notre propre callback, auquel tous les paramètres sont passés :

$assertion = function (Permission $acl, ?string $role, ?string $resource, ?string $privilege): bool {
	return /* ... */;
};

$acl->allow('registered', 'comment', null, $assertion);

Mais comment traiter une situation où les seuls noms de rôles et de ressources ne suffisent pas et où nous voudrions définir que le rôle registered, par exemple, ne peut modifier la ressource article que s'il en est l'auteur ? Nous utiliserons des objets au lieu de chaînes : le rôle sera un objet Nette\Security\Role et la ressource un objet Nette\Security\Resource. Leurs méthodes getRoleId() et getResourceId() renverront les chaînes d'origine :

class Registered implements Nette\Security\Role
{
	public $id;

	public function getRoleId(): string
	{
		return 'registered';
	}
}


class Article implements Nette\Security\Resource
{
	public $authorId;

	public function getResourceId(): string
	{
		return 'article';
	}
}

Et nous créons maintenant la règle :

$assertion = function (Permission $acl, ?string $role, ?string $resource, ?string $privilege): bool {
	$role = $acl->getQueriedRole(); // objet Registered
	$resource = $acl->getQueriedResource(); // objet Article
	return $role->id === $resource->authorId;
};

$acl->allow('registered', 'article', 'edit', $assertion);

Et l'interrogation de l'ACL se fait en passant les objets :

$user = new Registered(/* ... */);
$article = new Article(/* ... */);
$acl->isAllowed($user, $article, 'edit');

Un rôle peut hériter d'un rôle ou de plusieurs. Mais que se passe-t-il si un ancêtre a l'action refusée et l'autre autorisée ? Quels seront les droits du descendant ? Cela est déterminé par le poids du rôle : le dernier rôle indiqué dans la liste des ancêtres a le poids le plus élevé, le premier le plus faible. L'exemple est plus parlant :

$acl = new Nette\Security\Permission;
$acl->addRole('admin');
$acl->addRole('guest');

$acl->addResource('backend');

$acl->allow('admin', 'backend');
$acl->deny('guest', 'backend');

// cas A : le rôle admin a un poids plus faible que le rôle guest
$acl->addRole('john', ['admin', 'guest']);
$acl->isAllowed('john', 'backend'); // false

// cas B : le rôle admin a un poids plus élevé que le rôle guest
$acl->addRole('mary', ['guest', 'admin']);
$acl->isAllowed('mary', 'backend'); // true

Les rôles et les ressources peuvent aussi être supprimés (removeRole(), removeResource()), et les règles annulées (removeAllow(), removeDeny()). Le tableau de tous les rôles parents directs est renvoyé par getRoleParents(). Le fait que deux entités héritent l'une de l'autre est renvoyé par roleInheritsFrom() et resourceInheritsFrom(). L'existence d'un rôle ou d'une ressource se vérifie par hasRole() et hasResource(), les listes de tous les rôles et ressources enregistrés sont renvoyées par getRoles() et getResources(), et tout peut être effacé avec removeAllRoles() et removeAllResources().

Ajouter comme service

Nous devons passer l'ACL que nous avons créée à la configuration comme service, afin que l'objet $user commence à l'utiliser, autrement dit pour qu'il soit possible d'écrire $user->isAllowed('article', 'view') dans le code. Nous écrirons pour cela une factory :

namespace App\Model;

class AuthorizatorFactory
{
	public static function create(): Nette\Security\Permission
	{
		$acl = new Nette\Security\Permission;
		$acl->addRole(/* ... */);
		$acl->addResource(/* ... */);
		$acl->allow(/* ... */);
		return $acl;
	}
}

Et l'ajouter à la configuration :

services:
	- App\Model\AuthorizatorFactory::create

Dans les presenters, vous pouvez ensuite vérifier les permissions, par exemple dans la méthode startup() :

protected function startup()
{
	parent::startup();
	if (!$this->getUser()->isAllowed('backend')) {
		$this->error('Interdit', 403);
	}
}
version: 4.x