Kontrola dostępu (autoryzacja)

Autoryzacja sprawdza, czy użytkownik ma wystarczające uprawnienia, na przykład żeby dostać się do konkretnego zasobu albo wykonać jakąś akcję. Autoryzacja zakłada wcześniejsze udane uwierzytelnienie, czyli że użytkownik jest zalogowany.

Instalacja i wymagania

W przykładach będziemy używać obiektu klasy Nette\Security\User, który reprezentuje bieżącego użytkownika i który uzyskasz, pozwalając sobie go przekazać przez wstrzykiwanie zależności. W presenterach wystarczy wywołać $user = $this->getUser().

Przy bardzo prostych stronach z administracją, gdzie uprawnienia użytkowników nie są różnicowane, można jako kryterium autoryzacji użyć znanej już metody isLoggedIn(). Innymi słowy: gdy tylko użytkownik jest zalogowany, ma wszystkie uprawnienia, i odwrotnie.

if ($user->isLoggedIn()) { // czy użytkownik jest zalogowany?
	deleteItem(); // to ma uprawnienie do operacji
}

Role

Celem ról jest zaoferowanie precyzyjniejszej kontroli nad uprawnieniami i pozostanie niezależnym od nazwy użytkownika. Gdy tylko użytkownik się zaloguje, przypisywana jest mu jedna albo więcej ról, w których będzie działać. Role mogą być prostymi ciągami, na przykład admin, member, guest itd. Podaje się je jako drugi argument konstruktora SimpleIdentity, albo jako ciąg, albo jako tablicę ciągów, czyli ról.

Jako kryterium autoryzacji użyjemy teraz metody isInRole(), która zdradza, czy użytkownik działa w danej roli:

if ($user->isInRole('admin')) { // czy użytkownik jest w roli admin?
	deleteItem(); // to ma uprawnienie do operacji
}

Jak już wiesz, wylogowanie użytkownika nie musi usuwać jego tożsamości. Metoda getIdentity() nadal więc zwraca obiekt SimpleIdentity wraz ze wszystkimi przyznanymi rolami. Nette Framework wyznaje zasadę “mniej kodu, więcej bezpieczeństwa”, gdzie mniej pisania prowadzi do bezpieczniejszego kodu. Dlatego przy sprawdzaniu ról nie musisz weryfikować, czy użytkownik jest zalogowany. Metoda isInRole() pracuje z rolami efektywnymi: jeśli użytkownik jest zalogowany, wychodzi od ról podanych w tożsamości; jeśli nie jest zalogowany, ma automatycznie specjalną rolę guest (albo role tożsamości gościa, jeśli jest dostarczana).

Autoryzator

Oprócz ról wprowadzimy jeszcze pojęcia zasobu i operacji:

  • rola to właściwość użytkownika, np. moderator, redaktor, odwiedzający, zarejestrowany użytkownik, administrator…
  • zasób to logiczna jednostka witryny: artykuł, strona, użytkownik, pozycja menu, ankieta, presenter, …
  • operacja to konkretna czynność, którą użytkownik może albo nie może wykonać z zasobem, np. wyświetlenie, edycja, usunięcie, głosowanie, …

Autoryzator to obiekt decydujący, czy dana rola ma uprawnienie wykonać określoną operację z konkretnym zasobem. To obiekt implementujący interfejs Nette\Security\Authorizator z jedyną metodą 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;
	}
}

Autoryzator dodamy do konfiguracji jako usługę kontenera DI:

services:
	- MyAuthorizator

A oto przykład użycia. Uwaga: tym razem wywołujemy metodę Nette\Security\User::isAllowed(), a nie metodę autoryzatora, więc brakuje pierwszego parametru $role. Metoda ta wywołuje kolejno MyAuthorizator::isAllowed() dla wszystkich ról użytkownika i zwraca true, jeśli przynajmniej jedna z nich ma uprawnienie.

if ($user->isAllowed('file')) { // czy użytkownik może cokolwiek z zasobem 'file'?
	useFile();
}

if ($user->isAllowed('file', 'delete')) { // czy użytkownik może wykonać 'delete' na zasobie 'file'?
	deleteFile();
}

Oba argumenty są opcjonalne, wartość domyślna null oznacza cokolwiek.

Permission ACL

Nette zawiera wbudowaną implementację autoryzatora, klasę Nette\Security\Permission, która daje programiście lekką i elastyczną warstwę ACL (Access Control List) do zarządzania uprawnieniami i dostępem. Praca z nią polega na zdefiniowaniu ról, zasobów i poszczególnych uprawnień. Role i zasoby pozwalają tworzyć hierarchie. Dla wyjaśnienia pokażemy przykład aplikacji webowej:

  • guest: niezarejestrowany odwiedzający, który może czytać i przeglądać publiczną część witryny, czyli czytać artykuły, komentarze i głosować w ankietach.
  • registered: zarejestrowany użytkownik, który jest zalogowany i który może dodatkowo dodawać komentarze.
  • admin: może zarządzać artykułami, komentarzami i ankietami.

