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
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);
// ...
};