Аутентификация пользователей
Почти ни одно веб-приложение не обходится без механизма входа и выхода пользователей и проверки их прав. В этой главе мы поговорим о том:
- как выполнять вход и выход пользователей
- о собственных аутентификаторах
В примерах мы будем использовать объект класса Nette\Security\User, представляющий
текущего пользователя; получить его можно, попросив передать его через
внедрение зависимостей. В презентерах
достаточно вызвать $user = $this->getUser().
Аутентификация
Аутентификация означает вход пользователя, то есть процесс, при
котором проверяется личность пользователя. Обычно пользователь
удостоверяет свою личность именем и паролем. Проверку выполняет так
называемый аутентификатор. Если вход не удаётся,
выбрасывается Nette\Security\AuthenticationException.
try {
$user->login($username, $password);
} catch (Nette\Security\AuthenticationException $e) {
$this->flashMessage('Введённое имя пользователя или пароль неверны.');
}
Вот так вы выполняете выход пользователя:
$user->logout();
А узнать, вошёл ли пользователь, можно так:
echo $user->isLoggedIn() ? 'да' : 'нет';
Совсем просто, правда? А обо всех аспектах безопасности за вас заботится Nette.
В презентерах вы можете проверять вход в методе startup() и
перенаправлять невошедших пользователей на страницу входа.
protected function startup()
{
parent::startup();
if (!$this->getUser()->isLoggedIn()) {
$this->redirect('Sign:in');
}
}
Истечение срока
Срок входа пользователя истекает вместе со сроком хранилища, которым обычно является сессия
(см. настройку истечения сессии). Однако вы
можете задать и более короткий промежуток времени, после которого
пользователь будет разлогинен. Для этого служит метод setExpiration(),
который вызывается перед login(). Передайте аргументом строку с
относительным временем:
// вход истекает после 30 минут бездействия
$user->setExpiration('30 minutes');
// отмена заданного срока
$user->setExpiration(null);
Метод $user->getLogoutReason() подскажет, был ли пользователь
разлогинен из-за истечения промежутка времени. Он возвращает либо
константу Nette\Security\User::LogoutInactivity (истекло ограничение по времени),
либо User::LogoutManual (был вызван метод logout()).
Аутентификатор
Это объект, который проверяет учётные данные для входа, обычно имя пользователя и пароль. Тривиальный его вид – класс Nette\Security\SimpleAuthenticator, который можно определить в конфигурации:
security:
users:
# имя пользователя: пароль
johndoe: 'secret123'
kathy: 'evenmoresecretpassword'
Вместо паролей открытым текстом можно указать и их хеши, см. конфигурацию.
Такое решение больше подходит для целей проверки. Мы покажем, как создать аутентификатор, который проверяет учётные данные по таблице базы данных.
Аутентификатор – это объект, реализующий интерфейс Nette\Security\Authenticator с методом
authenticate(). Его задача – либо вернуть личность,
либо выбросить Nette\Security\AuthenticationException. Можно было бы указать и код
ошибки, чтобы точнее различить ситуацию: Authenticator::IdentityNotFound или
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('Пользователь не найден.');
}
if (!$this->passwords->verify($password, $row->password)) {
throw new Nette\Security\AuthenticationException('Неверный пароль.');
}
return new SimpleIdentity(
$row->id,
$row->role, // либо массив ролей
['name' => $row->username],
);
}
}
Класс MyAuthenticator общается с базой данных через Nette Database Explorer и работает с таблицей
users, где в столбце username находится имя пользователя для
входа, а в столбце password – хеш пароля. После
проверки имени и пароля он возвращает личность, содержащую ID
пользователя, его роль (столбец role в таблице), о которой мы
подробнее поговорим позже, и массив с
дополнительными данными (в нашем случае с именем пользователя).
Аутентификатор мы добавим в конфигурацию как сервис DI-контейнера:
services:
- MyAuthenticator
События $onLoggedIn, $onLoggedOut
У объекта Nette\Security\User есть события $onLoggedIn и $onLoggedOut,
так что вы можете добавить callback'и, которые вызываются соответственно
после успешного входа или после выхода пользователя.
$user->onLoggedIn[] = function () {
// пользователь только что вошёл
};
Личность
Личность (identity) – набор сведений о пользователе, который возвращает
аутентификатор и который затем хранится в сессии и доступен через
$user->getIdentity(). Так мы можем получить ID, роли и другие данные
пользователя ровно в том виде, в каком передали их в аутентификаторе:
$user->getIdentity()->getId();
// работает и сокращение $user->getId()
$user->getIdentity()->getRoles();
// данные пользователя доступны как свойства
// имя пользователя, которое мы передали в MyAuthenticator
$user->getIdentity()->name;
Важно, что при выходе через $user->logout() личность не
удаляется и остаётся доступной. То есть, даже если у пользователя
есть личность, он не обязан быть вошедшим. Если мы хотим удалить
личность явно, мы выполняем выход вызовом logout(true).
Благодаря этому вы по-прежнему можете предполагать, какой пользователь сидит за компьютером, и, например, показывать в интернет-магазине персонализированные предложения, но личные сведения показывать только после входа.
Кроме очистки личности при отдельном вызове
logout(true), её сохранение можно полностью отключить свойством
$persistIdentity. Если оно равно false, личность отбрасывается при
каждом выходе и при истечении срока, так что getIdentity() тогда
возвращает null. Сохранение личности зависит и от хранилища:
хранилище cookie не может сохранить её после выхода, потому что всегда
удаляет cookie.
Личность – объект, реализующий интерфейс Nette\Security\IIdentity. Реализация по умолчанию – Nette\Security\SimpleIdentity. И, как уже сказано, хранится она в сессии, так что если мы, например, изменим роль одного из вошедших пользователей, старые данные останутся в его личности, пока он не войдёт снова.
Хранилище вошедшего пользователя
Два основных сведения о пользователе, а именно вошёл ли он и его личность, обычно передаются в сессии. Это можно изменить.
За хранение этих сведений отвечает объект, реализующий интерфейс
Nette\Security\UserStorage. Доступны две стандартные реализации:
Nette\Bridges\SecurityHttp\SessionStorage, передающая данные в сессии, и
CookieStorage, передающая данные в cookie. Выбрать хранилище и настроить
его очень удобно в конфигурации security ›
authentication.
Кроме того, вы можете повлиять на то, как именно будут проходить
сохранение (sleep) и восстановление (wakeup) личности. Достаточно,
чтобы аутентификатор реализовал интерфейс Nette\Security\IdentityHandler.
Метод sleepIdentity() вызывается перед записью личности в хранилище, а
wakeupIdentity() – после её чтения. Эти методы могут изменить
содержимое личности либо заменить её новым объектом, который вернут.
Метод wakeupIdentity() может даже вернуть null, что разлогинит
пользователя. Интерфейс объявляет и метод getGuestIdentity(), см. Личность гостя.
В качестве примера покажем решение частого вопроса о том, как
обновить роли в личности сразу после загрузки из сессии. В методе
wakeupIdentity() мы передаём в личность актуальные роли, например из
базы данных:
final class Authenticator implements
Nette\Security\Authenticator, Nette\Security\IdentityHandler
{
public function sleepIdentity(IIdentity $identity): IIdentity
{
// здесь можно изменить личность перед записью в хранилище после входа,
// но сейчас нам это не нужно
return $identity;
}
public function wakeupIdentity(IIdentity $identity): ?IIdentity
{
// обновляем роли в личности
$userId = $identity->getId();
$identity->setRoles($this->facade->getUserRoles($userId));
return $identity;
}
public function getGuestIdentity(): ?IIdentity
{
// личность гостя здесь не используется
return null;
}
Теперь вернёмся к хранилищу на основе cookie. Оно позволяет создать
сайт, на котором пользователи могут входить, а сессии при этом не нужны.
То есть писать на диск не требуется. Именно так работает сайт, который
вы сейчас читаете, включая форум. В этом случае реализация
IdentityHandler необходима. В cookie мы будем хранить только случайный
токен, представляющий вошедшего пользователя.
Сначала задайте нужное хранилище в конфигурации через
security › authentication › storage: cookie.
В базе данных создайте столбец authtoken, где у каждого
пользователя будет совершенно случайная,
уникальная и неугадываемая строка достаточной длины (не менее
13 символов). CookieStorage передаёт в cookie только значение
$identity->getId(), поэтому в sleepIdentity() мы заменяем исходную
личность личностью-заместителем, содержащей в ID authtoken. И
наоборот, в методе wakeupIdentity() мы считываем всю личность из базы
данных по authtoken:
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);
// проверяем пароль
// ...
// возвращаем личность со всеми данными из базы данных
return new SimpleIdentity($row->id, null, (array) $row);
}
public function sleepIdentity(IIdentity $identity): SimpleIdentity
{
// возвращаем личность-заместитель, где в ID находится authtoken
return new SimpleIdentity($identity->authtoken);
}
public function wakeupIdentity(IIdentity $identity): ?SimpleIdentity
{
// заменяем личность-заместитель полной личностью, как в 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
{
// личность гостя здесь не используется
return null;
}
}
Личность гостя
Иногда удобно, чтобы личность была и у посетителей, которые не вошли,
например чтобы дать им набор ролей по умолчанию или какие-то данные.
Если аутентификатор реализует IdentityHandler, он может предоставить
её методом getGuestIdentity(), который используется всегда, когда никто
не вошёл. Тогда getIdentity(), getId() и getRoles() откатываются
к ней, так что у гостей могут быть собственные роли вместо простой роли
guest. Верните null, если личность гостя вам не нужна.
public function getGuestIdentity(): ?IIdentity
{
return new SimpleIdentity('guest', ['guest'], ['name' => 'Guest']);
}
Личность гостя никогда не сохраняется в хранилище, и вход всегда её заменяет.
Несколько независимых входов
В рамках одного сайта и одной сессии может одновременно входить несколько независимых пользователей. Например, если мы хотим иметь отдельную аутентификацию для администрирования и для публичной части сайта, достаточно задать каждой из них уникальное пространство имён:
$user->getStorage()->setNamespace('backend');
Важно не забыть задавать пространство имён всегда и во всех местах, относящихся к соответствующей части. Если мы используем презентеры, пространство имён мы задаём в общем предке этой части, обычно в BasePresenter. Делаем мы это расширением метода checkRequirements():
public function checkRequirements($element): void
{
$this->getUser()->getStorage()->setNamespace('backend');
parent::checkRequirements($element);
}
Если вы переключаете пространство имён в рамках одного запроса
(после того, как состояние аутентификации уже прочитано), объект
User по-прежнему держит состояние, закешированное из предыдущего
пространства имён. В таком случае вызовите refreshStorage(), чтобы
сбросить кеш и заставить перечитать состояние из нового
пространства имён:
$user->getStorage()->setNamespace('admin');
$user->refreshStorage(); // перечитываем состояние из нового пространства имён
Несколько аутентификаторов
Разделение приложения на части с независимым входом обычно требует и
разных аутентификаторов. Однако если бы мы зарегистрировали в
конфигурации сервисов два класса, реализующих Authenticator, Nette не знала бы,
какой из них автоматически передать объекту Nette\Security\User, и
показала бы ошибку. Поэтому нам нужно ограничить autowiring для аутентификаторов так, чтобы он
работал, только когда кто-то запрашивает конкретный класс, например
FrontAuthenticator. Достигается это выбором autowired: self:
services:
-
create: FrontAuthenticator
autowired: self
class SignPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private FrontAuthenticator $authenticator,
) {
}
}
Аутентификатор объекта User мы задаём перед вызовом метода login(), то есть обычно в коде формы, которая выполняет вход:
$form->onSuccess[] = function (Form $form, \stdClass $data) {
$user = $this->getUser();
$user->setAuthenticator($this->authenticator);
$user->login($data->username, $data->password);
// ...
};