Zdefiniowaliśmy pewne role (guest, registered i admin) i wspomnieliśmy o zasobach (article, comment, poll), do których użytkownicy z określoną rolą mogą sięgać albo wykonywać na nich określone operacje (view, vote, add, edit).

Tworzymy instancję klasy Permission i definiujemy role. Można użyć tak zwanego dziedziczenia ról, które zapewnia, że np. użytkownik z rolą admin może też to, co zwykły odwiedzający witrynę (i oczywiście więcej).

$acl = new Nette\Security\Permission;

$acl->addRole('guest');
$acl->addRole('registered', 'guest'); // 'registered' dziedziczy po 'guest'
$acl->addRole('admin', 'registered'); // 'admin' dziedziczy po 'registered'

Teraz zdefiniujemy listę zasobów, do których użytkownicy mogą sięgać.

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

Zasoby również mogą korzystać z dziedziczenia; można by na przykład podać $acl->addResource('perex', 'article').

A teraz najważniejsze. Definiujemy między nimi reguły określające, kto co może z czym robić:

// na początku nikt nie może nic

// pozwólmy gościowi wyświetlać artykuły, komentarze i ankiety
$acl->allow('guest', ['article', 'comment', 'poll'], 'view');
// a także głosować w ankietach
$acl->allow('guest', 'poll', 'vote');

// registered dziedziczy uprawnienia po guest, dodajmy mu prawo do komentowania
$acl->allow('registered', 'comment', 'add');

// administrator może wyświetlać i edytować cokolwiek
$acl->allow('admin', $acl::All, ['view', 'edit', 'add']);

A co, jeśli chcemy komuś zabronić dostępu do określonego zasobu?

// administrator nie może edytować ankiet, to byłoby niedemokratyczne
$acl->deny('admin', 'poll', 'edit');

Teraz, gdy mamy utworzony zestaw reguł, możemy po prostu zadawać zapytania autoryzacyjne:

// czy guest może wyświetlać artykuły?
$acl->isAllowed('guest', 'article', 'view'); // true

// czy guest może edytować artykuły?
$acl->isAllowed('guest', 'article', 'edit'); // false

// czy guest może głosować w ankietach?
$acl->isAllowed('guest', 'poll', 'vote'); // true

// czy guest może komentować?
$acl->isAllowed('guest', 'comment', 'add'); // false

To samo dotyczy zarejestrowanego użytkownika, ale on może też komentować:

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

Administrator może edytować wszystko oprócz ankiet:

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

Uprawnienia można też ewaluować dynamicznie i pozostawić decyzję własnemu callbackowi, któremu przekazywane są wszystkie parametry:

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

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

Ale jak poradzić sobie z sytuacją, w której same nazwy ról i zasobów nie wystarczają, a chcielibyśmy zdefiniować, że na przykład rola registered może edytować zasób article tylko wtedy, gdy jest jego autorem? Zamiast ciągów użyjemy obiektów: rola będzie obiektem Nette\Security\Role, a zasób obiektem Nette\Security\Resource. Ich metody getRoleId() względnie getResourceId() będą zwracać pierwotne ciągi:

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';
	}
}

A teraz tworzymy regułę:

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

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

A zapytanie do ACL wykonuje się, przekazując obiekty:

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

Rola może dziedziczyć po jednej roli albo po wielu rolach. Ale co się stanie, jeśli jeden przodek ma akcję zabronioną, a drugi dozwoloną? Jakie będą prawa potomka? Określa to waga roli: ostatnia rola podana na liście przodków ma największą wagę, pierwsza najmniejszą. Lepiej widać to na przykładzie:

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

$acl->addResource('backend');

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

// przypadek A: rola admin ma mniejszą wagę niż rola guest
$acl->addRole('john', ['admin', 'guest']);
$acl->isAllowed('john', 'backend'); // false

// przypadek B: rola admin ma większą wagę niż rola guest
$acl->addRole('mary', ['guest', 'admin']);
$acl->isAllowed('mary', 'backend'); // true

Role i zasoby można też usuwać (removeRole(), removeResource()), a reguły cofać (removeAllow(), removeDeny()). Tablicę wszystkich bezpośrednich ról nadrzędnych zwraca getRoleParents(). To, czy dwie encje po sobie dziedziczą, zwracają roleInheritsFrom() i resourceInheritsFrom(). Istnienie roli albo zasobu sprawdzają hasRole() i hasResource(), listy wszystkich zarejestrowanych ról i zasobów zwracają getRoles() i getResources(), a wszystko można wyczyścić za pomocą removeAllRoles() i removeAllResources().

Dodanie jako usługi

Utworzone przez nas ACL musimy przekazać do konfiguracji jako usługę, żeby obiekt $user zaczął go używać, czyli żeby dało się w kodzie użyć $user->isAllowed('article', 'view'). W tym celu napiszemy dla niego fabrykę:

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;
	}
}

I dodamy ją do konfiguracji:

services:
	- App\Model\AuthorizatorFactory::create

W presenterach możesz potem weryfikować uprawnienia na przykład w metodzie startup():

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