Авторизация в системе

Как мы уже выяснили в прошлой заметке, пользователь авторизовывается в контроллерах при каждем запросе с помощью токена 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, и запросы будем отправлять уже оттуда.

Комментарии

bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Futuris
04.04.2024, 08:56:12
Я просто немного запутался. Когда в абзаце "Vesp/Core" ты пишешь про "новый trait FileModel", я поду...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!