Přihlašování & oprávnění

Pomalu žádná webová aplikace se neobejde bez mechanismu přihlašování uživatelů a ověřování uživatelských oprávnění. V této kapitole si povíme o:

  • přihlašování a odhlašování uživatelů
  • ověření uživatelských oprávnění
  • vlastních autentikátorech a autorizátorech

V příkladech budeme používat objekt třídy Nette\Security\User, který představuje aktuálního uživatele a ke kterému se dostanete tak, že si jej necháte předat pomocí dependency injection. V presenterech stačí jen zavolat $user = $this->getUser().

Autentizace

Autentizací se rozumí přihlašování uživatelů, tedy proces, při kterém se ověřuje, zda je uživatel opravdu tím, za koho se vydává. Obvykle se prokazuje uživatelským jménem a heslem. Ověření provede tzv. autentikátor. Pokud přihlášení selže, vyhodí se Nette\Security\AuthenticationException.

try {
	$user->login($username, $password);
} catch (Nette\Security\AuthenticationException $e) {
	$this->flashMessage('Uživatelské jméno nebo heslo je nesprávné');
}

Ohlášení uživatele:

$user->logout();

A zjištění, že je přihlášen:

echo $user->isLoggedIn() ? 'ano' : 'ne';

Velmi jednoduché, viďte? A všechny bezpečnostní aspekty řeší Nette za vás.

Ještě lze nastavit časový interval, po jehož uplynutí dojde k odhlášení uživatele (jinak se odhlásí s expirací session). K tomu slouží metoda setExpiration(), která se volá před login(). Jako parametr uveďte řetězec s relativním časem:

Kromě odhlášení metodu logout() lze uživatele odhlásit po uplynutí časového intervalu nebo zavření prohlížeče. K nastavení slouží metoda setExpiration(), kterou voláme při přihlašování. Jako parametr lze uvést relativní čas v sekundách, UNIX timestamp nebo textový zápis.

// přihlášení vyprší po 30 minutách neaktivity
$user->setExpiration('30 minutes');

// zrušení expirace
$user->setExpiration(0);

Expirace musí být nastavena na stejnou nebo nižší hodnotu, než jakou má expirace session.

Důvod odhlášení prozradí metoda $user->getLogoutReason(), která vrací buď konstantu Nette\Security\IUserStorage::INACTIVITY (vypršel časový limit) nebo IUserStorage::MANUAL (odhlášen metodou logout()).

V presenterech můžete ověřit přihlášení v metodě startup():

protected function startup()
{
	parent::startup();
	if (!$this->getUser()->isLoggedIn()) {
		$this->redirect('Sign:in');
	}
}

Autentikátor

Jde o objekt, který ověřuje přihlašovací údaje, tedy zpravidla jméno a heslo. Triviální podobou je třída Nette\Security\SimpleAuthenticator, kterou můžeme nadefinovat v konfiguraci:

security:
	users:
		# jméno: heslo
		frantisek: tajneheslo
		katka: jestetajnejsiheslo

Toto řešení je vhodné spíš pro testovací účely. Ukážeme si, jak vytvořit autentikátor, který bude ověřovat přihlašovací údaje oproti databázové tabulce.

Autentikátor je objekt implementující rozhraní Nette\Security\IAuthenticator s metodou authenticate(). Jejím úkolem je buď vrátit tzv. identitu nebo vyhodit výjimku Nette\Security\AuthenticationException. Bylo by možné u ní ještě uvést chybový kód k jemnějšímu rozlišení vzniklé situace: IAuthenticator::IDENTITY_NOT_FOUND a IAuthenticator::INVALID_CREDENTIAL.

use Nette;

class MyAuthenticator implements Nette\Security\IAuthenticator
{
	private $database;

	public function __construct(Nette\Database\Context $database)
	{
		$this->database = $database;
	}

	/** @return Nette\Security\IIdentity */
	public function authenticate(array $credentials)
	{
		list($username, $password) = $credentials;
		$row = $this->database->table('users')
			->where('username', $username)
			->fetch();

		if (!$row) {
			throw new Nette\Security\AuthenticationException('User not found.');
		}

		if (!Nette\Security\Passwords::verify($password, $row->password)) {
			throw new Nette\Security\AuthenticationException('Invalid password.');
		}

		return new Nette\Security\Identity(
			$row->id,
			$row->role, // nebo pole více rolí
			['name' => $row->username]
		);
	}
}

