Проверка прав доступа в контроллерах

Мы продолжаем знакомиться с внутренним устройством Vesp и сегодня пришла пора поговорить о системе прав доступа к данным.
После работы с MODX мне не хотелось изобретать что-то сложное, поэтому я придумал следующее:
  1. Права доступа прописываются группе, а не пользователю и хранятся в JSON колонке scope (область действия)
  2. Каждый пользователь может принадлежать только к одной группе
  3. Права в группе могут быть указаны для конкретного метода. Если users позволяет любые запросы, то users/get только получение данных, без put, patch и delete.
Таким образом каждой группе можно гибко давать разрешения на конкретные методы в контроллерах.
Когда подобной системы недостаточно, и нужно проверять, например, id автора комментария при редактировании, то мы просто расширяем метод checkScope у контроллера.
Давайте посмотрим, как он работает по-умолчанию.

Стандартный метод checkScope

// Метод должен вернуть или ошибку через $this->failure()
// или null, если доступ разрешён
public function checkScope(string $method): ?ResponseInterface
{
    // Если это кросс-доменная проверка возможности запроса через OPTIONS
    // Или scope у контроллера не указан
    // Или работа идёт из консоли сервера
    // Или это вообще проходят тесты
    // То всё в порядке - возвращаем null
    if ($method === 'options' || !$this->scope || (PHP_SAPI === 'cli' && !getenv('PHPUNIT'))) {
        return null;
    }

    // А вот если нет юзера, а наличие scope мы уже проверили в предыдущем блоке
    // То это ошибка доступа
    if (!$this->user) {
        return $this->failure('Authentication required', 401);
    }
    // Дальше соединяем scope с методом запроса
    $scope = $this->scope . '/' . $method;

    // И передаём проверку уже модели пользователя
    return !$this->user->hasScope($scope)
        ? $this->failure('You have no "' . $scope . '" scope for this action', 403)
        : null;
}
Как видите, контроллер проверяет базовые значения, но дальше основную проверку передаёт модели пользователя. Ведь она может отличаться с вашей системе, например там может быть отключена вообще любая проверка прав для участников группы Admin.
Смотрим в стандартный метод User::hasScope:
public function hasScope($scopes): bool
{
    // Метод может проверять сразу массив scope
    if (!is_array($scopes)) {
        $scopes = [$scopes];
    }
    // Получаем разрешения группы пользователя через связь моделей
    $user = $this->role->scope;

    // Проверяем массив разрешений
    foreach ($scopes as $scope) {
        // Если запрошена проверка конкретного метода в scope
        // Например users/get
        if (strpos($scope, '/') !== false) {
            // То ищем в scope группы или users/get
            // Или целый users
            if (!in_array($scope, $user, true) && !in_array(preg_replace('#/.*#', '', $scope), $user, true)) {
                return false;
            }
        } 
        // Ну или просто проверяем разрешение целиком, без метода запроса
        elseif (!in_array($scope, $user, true)) {
            return false;
        }
    }

    return true;
}
Этот метод может принимать одно или несколько разрешений и возвращает boolean. Если все запрошенные разрешения у группы пользователя есть, то возвращает true, если же хоть одного не хватает - то false.
Поэтому его удобно использовать внутри системы в разных случаях:
$this->user->hasScope('users');
$this->user->hasScope('users/put');
$this->user->hasScope(['users/get', 'comments/post']);
Для наглядности, вот какие разрешения у групп пользователей bezumkin.ru:
Осталось понять, откуда же берётся $this->user внутри контроллера.

Загрузка пользователя в контроллер

При запуске нашего приложения в core/api.php мы указали 2 интересные строчки:
$app->add(App\Middlewares\Auth::class);
$app->add(new RKA\Middleware\IpAddress());
Оба этих класса являются middlewares, или говоря по-русски, посредниками, через которые проходит запрос.
Эта концепция описана в PSR-15 и Slim 4 использует её по умолчанию - можно почитать подробнее в его документации. Суть в том, что каждый запрос в контроллер будет проходить через указанные классы до и после контроллера.
RKA\Middleware\IpAddress является сторонней библиотекой akrabat/ip-address-middleware, которая просто добавляет к запросу IP адрес клиента, а вот App\Middlewares\Auth является нашим классом загрузки пользователя.
Он расширяет стандартный класс из vesp-core и добавляет хранение токенов пользователй в базе данных при помощи модели UserToken.
UserToken, в отличие от MODX не является сессией, нет. В нём не хранится ничего, кроме самого токена, id его владельца и срока годности. Эти токены не занимают много места, базу не раздувают, но позволяют нам делать 2 очень важные вещи:
  1. Мы можем удалять токены пользователей, например при блокировке учётной записи
  2. Мы можем ограничивать количество одновременных сессий пользователя и делать юзеру кнопочку "завершить все сеансы кроме текущего".
