Přihlašování & oprávnění uživatelů

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í
  • zabezpečení proti zranitelnostem
  • vlastní autentikátory a autorizátory
  • Access Control List

Než se do tématu pustíme, řekněme si, že v příkladech budeme používat službu user, což je objekt třídy Nette\Security\User. Službu můžeme v presenteru získat voláním $user = $this->getUser(), nebo si ji vyžádáme pomocí Dependency Injection.

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.

Uživatele přihlásíme pomocí jména a hesla:

$user->login($username, $password);

Zjistíme, zda je uživatel přihlášen:

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

A odhlásíme jej:

$user->logout();

Velmi jednoduché, viďte?

Přihlašování vyžaduje u uživatele povolené cookies; jiná metoda přihlašování není bezpečná!

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

// přihlášení vyprší po 2 dnech
$user->setExpiration('2 days');

// bez časového limitu
$user->setExpiration(0);

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

Důvod posledního odhlášení prozradí metoda $user->getLogoutReason(), která vrací buď konstantu IUserStorage::INACTIVITY (vypršel časový limit), IUserStorage::BROWSER_CLOSED (uživatel zavřel prohlížeč) nebo IUserStorage::MANUAL (volání metody logout()).

Aby však příklad s přihlášením fungoval, je potřeba napsat objekt, který ověří uživatelské jméno a heslo. Říká se mu autentikátor. Jeho triviální podobou je třída Nette\Security\SimpleAuthenticator, která dostane v konstruktoru seznam uživatelů a hesel jakožto asociativní pole:

$authenticator = new Nette\Security\SimpleAuthenticator([
    'john' => 'IJ^%4dfh54*',
    'kathy' => '12345', // Kathy, this is a very weak password!
]);
$user->setAuthenticator($authenticator);

Pokud přihlašovací údaje nejsou platné, vyhodí autentikátor výjimku Nette\Security\AuthenticationException.

