Uwierzytelnianie użytkowników

Prawie żadna aplikacja webowa nie obejdzie się bez mechanizmu logowania i wylogowywania użytkowników oraz weryfikacji ich uprawnień. W tym rozdziale będzie mowa o:

  • logowaniu i wylogowywaniu użytkowników
  • własnych authenticatorach

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

Uwierzytelnianie

Uwierzytelnianie oznacza logowanie użytkownika, czyli proces, w trakcie którego weryfikowana jest tożsamość użytkownika. Użytkownik zwykle identyfikuje się nazwą użytkownika i hasłem. Weryfikacją zajmuje się tak zwany Authenticator. Jeśli logowanie się nie powiedzie, rzucany jest Nette\Security\AuthenticationException.

try {
	$user->login($username, $password);
} catch (Nette\Security\AuthenticationException $e) {
	$this->flashMessage('Wpisana nazwa użytkownika albo hasło są nieprawidłowe.');
}

W ten sposób wylogujesz użytkownika:

$user->logout();

A żeby dowiedzieć się, czy użytkownik jest zalogowany:

echo $user->isLoggedIn() ? 'tak' : 'nie';

Bardzo proste, prawda? A o wszystkie aspekty bezpieczeństwa Nette zadba za Ciebie.

W presenterach możesz zweryfikować logowanie w metodzie startup() i przekierować niezalogowanych użytkowników na stronę logowania.

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

Wygaśnięcie

Logowanie użytkownika wygasa razem z wygaśnięciem magazynu, którym zwykle jest sesja (patrz ustawienie wygaśnięcia sesji). Możesz jednak ustawić też krótszy interwał czasowy, po którym użytkownik zostanie wylogowany. Służy do tego metoda setExpiration(), którą wywołuje się przed login(). Jako argument przekaż ciąg z czasem względnym:

// logowanie wygasa po 30 minutach nieaktywności
$user->setExpiration('30 minutes');

// anulowanie ustawionego wygaśnięcia
$user->setExpiration(null);

Metoda $user->getLogoutReason() zdradza, czy użytkownik został wylogowany, bo upłynął interwał czasowy. Zwraca albo stałą Nette\Security\User::LogoutInactivity (limit czasu upłynął), albo User::LogoutManual (wywołano metodę logout()).

Authenticator

To obiekt weryfikujący dane logowania, typowo nazwę użytkownika i hasło. Trywialną formą jest klasa Nette\Security\SimpleAuthenticator, którą można zdefiniować w konfiguracji:

security:
	users:
		# nazwa użytkownika: hasło
		johndoe: 'secret123'
		kathy: 'evenmoresecretpassword'

Zamiast haseł jawnym tekstem możesz podać też ich hashe; patrz konfiguracja.

To rozwiązanie nadaje się bardziej do celów testowych. Pokażemy Ci, jak utworzyć authenticator weryfikujący dane logowania względem tabeli w bazie danych.

Authenticator to obiekt implementujący interfejs Nette\Security\Authenticator z metodą authenticate(). Jej zadaniem jest albo zwrócić tożsamość, albo rzucić Nette\Security\AuthenticationException. Można by też podać kod błędu, żeby dokładniej rozróżnić sytuację: Authenticator::IdentityNotFound albo Authenticator::InvalidCredential.

use Nette;
use Nette\Security\SimpleIdentity;

class MyAuthenticator implements Nette\Security\Authenticator
{
	public function __construct(
		private Nette\Database\Explorer $database,
		private Nette\Security\Passwords $passwords,
	) {
	}

	public function authenticate(string $username, string $password): SimpleIdentity
	{
		$row = $this->database->table('users')
			->where('username', $username)
			->fetch();

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

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

		return new SimpleIdentity(
			$row->id,
			$row->role, // albo tablica ról
			['name' => $row->username],
		);
	}
}

Klasa MyAuthenticator komunikuje się z bazą danych przez Nette Database Explorer i pracuje z tabelą users, w której kolumna username zawiera nazwę logowania użytkownika, a kolumna password hash hasła. Po zweryfikowaniu nazwy i hasła zwraca tożsamość zawierającą ID użytkownika, jego rolę (kolumna role w tabeli), o której będzie mowa później, oraz tablicę z dodatkowymi danymi (w naszym przypadku nazwą użytkownika).

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

services:
	- MyAuthenticator

Zdarzenia $onLoggedIn, $onLoggedOut

Obiekt Nette\Security\User ma zdarzenia $onLoggedIn i $onLoggedOut, więc możesz dodać callbacki wywoływane po udanym zalogowaniu albo po wylogowaniu użytkownika.

$user->onLoggedIn[] = function () {
	// użytkownik właśnie się zalogował
};

Tożsamość