<?php

// Для посредников выделено отдельное пространство имён
namespace App\Middlewares;

use App\Models\User;
use App\Models\UserToken;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;

// Наш класс расширяет стандартный из vesp-core
class Auth extends \Vesp\Middlewares\Auth
{
    protected $model = User::class;

    // Единственный магический метод вызывается при создании класса в приложении
    public function __invoke(Request $request, RequestHandler $handler): ResponseInterface
    {
        // Если родительский класс получил токен от пользователя
        // То есть, пользователь пытается быть авторизованным
        if ($token = $this->getToken($request)) {
            // Ищем этот токен в нашей базе данных
            $user_token = UserToken::query()
                ->where(['token' => $token->token, 'active' => true])
                ->first();
            // И если нашли такой токен
            if ($user_token) {
                // Проверяем его срок годности
                if ($user_token->valid_till->toDateTimeString() > date('Y-m-d H:i:s')) {
                    // Проверяем статус его пользователя
                    if ($user = $user_token->user()->where('active', true)->first()) {
                        // Если всё в порядке - добавляем к объекту запроса пользователя
                        $request = $request->withAttribute('user', $user);
                        // И сам токен, на всякий случай, чтобы было
                        $request = $request->withAttribute('token', $token->token);
                    }
                }
                // Если токен уже просрочен - отключаем его
                else {
                    $user_token->active = false;
                    $user_token->save();
                }
            }
        }

        // Возвращаем работу основному приложению
        return $handler->handle($request);
    }
}
Как видите, мы просто проверяем был ли прислан токен, и если был - проверяем всё важное и добавляем модель User к запросы. Затем Vesp\Controllers\Controller сохранит его в $this->user в начале своей работы и все расширяющие контроллеры смогут проверять $this->user у себя.
Осталось только понять, откуда получается токен пользователя при запросе? Смотрим родительский класс Vesp\Middlewares\Auth:
protected function getToken(ServerRequestInterface $request): ?object
{
    // Заголовок (header) с токеном выглядит как Bearer .....токен
    $pcre = '#Bearer\s+(.*)$#i';
    $token = null;

    // Получаем у запроса заголовок Authorization
    $header = $request->getHeaderLine('Authorization');
    // Получаем query параметры, это которые в url после ?=
    $query = $request->getQueryParams();
    // Если есть заголовок и он выглдят как "Bearer токен"
    if ($header && preg_match($pcre, $header, $matches)) {
        // То считаем его нормальным токеном
        $token = $matches[1];
    }
    // Заголовка нет, проверяем параметры url
    elseif (!empty($query['token'])) {
        // Если там есть "?token=токен", то берём его
        $token = $query['token'];
    } else {
        // Последняя отчаянная попытка - проверяем куки пользователя
        $cookies = $request->getCookieParams();
        // Если там выставлены вот такие
        if (isset($cookies['auth._token.local'])) {
            // Получаем токен из них
            $token = preg_match($pcre, $cookies['auth._token.local'], $matches)
                ? $matches[1]
                : $cookies['auth._token.local'];
        }
    }

    // Если вышло хоть что-то достать одним из способов,
    // то пробуем декодировать токен
    if ($token && $decoded = JWT::decodeToken($token)) {
        $decoded->token = $token;

        return $decoded;
    }

    return null;
}
Метод вернёт или декодированный токен (в добавленным внутрь оригинальным токеном на всякий случай), или null - если ничего не смог вытащить ни из header, ни из query, ни из cookies.
Попробуем применить эти знания на практике и добавляем токен к запросу контроллера в админке - работает!
А без токена, понятное дело, доступ запрещён:

Заключение

Теперь вы знаете, откуда берётся $this->user в контроллере и как именно от туда загружается.
Непонятно только где взять этот самый токен пользователя для авторизации, верно?
Именно этим мы и займёмся на следующем занятии - будем авторизовываться в системе и разбираться как именно это работает.

Комментарии

bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Дмитрий
21.12.2024, 13:27:06
Здравствуйте.В ModX есть полезная функция "заморозить url родителя". При ее включении вместо: УРЛ п...
Дмитрий
14.12.2024, 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Василий Наумкин
05.12.2024, 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Василий Наумкин
22.11.2024, 03:33:54
Спасибо!
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!