Проверка прав доступа в контроллерах
Мы продолжаем знакомиться с внутренним устройством Vesp и сегодня пришла пора поговорить о системе прав доступа к данным.
После работы с MODX мне не хотелось изобретать что-то сложное, поэтому я придумал следующее:
- Права доступа прописываются группе, а не пользователю и хранятся в JSON колонке scope (область действия)
- Каждый пользователь может принадлежать только к одной группе
- Права в группе могут быть указаны для конкретного метода. Если 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 очень важные вещи:
- Мы можем удалять токены пользователей, например при блокировке учётной записи
- Мы можем ограничивать количество одновременных сессий пользователя и делать юзеру кнопочку "завершить все сеансы кроме текущего".
<?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
👍
👎
❤️
🔥
😮
😢
😀
😡
291
04.06.2022, 10:25:37
Комментарии
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Дмитрий
21.12.2024, 13:27:06
Здравствуйте.В ModX есть полезная функция "заморозить url родителя". При ее включении вместо:
УРЛ п...
Вывод товаров на сайте
20
Дмитрий
14.12.2024, 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Начинаем новый курс!
14
Василий Наумкин
05.12.2024, 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Запуск в продакшн
55
Василий Наумкин
22.11.2024, 03:33:54
Спасибо!
День рождения 42
5
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Vesp 3.0
108
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Оплата заказа
2
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Обновление проекта
2
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
Создание нового проекта
63
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Поездка в Швейцарию
8
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
День рождения 41
6
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!