Tożsamość to zbiór informacji o użytkowniku zwracany przez authenticator, który następnie przechowywany jest w sesji i który można uzyskać za pomocą $user->getIdentity(). Pozwala nam to uzyskać ID, role i inne dane użytkownika dokładnie w takiej postaci, w jakiej przekazaliśmy je w authenticatorze:

$user->getIdentity()->getId();
// działa też skrót $user->getId()

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

// dane użytkownika dostępne są jako właściwości
// nazwa użytkownika, którą przekazaliśmy w MyAuthenticator
$user->getIdentity()->name;

Co ważne, przy wylogowaniu za pomocą $user->logout() tożsamość nie jest usuwana i nadal jest dostępna. Nawet jeśli więc użytkownik ma tożsamość, nie musi być zalogowany. Jeśli chcemy tożsamość jawnie usunąć, wylogowujemy użytkownika, wywołując logout(true).

Dzięki temu możesz nadal zakładać, który użytkownik siedzi przy komputerze, i na przykład wyświetlać w sklepie spersonalizowane oferty, ale jego dane osobowe możesz wyświetlić dopiero po zalogowaniu.

Oprócz czyszczenia tożsamości przy pojedynczym wywołaniu logout(true) możesz jej zachowywanie całkowicie wyłączyć właściwością $persistIdentity. Przy wartości false tożsamość jest odrzucana przy każdym wylogowaniu i przy wygaśnięciu, więc getIdentity() zwraca wtedy null. Zachowanie tożsamości zależy też od magazynu: magazyn cookie nie potrafi jej zachować po wylogowaniu, bo zawsze usuwa cookie.

Tożsamość to obiekt implementujący interfejs Nette\Security\IIdentity. Domyślną implementacją jest Nette\Security\SimpleIdentity. I jak wspomniano, utrzymywana jest w sesji, więc jeśli na przykład zmienimy rolę któregoś z zalogowanych użytkowników, stare dane pozostaną w jego tożsamości do momentu ponownego zalogowania.

Magazyn zalogowanego użytkownika

Dwie podstawowe informacje o użytkowniku, czyli to, czy jest zalogowany, i jego tożsamość, przenoszone są zwykle w sesji. Co da się zmienić. Za przechowywanie tych informacji odpowiada obiekt implementujący interfejs Nette\Security\UserStorage. Do dyspozycji są dwie standardowe implementacje: Nette\Bridges\SecurityHttp\SessionStorage, który przenosi dane w sesji, oraz CookieStorage, który przenosi dane w cookie. Magazyn możesz wybrać i skonfigurować bardzo wygodnie w konfiguracji security › authentication.

Poza tym możesz wpłynąć na to, jak dokładnie będzie przebiegać zapis tożsamości (sleep) i jej odtworzenie (wakeup). Wystarczy, żeby authenticator implementował interfejs Nette\Security\IdentityHandler. Metoda sleepIdentity() wywoływana jest przed zapisem tożsamości do magazynu, a wakeupIdentity() po jej odczycie. Metody te mogą zmodyfikować zawartość tożsamości albo zastąpić ją nowym obiektem, który zwrócą. Metoda wakeupIdentity() może nawet zwrócić null, co wylogowuje użytkownika. Interfejs deklaruje też metodę getGuestIdentity(), patrz Tożsamość gościa.

Jako przykład pokażmy rozwiązanie częstego pytania, jak zaktualizować role w tożsamości zaraz po wczytaniu z sesji. W metodzie wakeupIdentity() przekazujemy do tożsamości aktualne role, np. z bazy danych:

final class Authenticator implements
	Nette\Security\Authenticator, Nette\Security\IdentityHandler
{
	public function sleepIdentity(IIdentity $identity): IIdentity
	{
		// tutaj możesz zmodyfikować tożsamość przed zapisem do magazynu po zalogowaniu,
		// ale teraz tego nie potrzebujemy
		return $identity;
	}

	public function wakeupIdentity(IIdentity $identity): ?IIdentity
	{
		// aktualizujemy role w tożsamości
		$userId = $identity->getId();
		$identity->setRoles($this->facade->getUserRoles($userId));
		return $identity;
	}

	public function getGuestIdentity(): ?IIdentity
	{
		// tożsamość gościa nie jest tu używana
		return null;
	}

Wróćmy teraz do magazynu opartego na cookies. Pozwala on utworzyć stronę, na której użytkownicy mogą się logować, a która nie potrzebuje sesji. Nie musi więc zapisywać na dysk. Tak działa strona, którą właśnie czytasz, wraz z forum. W tym przypadku implementacja IdentityHandler jest koniecznością. W cookie będziemy przechowywać tylko losowy token reprezentujący zalogowanego użytkownika.

Najpierw ustaw w konfiguracji wymagany magazyn za pomocą security › authentication › storage: cookie.

W bazie danych utwórz kolumnę authtoken, w której każdy użytkownik będzie mieć całkowicie losowy, unikalny i nieodgadywalny ciąg o dostatecznej długości (co najmniej 13 znaków). CookieStorage przenosi w cookie tylko wartość $identity->getId(), więc w sleepIdentity() zastępujemy pierwotną tożsamość tożsamością zastępczą zawierającą authtoken w ID. Odwrotnie, w metodzie wakeupIdentity() odczytujemy całą tożsamość z bazy danych na podstawie authtokenu:

final class Authenticator implements
	Nette\Security\Authenticator, Nette\Security\IdentityHandler
{
	public function authenticate(string $username, string $password): SimpleIdentity
	{
		$row = $this->db->fetch('SELECT * FROM user WHERE username = ?', $username);
		// weryfikacja hasła
		// ...
		// zwracamy tożsamość ze wszystkimi danymi z bazy
		return new SimpleIdentity($row->id, null, (array) $row);
	}

	public function sleepIdentity(IIdentity $identity): SimpleIdentity
	{
		// zwracamy tożsamość zastępczą, w której ID zawiera authtoken
		return new SimpleIdentity($identity->authtoken);
	}

	public function wakeupIdentity(IIdentity $identity): ?SimpleIdentity
	{
		// zastępujemy tożsamość zastępczą pełną tożsamością, jak w authenticate()
		$row = $this->db->fetch('SELECT * FROM user WHERE authtoken = ?', $identity->getId());
		return $row
			? new SimpleIdentity($row->id, null, (array) $row)
			: null;
	}

	public function getGuestIdentity(): ?IIdentity
	{
		// tożsamość gościa nie jest tu używana
		return null;
	}
}

Tożsamość gościa

Czasem przydaje się, żeby także niezalogowani odwiedzający mieli tożsamość, na przykład po to, żeby dać im domyślny zestaw ról albo jakieś dane. Jeśli authenticator implementuje IdentityHandler, może ją dostarczyć metodą getGuestIdentity(), używaną zawsze wtedy, gdy nikt nie jest zalogowany. Wtedy getIdentity(), getId() i getRoles() sięgają po nią, więc goście mogą mieć własne role zamiast samej roli guest. Zwróć null, jeśli tożsamości gościa nie chcesz.

public function getGuestIdentity(): ?IIdentity
{
	return new SimpleIdentity('guest', ['guest'], ['name' => 'Guest']);
}

Tożsamość gościa nigdy nie jest zapisywana do magazynu, a zalogowanie zawsze ją zastępuje.

Wiele niezależnych logowań

Możliwe jest, żeby w ramach jednej witryny i jednej sesji logowało się jednocześnie wielu niezależnych użytkowników. Jeśli na przykład chcemy mieć osobne uwierzytelnianie dla administracji i części publicznej witryny, wystarczy ustawić każdemu unikalną przestrzeń nazw:

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

Ważne, żeby pamiętać o ustawianiu przestrzeni nazw zawsze we wszystkich miejscach należących do danej części. Jeśli używamy presenterów, ustawiamy przestrzeń nazw we wspólnym przodku dla tej części, zwykle w BasePresenterze. Robimy to, rozszerzając metodę checkRequirements():

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

Jeśli przełączysz przestrzeń nazw w trakcie jednego żądania (po tym, jak stan uwierzytelnienia został już odczytany), obiekt User nadal trzyma stan zapamiętany z poprzedniej przestrzeni nazw. W takim przypadku wywołaj refreshStorage(), żeby odrzucić cache i wymusić ponowne wczytanie z nowej przestrzeni nazw:

$user->getStorage()->setNamespace('admin');
$user->refreshStorage(); // ponowne wczytanie stanu z nowej przestrzeni nazw

Wiele authenticatorów

Podział aplikacji na części z niezależnym logowaniem zwykle wymaga też różnych authenticatorów. Gdybyśmy jednak zarejestrowali w konfiguracji usług dwie klasy implementujące Authenticator, Nette nie wiedziałoby, którą automatycznie przypisać do obiektu Nette\Security\User, i wyświetliłoby błąd. Dlatego musimy ograniczyć autowiring dla authenticatorów tak, żeby działał tylko wtedy, gdy ktoś zażąda konkretnej klasy, np. FrontAuthenticator. Osiągniemy to, wybierając autowired: self:

services:
	-
		create: FrontAuthenticator
		autowired: self
class SignPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private FrontAuthenticator $authenticator,
	) {
	}
}

Authenticator obiektu User ustawiamy przed wywołaniem metody login(), czyli zwykle w kodzie formularza, który go loguje:

$form->onSuccess[] = function (Form $form, \stdClass $data) {
	$user = $this->getUser();
	$user->setAuthenticator($this->authenticator);
	$user->login($data->username, $data->password);
	// ...
};
wersja: 4.x