try {
    // pokusíme se přihlásit uživatele...
    $user->login($username, $password);

    // ...a v případě úspěchu presměrujeme na další stránku
    $this->redirect(...);

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

Autentikátor obvykle budeme nastavovat přímo v konfiguračním souboru, v takovém případě se objekt vytvoří, až když ho bude skutečně potřeba. Vyše uvedený příklad by se v config.neon zapsal takto:

services:
    authenticator: Nette\Security\SimpleAuthenticator([
            john: IJ^%4dfh54*
            kathy: 12345
        ])

Vlastní autentikátor

Napíšeme si vlastní autentikátor, který bude ověřovat přihlašovací údaje oproti databázové tabulce. Každý autentikátor je implementací rozhraní Nette\Security\IAuthenticator mající jedinou metodu authenticate(). Jejím úkolem je buď vrátit tzv. identitu nebo vyhodit výjimku Nette\Security\AuthenticationException. Framework definuje i několik chybových kódů, které můžeme využít k formálnímu popisu vzniklé chyby (např. IAuthenticator::IDENTITY_NOT_FOUND nebo IAuthenticator::INVALID_CREDENTIAL.)

use Nette\Security as NS;

class MyAuthenticator implements NS\IAuthenticator
{
    public $database;

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

    function authenticate(array $credentials)
    {
        list($username, $password) = $credentials;
        $row = $this->database->table('users')
            ->where('username', $username)->fetch();

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

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

        return new NS\Identity($row->id, $row->role, ['username' => $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 (bcrypt). 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 by se v konfiguračním souboru nastavil takto:

services:
    authenticator: MyAuthenticator

Identita

Identita představuje soubor informací o uživateli, jak nám jej vrátí autentikátor. Jedná se o objekt implementující rozhraní Nette\Security\IIdentity, výchozí implementací je Nette\Security\Identity. Třída disponuje metodou getId(), která vrací ID uživatele (např. primární klíč v databázi), a getRoles(), která vrátí seznam všech rolí, ve kterých uživatel vystupuje. K uživatelským datům lze přistupovat jako k proměnným objektu.

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).

Služba user třídy Nette\Security\User udržuje identitu v session a využívá ji ke všem autorizačním procesům. K identitě se dostaneme přes funkci getIdentity():

if ($user->isLoggedIn()) {
    echo 'Prihlášen uživatel: ' . $user->getIdentity()->getId();
    // nebo kratší způsob
    echo 'Prihlášen uživatel: ' . $user->id;

    // uživatelské jméno, které jsme si předali do dat v identitě
    echo ' ' . $user->getIdentity()->username;
} else {
    echo 'Uživatel není přihlášen';
}

Jak již bylo zmíněno, identita se udržuje v session. 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 souboru či pro provedení nějaké akce. Autorizace předpokládá předchozí úspěšnou autentizaci, tj. že uživatel je přihlášen.

Autorizace se může v Nette Framework vyhodnocovat na základě členství uživatele v určitých skupinách či přidělených rolích. Začněme ale od úplného začátku.

U jednoduchých webů s administrací, kde se nerozlišují různá oprávnění uživatelů, je možné jako autorizační kritérium použít již známou metodu isLoggedIn(). Řečeno srozumitelnějším jazykem: 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í, ale 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 pojmenovány 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ávající princip „less code, more security“, kdy méně psaní vede k více zabezpečenému kódu, nechce nutit programátora všude psát if ($user->isLoggedIn() && $user->isInRole('admin')) a proto metoda isInRole() pracuje s efektivními rolemi. 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

Autorizátor je ten, kdo umí rozhodnout, zda má uživatel oprávnění provést určitou akci. Jde o objekt implementující rozhraní Nette\Security\IAuthorizator s jedinou metodu isAllowed(), jejímž úkolem je rozhodnout, zda má daná role povolení provést určitou operaci s určitým zdrojem.

  • 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 (privilege) 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, …

Rámcová podoba implementace vypadá takto:

class MyAuthorizator implements Nette\Security\IAuthorizator
{
    function isAllowed($role, $resource, $privilege)
    {
        return ...; // vrací true nebo false
    }
}

A následuje příklad použití:

// zaregistrujeme autorizátor
$user->setAuthorizator(new MyAuthorizator);

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

Nezaměňujte si dvě různé metody isAllowed: jedna patří autorizátoru, druhá třídě User, kde už není první parametr $role.

Protože uživatel může vystupovat ve více rolích, povolení dostane, pokud alespoň jedna role má povolení. Oba parametry jsou volitelné, výchozí hodnota nese význam všechny.

Permission ACL

Nette Framework disponuje předpřipravenou implementací autorizátoru, a to třídou Nette\Security\Permission poskytující programátorovi lehkou a flexibilní ACL vrstvu pro řízení práv a přístupu. 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 psát a 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 uživatelské 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;

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

Docela triviální že? Tímto zajistíme, že se nám vlastnosti budou přenášet z rodičovské role na potomky.

Za zmínku stojí metoda getRoleParents(), která vrací pole se všemi přímými rodičovskými rolemi a také metoda roleInheritsFrom(), která zjistí, zda od sebe dědí dvě role. Jejich použití:

$acl->roleInheritsFrom('administrator', 'guest'); // true
$acl->getRoleParents('administrator'); // ['registered']

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

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

I zdroje/objekty mohou používat dědičnost. Metody pro dotazování se na hierarchii zdrojů jsou podobné jako u rolí, liší se jen názvy: resourceInheritsFrom(), removeResource().

Role i zdroje můžeme nastavit také pomocí konfiguračního souboru.

A teď to nejdůležitější. Samotné role a objekty by nám byly k ničemu, musíme mezi nimi vytvořit ještě 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 obsah veřejné části, hlasovat v anketách
$acl->allow('guest', ['article', 'comment', 'poll'], 'view');
$acl->allow('guest', 'poll', 'vote');

// registrovaný dědí právo od guesta, ale má i právo komentovat
$acl->allow('registered', 'comment', 'add');

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

Co když chceme někomu zamezit do určitého zdroje přístup?

// administrátoři neuvidí reklamy
$acl->deny('administrator', 'ad', 'view');

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

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

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

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

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

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

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

Administrátor může editovat vše:

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

Práva administrátora lze nadefinovat i bez omezení tzn. bez rodičů, od kterých by dědil nějaká omezení. Vypadalo by to asi takto:

$acl->addRole('supervisor');
$acl->allow('supervisor');  // všechna práva a zdroje pro administrátora

Kdykoliv za běhu aplikace můžeme i odebrat roli metodou removeRole(), zdroj odebere removeResource(), pravidlo removeAllow() nebo removeDeny().

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

Použití v aplikaci

Permission můžeme nakonfigurovat v souboru config.neon následujícím způsobem:

services:
    acl:
        factory: Nette\Security\Permission
        setup:
            - addRole(admin)
            - addRole(guest)

            - addResource(backend)

            - allow(admin, backend)
            - deny(guest, backend)

            # případ A: role admin má menší váhu než role guest
            - addRole(john, [admin, guest])

            # případ B: role admin má větší váhu než guest
            - addRole(mary, [guest, admin])

a v presenterech pak můžete ověřit práva například v metodě startup:

protected function startup()
{
    parent::startup();
    if (!$this->getUser()->isAllowed('backend')) {
        throw new Nette\Application\ForbiddenRequestException;
    }
}

Alternativou k nastavení v souboru config.neon je vytvoření továrny, která nám Permission nastaví. Ta pak může vypadat například následovně:

<?php

namespace App\Model;

class AuthorizatorFactory
{
    /**
     * @return Nette\Security\Permission
     */
    public static function create()
    {
        $acl = new Nette\Security\Permission;

        // pokud chceme, můžeme role a zdroje načíst z databáze
        $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

        return $acl;
    }
}

Tovární metodu použijeme jako továrnu pro Permission:

services:
    # třídu Permission vytvoříme metodou create třídy AuthorizatorFactory
    - App\Model\AuthorizatorFactory::create

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

V rámci jedné aplikace (serveru, session) můžeme rozdělit přihlášení na několik nezávislých částí, tak, aby každá z nich využívala vlastní prostor v session. Pokud například chceme mít na webu oddělenou autentizaci pro administraci a veřejnou část, stačí každé z nich nastavit vlastní jmenný prostor:

$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 @inject */
    public $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']);
    ...
}

Události $onLoggedIn, $onLoggedOut

Služba user disponuje událostmi $onLoggedIn a $onLoggedOut například pro jednoduché přidání callbacku pro logování autorizačních aktivit na webu. Událost $onLoggedIn je volána po úspěšném přihlášení, událost $onLoggedOut po odhlášení uživatele.