Logowanie użytkowników (Uwierzytelnianie)

Prawie żadna aplikacja internetowa nie obejdzie się bez mechanizmu logowania użytkowników i weryfikacji uprawnień użytkowników. W tym rozdziale omówimy:

  • logowanie i wylogowywanie użytkowników
  • własne autentykatory

Instalacja i wymagania

W przykładach będziemy używać obiektu klasy Nette\Security\User, który reprezentuje aktualnego użytkownika i do którego dostaniesz się, prosząc o jego przekazanie za pomocą wstrzykiwania zależności. W presenterach wystarczy tylko wywołać $user = $this->getUser().

Uwierzytelnianie

Uwierzytelnianiem rozumie się logowanie użytkowników, czyli proces, podczas którego weryfikuje się, czy użytkownik jest naprawdę tym, za kogo się podaje. Zwykle udowadnia to nazwą użytkownika i hasłem. Weryfikację przeprowadza tzw. autentykator. Jeśli logowanie się nie powiedzie, zostanie rzucony wyjątek Nette\Security\AuthenticationException.

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

W ten sposób wylogujesz użytkownika:

$user->logout();

A sprawdzenie, czy jest zalogowany:

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

Bardzo proste, prawda? A wszystkie aspekty bezpieczeństwa Nette rozwiązuje za Ciebie.

W presenterach możesz zweryfikować zalogowanie w metodzie startup() i niezalogowanego użytkownika przekierować na stronę logowania.

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

Wygaśnięcie

Zalogowanie użytkownika wygasa wraz z wygaśnięciem przechowywania, którym zazwyczaj jest sesja (patrz ustawienia wygaśnięcia sesji). Można jednak ustawić krótszy interwał czasowy, po upływie którego nastąpi wylogowanie użytkownika. Do tego służy metoda setExpiration(), która jest wywoływana przed login(). Jako parametr podaj ciąg znaków z czasem względnym:

// zalogowanie wygaśnie po 30 minutach nieaktywności
$user->setExpiration('30 minutes');

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

Czy użytkownik został wylogowany z powodu upływu interwału czasowego, powie metoda $user->getLogoutReason(), która zwraca albo stałą Nette\Security\UserStorage::LogoutInactivity (upłynął limit czasu) albo UserStorage::LogoutManual (wylogowany metodą logout()).

Autentykator

Jest to obiekt, który weryfikuje dane logowania, czyli zazwyczaj nazwę i hasło. Trywialną postacią jest klasa Nette\Security\SimpleAuthenticator, którą możemy zdefiniować w konfiguracji:

security:
	users:
		# nazwa: hasło
		frantisek: tajnehaslo
		katka: jestetajnejsiheslo

To rozwiązanie jest odpowiednie raczej do celów testowych. Pokażemy, jak stworzyć autentykator, który będzie weryfikował dane logowania w oparciu o tabelę bazy danych.

Autentykator to obiekt implementujący interfejs Nette\Security\Authenticator z metodą authenticate(). Jej zadaniem jest albo zwrócić tzw. tożsamość albo rzucić wyjątek Nette\Security\AuthenticationException. Można by było przy niej jeszcze podać kod błędu do dokładniejszego rozróżnienia powstałej sytuacji: Authenticator::IdentityNotFound i 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, // lub tablica wielu ról
			['name' => $row->username],
		);
	}
}

Klasa MyAuthenticator komunikuje się z bazą danych za pomocą Nette Database Explorer i pracuje z tabelą users, gdzie w kolumnie username znajduje się nazwa logowania użytkownika, a w kolumnie password skrót hasła. Po weryfikacji nazwy i hasła zwraca tożsamość, która zawiera ID użytkownika, jego rolę (kolumna role w tabeli), o której więcej powiemy później, oraz tablicę z dodatkowymi danymi (w naszym przypadku nazwę użytkownika).

Autentykator jeszcze dodamy do konfiguracji jako usługę kontenera DI:

services:
	- MyAuthenticator

Zdarzenia $onLoggedIn, $onLoggedOut

Obiekt Nette\Security\User ma zdarzenia $onLoggedIn i $onLoggedOut, możesz więc dodać callbacki, które zostaną wywołane po pomyślnym zalogowaniu lub po wylogowaniu użytkownika.

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

Tożsamość

Tożsamość reprezentuje zbiór informacji o użytkowniku, który zwraca autentykator i który następnie jest przechowywany w sesji i uzyskujemy go za pomocą $user->getIdentity(). Możemy więc uzyskać id, role i inne dane użytkownika, tak jak je przekazaliśmy w autentykatorze:

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

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

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

Co jest ważne, to że przy wylogowaniu za pomocą $user->logout() tożsamość się nie kasuje i jest nadal dostępna. Tak więc, chociaż użytkownik ma tożsamość, nie musi być zalogowany. Jeśli chcielibyśmy tożsamość jawnie skasować, wylogujemy użytkownika wywołaniem logout(true).

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