Třída MyAuthenticator komunikuje s databází prostřednictvím Nette Database Explorer a pracuje s tabulkou users, kde je v sloupci username přihlašovací jméno uživatele a ve sloupci password otisk hesla. Po ověření jména a hesla vrací identitu, která nese ID uživatele, jeho roli (sloupec role v tabulce), o které si více řekneme později, a pole s dalšími daty (v našem případě uživatelské jméno).

Autentikátor ještě přidáme do konfigurace jako službu DI kontejneru:

services:
	- MyAuthenticator

Události $onLoggedIn, $onLoggedOut

Objekt Nette\Security\Userudálosti $onLoggedIn a $onLoggedOut, můžete tedy přidat callbacky, které se vyvolají po úspěšném přihlášení resp. po odhlášení uživatele.

$user->onLoggedIn[] = function () {
	// uživatel byl právě přihlášen
};

Identita

Identita představuje soubor informací o uživateli, který vrací autentikátor a který se následně uchovává v session a získáváme jej pomocí $user->getIdentity(). Můžeme tedy získat id, role a další uživatelská data, tak jak jsme si je předali v autentikátoru:

$user->getIdentity()->getId();
// funguje i zkratka $user->getId();

$user->getIdentity()->getRoles();

// uživatelská data jsou dostupná jako properties
// jméno, které jsme si předali v MyAuthenticator
$user->getIdentity()->name;

Co je důležité, tak že při odhlášení se identita nesmaže a je nadále k dispozici. Takže ačkoliv má uživatel identitu, nemusí být přihlášený. Pokud bychom chtěli identitu explicitně smazat, odhlásíme uživatele voláním $user->logout(true).

Díky tomu můžete nadále předpokládat, který uživatel je u počítače a například mu v e-shopu zobrazovat personalizované nabídky, nicméně zobrazit mu jeho osobní údaje můžete až po přihlášení.

Identita je objekt implementující rozhraní Nette\Security\IIdentity, výchozí implementací je Nette\Security\Identity. A jak bylo zmíněno, udržuje se v session, takže pokud tedy například změníme roli některého z přihlášených uživatelů, zůstanou stará data v jeho identitě až do jeho opětovného přihlášení.

Autorizace

Autorizace zjišťuje, zda má uživatel dostatečná oprávnění například pro přístup k určitému zdroje či pro provedení nějaké akce. Autorizace předpokládá předchozí úspěšnou autentizaci, tj. že uživatel je přihlášen.

U velmi jednoduchých webů s administrací, kde se nerozlišují oprávnění uživatelů, je možné jako autorizační kritérium použít již známou metodu isLoggedIn(). Jinými slovy: jakmile je uživatel přihlášen, má veškerá oprávnění a naopak.

if ($user->isLoggedIn()) { // je uživatel přihlášen?
	deleteItem(); // pak má k operaci oprávnění
}

Role

Smyslem rolí je nabídnout přesnější řízení oprávnění a zůstat nezávislý na uživatelském jméně. Každému uživateli hned při přihlášení přiřkneme jednu či více rolí, ve kterých bude vystupovat. Role mohou být jednoduché řetězce například admin, member, guest, apod. Uvádí se jako druhý parametr konstruktoru Identity, buď jako řetězec nebo pole řetězců – rolí.

Jako autorizační kritérium nyní použijeme metodu isInRole(), která prozradí, zda uživatel vystupuje v dané roli:

if ($user->isInRole('admin')) { // je uživatel v roli admina?
	deleteItem(); // pak má k operaci oprávnění
}

Jak už víte, po odhlášení uživatele se nemusí smazat jeho identita. Tedy i nadále metoda getIdentity() vrací objekt Identity, včetně všech udělených rolí. Nette Framework vyznává princip „less code, more security“, kdy méně psaní vede k více zabezpečenému kódu, proto při zjišťování rolí nemusíte ještě ověřovat, zda je uživatel přihlášený. Metoda isInRole() pracuje s efektivními rolemi, tj. pokud je uživatel přihlášen, vychází z rolí uvedených v identitě, pokud přihlášen není, má automaticky speciální roli guest.

Autorizátor

