Как мы уже выяснили в прошлой заметке, пользователь авторизовывается в контроллерах при каждем запросе с помощью токена JWT, который передаётся или в заголовках, или в GET параметре, или получается из куки.
Теперь давайте посмотрим, как происходит получение этого токена пользователем.
Согласно нашему route.php
запросы на авторизацию принимаются по адресу api/security/login
контроллером App\Controllers\Security\Login
, который расширяет Vesp\Controllers\Security\Login
:
<?php
// Это оригинальный контроллер из vesp/core
namespace Vesp\Controllers\Security;
use Psr\Http\Message\ResponseInterface;
use Vesp\Controllers\Controller;
use Vesp\Helpers\Jwt;
use Vesp\Models\User;
class Login extends Controller
{
// Контроллер работает с моделью User
protected $model = User::class;
// Обращаться можно только методом POST
public function post(): ResponseInterface
{
// для авторизации требуются username и password
$username = trim($this->getProperty('username', ''));
$password = trim($this->getProperty('password', ''));
// Выбираем пользователя по username
$user = (new $this->model())->newQuery()->where('username', $username)->first();
// Если есть такой, и указан верный пароль
if ($user && $user->verifyPassword($password)) {
// Проверяем его статус, и возвращаем токен, или ошибку
return !$user->active
? $this->failure('This user is not active', 403)
: $this->success(['token' => Jwt::makeToken($user->id)]);
}
// Авторизовать не удалось - возвращаем ошибку
return $this->failure('Wrong username or password');
}
}
Как видите, оригинальный контроллер очень прост, и сделан скорее для примера, нежели для реальной работы.
Давайте еще посмотрим как генерируется токен в Vesp\Helpers\JWT
:
public static function makeToken(int $id, array $add = []): string
{
// В токен пишется только самый минимум информации
$time = time();
$data = [
'id' => $id, // id пользователя
'iat' => $time, // время выпуска токена
'exp' => $time + getenv('JWT_EXPIRE'), // и срок годности, в зависимости от настроек
];
// Кодирование токена по стандарту занимается сторонная библиотека
// В качестве секретной подписи используется наша настройка из .env
return FirebaseJWT::encode(array_merge($data, $add), getenv('JWT_SECRET'));
}
Метод написан таким образом, чтобы вы могли передавать в токен дополнительные данные вторым параметром, если нужно.
Обратите внимание, что JWT токены предназначены для декодировани и не должны содержать секретную информацию. Более подробно про JWT можно почитать здесь, там же можно кодировать и декодировать токены, если что.
С оригинальным контроллером всё понятно: шлём через POST логин и пароль, в ответ получаем токен, который потом используем для доступа в другие контроллеры.
Однако Vesp предлагает использовать расширенный контроллер, который добавляет хранение выданных токенов в базе с помощью UserToken
:
<?php
namespace App\Controllers\Security;
use App\Models\UserToken;
use Psr\Http\Message\ResponseInterface;
use Vesp\Helpers\Jwt;
// Наш контроллер расширяет оригинальный из vesp/core
class Login extends \Vesp\Controllers\Security\Login
{
// Тот же метод POST
public function post(): ResponseInterface
{
// Получаем ответ из оригинального контроллера
$response = parent::post();
$code = $response->getStatusCode();
// Работаем только если юзер был проверен без ошибок
if ($code === 200) {
// Декодируем содержимое токена
$token = json_decode($response->getBody()->__toString(), false)->token;
if ($decoded = JWT::decodeToken($token)) {
// И сохраняем токен в БД
(new UserToken(
[
'user_id' => $decoded->id,
'token' => $token,
'valid_till' => date('Y-m-d H:i:s', $decoded->exp),
// Вот тут-то нам и пригодился akrabat/ip-address-middleware
// для сохранения IP пользователя
'ip' => $this->request->getAttribute('ip_address'),
]
))->save();
// Если в настройках указано максимальное количество активных токенов
if ($max = getenv('JWT_MAX')) {
$query = UserToken::query()->where(['user_id' => $decoded->id, 'active' => true]);
// И юзер уже превысил этот лимит
if ($query->count() > $max) {
// Ищем самый старый активный токен
if ($result = $query->orderBy('updated_at')->orderBy('created_at')->first()) {
// и отключаем его
$result->update(['active' => false]);
}
}
}
}
} else {
// Так как vesp/vesp в отличие от vesp/core есть фронтенд
// заменяем стандартные сообщения об ошибках на строки из лекикона
// Про лексиконы потом поговорим отдельно
if ($code === 403) {
return $this->failure('errors.security.inactive');
}
if ($code === 422) {
return $this->failure('errors.security.wrong');
}
}
// Возвращаем ответ
return $response;
}
}
Как вы видите из кода, расширенный контроллер никак не меняет успешный ответ от родительского контроллера, он только:
Забегая вперёд, старые токены затем удаляются через cron консольным скриптом в core/cli/clear-tokens.php
:
UserToken::query()
->where('valid_till', '<', date('Y-m-d H:i:s'))
->orWhere('active', false)
->delete();
На предыдущем уроке мы уже разобрали, что при загрузке пользователя в App\Middlewares\Auth
проверяется именно наличие UserToken в базе данных, поэтому удаление токенов пользователя заставит его авторизоваться повторно.
Это позволяет:
А если нужно, вы можете изменить расширенный контроллер и добавить какую-то свою логику.
Давайте теперь авторизуемся в нашей системе! Напоминаю, что после засеивания теблиц у на есть 2 пользователя: user
и admin
, пароли совпадают с именами.
Запросы на авторизацию толжны быть отправлены на http://vesp-shop.test/api/security/login, но метод GET в контроллер отсутствует, нужен POST.
Для этого можно использовать cURL:
curl -X POST -F 'username=admin' -F 'password=admin' \
http://vesp-shop.test/api/security/login
В ответ прилетает JSON с полем token
:
Дальше мы можем использовать этот токен для работы с закрытыми контроллерами. Например, получить профиль владельца токена по адресу http://vesp-shop.test/api/user/profile:
Именно так работает фронтенд при авторизации пользователя. Делает один запрос для получаения токена, а затем загружает и сохраняет данные пользователя с этим токеном.
Это можно заметить в запросах из браузера
Именно поэтому ничего кроме id
и временных отметок в токен пользователя нам писать не нужно.
В отличи vesp/core
выхода из системы быть не может, потому что в нём нет и хранения токенов в базе.
А в нашей расширенной версии появляется возможность завершить сеанс отключением текущего токена:
<?php
namespace App\Controllers\Security;
use App\Models\UserToken;
use Psr\Http\Message\ResponseInterface;
use Vesp\Controllers\Controller;
// У vesp/core этой логики нет, так что и расшрять нечего
class Logout extends Controller
{
// Для обращения к этому контроллеру юзер должен быть
// авторизован в системе
protected $scope = 'profile';
public function post(): ResponseInterface
{
// Ищем текущий токен юзера
$user_token = $token = UserToken::query()
->where([
'user_id' => $this->user->id,
// Вот для этого наш App\Middlewares\Auth и
// добавлял токен в объект запроса
'token' => $this->request->getAttribute('token')
])
->first();
// Если нашли - отключаем
if ($user_token) {
$token->active = false;
$token->save();
}
// Ответом всегда код 200
return $this->success();
}
}
Вот и всё, отключенный токен будет позже удалён через cron
.
Можно попробовать завершить сессию из консоли так же, методом POST. Только теперь нужно добавить заголовок авторизации:
curl -X POST --header "Authorization: Bearer ваштокен" \
http://vesp-shop.test/api/security/logout
Как видно на картинке, первый запрос прошёл без ошибки, токен был отключен. А второй запрос с тем же токеном уже говорит, что пользователь не авторизован.
На мой взгляд логика авторизации очень простая и логичная.
Здесь не используются никакие механизмы PHP или сервера, вроде сессий. Вы просто проверяете логин с паролем, и сохраняете об этом запись в БД.
Затем эта запись проверяется при работе, а когда устарела - удаляется. При этом можно еще посмотреть IP запросов на авторизацию в БД и отключить некоторые сеансы вручную.
Сохраняется самый минимум информации, количество сенсов по умолчанию ограничено тремя - таблица не раздувается на несколько гигабайт как таблица сессий в MODX.
На этом мы закончили разбирать как работает бэкенд. Теперь вы знаете как обрабатываются запрос, как авторизуется и загружается пользователь, и как в целом работают контроллеры.
На следующем уроке мы переходим к фронтенду на VueJs и NuxtJs, и запросы будем отправлять уже оттуда.