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

Мы продолжаем знакомиться с внутренним устройством 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 в контроллере и как именно от туда загружается.

Непонятно только где взять этот самый токен пользователя для авторизации, верно?

Именно этим мы и займёмся на следующем занятии - будем авторизовываться в системе и разбираться как именно это работает.

Следующая заметка →
Авторизация в системе
Комментарии (0)
bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...