Kromě rolí zavedeme ještě pojmy zdroj a operace:

  • role je vlastnost uživatele – např. moderátor, redaktor, návštěvník, zaregistrovaný uživatel, správce…
  • zdroj (resource) je nějaký logický prvek webu – článek, stránka, uživatel, položka v menu, anketa, presenter, …
  • operace (operation) je nějaká konkrétní činnost, kterou uživatel může či nemůže se zdrojem dělat – například smazat, upravit, vytvořit, hlasovat, …

Autorizátor je objekt, který rozhoduje, zda má daná role povolení provést určitou operaci s určitým zdrojem. Jde o objekt implementující rozhraní Nette\Security\IAuthorizator s jedinou metodu isAllowed():

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

		...

		return false;
	}
}

Autorizátor přidáme do konfigurace jako službu DI kontejneru:

services:
	- MyAuthorizator

A následuje příklad použití. Pozor, tentokrát voláme metodu Nette\Security\User::isAllowed(), nikoliv autorizátor, takže tam není první parametr $role. Tato metoda volá MyAuthorizator::isAllowed() postupně pro všechny uživatelovy role a vrací true, pokud alespoň jedna z nich má povolení.

if ($user->isAllowed('file')) { // může uživatel dělat cokoliv se zdrojem 'file'?
	useFile();
}

if ($user->isAllowed('file', 'delete')) { // může nad zdrojem 'file' provést 'delete'?
	deleteFile();
}

Oba parametry jsou volitelné, výchozí hodnota null má význam cokoliv.

Permission ACL

Nette přichází s vestavěnou implementací autorizátoru, a to třídou Nette\Security\Permission poskytující programátorovi lehkou a flexibilní ACL (Access Control List) vrstvu pro řízení oprávnění a přístupů. Práce s ní spočívá v definici rolí, zdrojů a jednotlivých oprávnění. Přičemž role a zdroje umožňují vytvářet hierarchie. Na vysvětlenou si ukážeme příklad webové aplikace:

  • guest: nepřihlášený návštěvník, který může číst a procházet veřejnou část webu, tzn. číst články, komentáře a volit v anketách
  • registered: přihlášený registrovaný uživatel, který navíc může komentovat
  • administrator: může spravovat články, komentáře i ankety

Nadefinovali jsme si tedy určité role (guest, registered a admin) a zmínili zdroje (article, comment, poll), ke kterým mohou uživatelé s nějakou rolí přistupovat nebo provádět určité operace (view, vote, add, edit).

Vytvoříme instanci třídy Permission a nadefinujeme role. Lze přitom využít tzv. dědičnost rolí, která zajistí, že např. uživatel s rolí administrátor může dělat i to co obyčejný návštěvník webu (a samozřejmě i více).

$acl = new Nette\Security\Permission;

$acl->addRole('guest');
$acl->addRole('registered', 'guest'); // registered dědí od guest
$acl->addRole('administrator', 'registered'); // a od něj dědí administrator

Nyní nadefinujeme i seznam zdrojů, ke kterým mohou uživatelé přistupovat.

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

I zdroje mohou používat dědičnost, bylo by možné například zadat $acl->addResource('perex', 'article').

A teď to nejdůležitější. Nadefinujeme mezi nimi pravidla určující, kdo co může s čím dělat:

// nejprve nikdo nemůže dělat nic

// nechť guest může prohlížet články, komentáře i ankety
$acl->allow('guest', ['article', 'comment', 'poll'], 'view');
// a v anketách navíc i hlasovat
$acl->allow('guest', 'poll', 'vote');

// registrovaný dědí práva od guesta, dáme mu navíc právo komentovat
$acl->allow('registered', 'comment', 'add');

// administrátor může prohlížet a editovat cokoliv
$acl->allow('administrator', $acl::ALL, ['view', 'edit', 'add']);

Co když chceme někomu zamezit k určitému zdroji přístup?

// administrátor nemůže editovat ankety, to by bylo nedemokratické
$acl->deny('administrator', 'poll', 'edit');

Nyní, když máme vytvořený seznam pravidel, můžeme jednoduše klást autorizační dotazy:

// může guest prohlížet články?
$acl->isAllowed('guest', 'article', 'view'); // true

// může guest editovat články?
$acl->isAllowed('guest', 'article', 'edit'); // false

// může guest hlasovat v anketách?
$acl->isAllowed('guest', 'poll', 'vote'); // true

// může guest komentovat?
$acl->isAllowed('guest', 'comment', 'add'); // false