Tożsamość to obiekt implementujący interfejs Nette\Security\IIdentity, domyślną implementacją jest Nette\Security\SimpleIdentity. I jak wspomniano, utrzymuje się 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 aż do jego ponownego zalogowania.

Przechowywanie danych zalogowanego użytkownika

Dwie podstawowe informacje o użytkowniku, czyli czy jest zalogowany i jego identita, zazwyczaj są przenoszone w sesji. Co można zmienić. Za przechowywanie tych informacji odpowiada obiekt implementujący interfejs Nette\Security\UserStorage. Dostępne są dwie standardowe implementacje, pierwsza przenosi dane w sesji, a druga w cookie. Są to klasy Nette\Bridges\SecurityHttp\SessionStorage i CookieStorage. Wybrać przechowywanie i skonfigurować je możesz bardzo wygodnie w konfiguracji security › authentication.

Dalej możesz wpłynąć na to, jak dokładnie będzie przebiegać zapisywanie tożsamości (sleep) i odtwarzanie (wakeup). Wystarczy, aby autentykator implementował interfejs Nette\Security\IdentityHandler. Ma on dwie metody: sleepIdentity() jest wywoływana przed zapisem tożsamości do przechowywania, a wakeupIdentity() po jej odczytaniu. Metody mogą zmodyfikować zawartość tożsamości, ewentualnie zastąpić ją nowym obiektem, który zwrócą. Metoda wakeupIdentity() może nawet zwrócić null, co spowoduje wylogowanie użytkownika.

Jako przykład pokażemy rozwiązanie częstego pytania, jak zaktualizować role w tożsamości zaraz po odczytaniu z sesji. W metodzie wakeupIdentity() przekażemy 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żna zmodyfikować tożsamość przed zapisem do przechowywania po zalogowaniu,
		// ale tego teraz nie potrzebujemy
		return $identity;
	}

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

A teraz wrócimy do przechowywania opartego na cookies. Pozwala ono stworzyć stronę internetową, na której mogą logować się użytkownicy, a przy tym nie potrzebuje sesji. Czyli nie potrzebuje zapisywać na dysku. Zresztą tak działa również strona, którą właśnie czytasz, włącznie z forum. W tym przypadku implementacja IdentityHandler jest koniecznością. Do cookie bowiem będziemy zapisywać tylko losowy token reprezentujący zalogowanego użytkownika.

Najpierw więc w konfiguracji ustawimy wymagane przechowywanie za pomocą security › authentication › storage: cookie.

W bazie danych stworzymy kolumnę authtoken, w której każdy użytkownik będzie miał całkowicie losowy, unikalny i nie do odgadnięcia ciąg znaków o wystarczającej długości (co najmniej 13 znaków). Przechowywanie CookieStorage przenosi w cookie tylko wartość $identity->getId(), więc w sleepIdentity() oryginalną tożsamość zastąpimy zastępczą z authtoken w ID, natomiast w metodzie wakeupIdentity() na podstawie authtokenu odczytamy całą tożsamość z bazy danych:

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);
		// zweryfikujemy hasło
		...
		// zwrócimy tożsamość ze wszystkimi danymi z bazy danych
		return new SimpleIdentity($row->id, null, (array) $row);
	}

	public function sleepIdentity(IIdentity $identity): SimpleIdentity
	{
		// zwrócimy zastępczą tożsamość, gdzie w ID będzie authtoken
		return new SimpleIdentity($identity->authtoken);
	}

	public function wakeupIdentity(IIdentity $identity): ?SimpleIdentity
	{
		// zastępczą tożsamość zastąpimy 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;
	}
}

Wiele niezależnych logowań

Jednocześnie w ramach jednej strony i jednej sesji może być kilku niezależnych logujących się użytkowników. Jeśli na przykład chcemy mieć na stronie oddzielną autentykację dla administracji i części publicznej, wystarczy każdej z nich ustawić własną nazwę:

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

Ważne jest, aby pamiętać, aby przestrzeń nazw ustawić zawsze we wszystkich miejscach należących do danej części. Jeśli używamy presenterów, ustawimy przestrzeń nazw we wspólnym przodku dla danej części – zazwyczaj BasePresenter. Uczynimy tak, rozszerzając metodę checkRequirements():

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

Wiele autentykatorów

Podział aplikacji na części z niezależnym logowaniem zazwyczaj wymaga również różnych autentykatorów. Gdybyśmy jednak w konfiguracji usług zarejestrowali dwie klasy implementujące Authenticator, Nette nie wiedziałoby, którą z nich automatycznie przypisać obiektowi Nette\Security\User, i wyświetliłoby błąd. Dlatego musimy dla autentykatorów autowiring ograniczyć tak, aby działał tylko wtedy, gdy ktoś zażąda konkretnej klasy, np. FrontAuthenticator, czego dokonamy wyborem autowired: self:

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

Autentykator obiektu User ustawimy przed wywołaniem metody login(), więc zazwyczaj 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.0