Logowanie użytkowników (Autentykacja)
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łasnych autentykatorów
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().
Autentykacja
Autentykacją 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('Użytkownik nie znaleziony.');
}
if (!$this->passwords->verify($password, $row->password)) {
throw new Nette\Security\AuthenticationException('Nieprawidłowe hasło.');
}
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 Tożsamość,
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);
// ...
};