Totéž platí pro registrovaného uživatele, ten však může i komentovat:

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

Administrátor může editovat vše, kromě anket:

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

Oprávění mohou také být vyhodnocována dynamicky a můžeme rozhodnutí nechat na vlastním callbacku, kterému se předají všechny parametry:

$assertion = function (Permission $acl, $role, $resource, $privilege) {
	return ...; // true | false
};

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

Jak ale třeba řešit situaci, kdy nestačí jen názvy rolí a zdrojů, ale chtěli bychom definovat, že třeba role registered může editovat zdroj article jen pokud je jeho autorem? Místo řetězců použijeme objekty, role bude objekt Nette\Security\IRole a zdroj Nette\Security\IResource. Jejich metody getRoleId() resp. getResourceId() budou vracet původní řetezce:

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

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


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

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

A nyní vytvoříme pravidlo:

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

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

A dotaz na ACL se provede předáním objektů:

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

Role může dědit od jiné role či od více rolí. Co se ale stane, pokud má jeden předek akci zakázanou a druhý povolenou? Jaké budou práva potomka? Určuje se to podle váhy role – poslední uvedená role v seznamu předků má největší váhu, první uvedená role tu nejmenší. Více názorné je to z příkladu:

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

$acl->addResource('backend');

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

// případ A: role admin má menší váhu než role guest
$acl->addRole('john', ['admin', 'guest']);
$acl->isAllowed('john', 'backend'); // false

// případ B: role admin má větší váhu než guest
$acl->addRole('mary', ['guest', 'admin']);
$acl->isAllowed('mary', 'backend'); // true

Role a zdroje lze i odebírat (removeRole(), removeResource()), lze revertovat i pravidla (removeAllow(), removeDeny()). Pole všech přímých rodičovských rolí vrací getRoleParents(), zda od sebe dvě entity dědí vrací roleInheritsFrom() a resourceInheritsFrom().

Přidání jako služby

Námi vytvořené ACL si potřebujeme předat do konfigurace jako službu, aby jej začal používat objekt $user, tedy aby bylo možné používat v kódu např. $user->isAllowed('article', 'view'). Za tím účelem si na něj napíšeme továrnu:

namespace App\Model;

use Nette;

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

A přidáme ji do konfigurace:

services:
	- App\Model\AuthorizatorFactory::create

V presenterech pak můžete ověřit oprávnění například v metodě startup():

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

Více nezávislých přihlášení

Souběžně je možné v rámci jednoho webu a jedné session mít několik nezávislých přihlašujících se uživatelů. Pokud například chceme mít na webu oddělenou autentizaci pro administraci a veřejnou část, stačí každé z nich nastavit vlastní název:

$user->getStorage()->setNamespace('backend');

Je důležité pamatovat na to, abychom jmenný prostor nastavili vždy na všech místech patřících do dané části. Pakliže používáme presentery, nastavíme jmenný prostor ve společném předkovi pro danou část – obvykle BasePresenter. Učiníme tak rozšířením metody checkRequirements():

public function checkRequirements($element)
{
	$this->getUser()->getStorage()->setNamespace('backend');
	parent::checkRequirements($element);
}

Více autentikátorů

Rozdělení aplikace na části s nezávislým přihlašováním většinou vyžaduje také různé autentikátory. Jakmile bychom však v konfiguraci služeb zaregistrovali dvě třídy implementující IAuthenticator, Nette by nevědělo, který z nich automaticky přiřadit objektu Nette\Security\User, a zobrazilo by chybu. Proto musíme pro autentikátory autowiring omezit tak, aby fungoval, jen když si někdo vyžádá konkrétní třídu, např. FrontAuthenticator, čehož docílíme volbou autowired: self:

services:
	-
		factory: FrontAuthenticator
		autowired: self
class SignPresenter extends Nette\Application\UI\Presenter
{
	/** @var FrontAuthenticator */
	private $authenticator;

	public function __construct(FrontAuthenticator $authenticator)
	{
		$this->authenticator = $authenticator;
	}
}

Autentikátor objektu User nastavíme před voláním metody login(), takže obvykle v kódu formuláře, který ho přihlašuje:

$form->onSuccess[] = function(Form $form, $values)
{
	$user = $this->getUser();
	$user->setAuthenticator($this->authenticator);
	$user->login($values['username'], $values['password']);
	...
}
Vylepšit tuto stránku