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