Введение
Здравствуйте, дорогие Хабровчане.
В ходе своей работы над api-платформой я провел много времени в поисках верного пути авторизации действий пользователя. Задача была поставлена такая — создать довольно разветвленную систему контроля доступа и действий.
При этом большинство из них на обычный CRUD, но необходимо будет авторизовать и другие действия контролера.
А значит необходимо создать простую и в то же время эффективную и гибкую систему. Шишек было набито немало, потому в этих статьях я решил продемонстрировать несколько упрощенную версию того что у меня получилось.
Отдельно хотелось бы добавить — материал рассчитан на практикующих программистов, и будет сложен для понимания начинающему свой путь разработчику. В данной статье не будет расписано установки проекта, и настройки подключения к БД. Все это вы без труда найдете на просторах интернета.
Часть 1. Модель, Контроллеры
Итак, задача: Имеется много моделей, количество которых может увеличиваться, и уменьшаться по ходу развития проекта.
Действия над каждой из них должны авторизоваться по ролях.
Присутствуют и простые действия, такие как CRUD, и дополнительные (к примеру import, export).
Необходимо максимально упростить работу разработчику (себе любимому) для внесения дополнительных моделей и методов к ним.
В статье приводится пример для Api приложения, но решение подходит не только для такового.
Модель
Для демонстрации создадим модель Post. Я предпочитаю отойти от не целесообразной (в нашем случае) практики хранения моделей в корне папки app.
Потому создаю Папку app/Models а в ней модель Posts. (Предполагается что моделей будет достаточно много, иначе зачем тогда весь сыр-бор).
К модели создаем таблицу. Для этого в консоли выполняем команды:
php artisan make:model Models/Post --api --migration php artisan migrate
В результате мы получим стандартные: модель, миграцию и контроллер. На модели и миграции останавливаться не стоит, так как в рамках данной статьи их я изменять не буду.
Контроллер
Мы уже имеем сгенерированный контроллер PostController с базовыми CRUD операциями
и стандартный родительский класс Controller.
Еще нам понадобится создать абстрактный класс ModelController который расширит клас Controller и от которого, в свою очередь, унаследуем наш PostController.
В сам же PostController для демонстрации добавим еще два метода — import() и export(Post $post).
Я не стану останавливаться на действиях над ресурсами, так как это не есть предметом статьи.
А сейчас немного теории
В Laravel существует стандартный трейт Illuminate\Foundation\Auth\Access\AuthorizesRequests.
В нашем случае он расширяет стандартный клас контроллера App\Http\Controllers\Controller от которого мы унаследуем свой ModelController. Сам же Controller я сознательно изменять не буду, так как попытка перезаписывать методы трейта в стандартном контроллере приведет к ошибке во время исполнения.
Этот трейт в своем арсенале имеет метод authorizeResource($model).
Он может принимать аргументом строковое имя класса, и генерировать соответствующие посредники (Middleware) для доступа к методам контроллера.
Забегая немного наперед продемонстрирую вам пример генерируемых посредников:
can:viewAny,App\Models\Post can:view,post can:update,post
Как вы, возможно, заметили — здесь есть две особенности:
- Методов viewAny и view не существует в стандартных методах классов типа apiResource
- В одном случае используется имя класса, в других — экземпляра модели.
Об этом по порядку:
Первое. Для определения соответствия метода контроллера и значения в аргументе посредника существует стандартный метод resourceAbilityMap() Он и задает это соответствие.
<?php
protected function resourceAbilityMap()
{
return [
'index' => 'viewAny',
'show' => 'view',
'create' => 'create',
'store' => 'create',
'edit' => 'update',
'update' => 'update',
'destroy' => 'delete',
];
}
Второе. В Laravel существует два типа авторизации действий. Действие с экземпляром модели, и действие без экземпляра модели. К примеру метод просмотра списка index() не имеет экземпляра модели, а метод show(Post $post) имеет. Для определения соответствия фремворк использует метод resourceMethodsWithoutModels()
<?php
protected function resourceMethodsWithoutModels()
{
return ['index', 'create', 'store'];
}
Перейдем к практике
Базовый контроллер (ModelController)
В директории app/Http/Controllers Создаем абстрактный класс ModelController. В нем определяем следующее:
- Свойство $guardedMethods содержит в себе список пар значений «метод контроллера» => «метод политики».
О методах политики(Policy) подробней будет написано во второй части статьи, и все же стоит уточнить, что на самом деле это имя Шлюза(Gate). Но в конечном итоге мы придем именно к тому что здесь будет записываться метод политики.
- Свойство $methodsWithoutModels содержит в себе список методов которые не имеют экземпляра модели.
- Абстрактный метод getModelClass() обязует программиста определить в дочернем контроллере соответствующий метод.
Это даст нам возможность использовать его в качестве аргумента для метода авторизации ресурсов.
- Конструктор посредством метода authorizeResource($this->getModelClass()) запускает генерацию посредников для защиты методов. (Точнее для защиты путей(Route), но это уже нюансы)
- Методы resourceAbilityMap() и resourceMethodsWithoutModels() функциональность которых описана выше, с той лишь разницей, что наши методы будут дополнять стандартные значения.
<?php
namespace App\Http\Controllers;
abstract class ModelController extends Controller
{
/** @var array 'method' => 'policy'*/
protected $guardedMethods = [];
protected $methodsWithoutModels = [];
protected abstract function getModelClass(): string;
public function __construct()
{
$this->authorizeResource($this->getModelClass());
}
protected function resourceAbilityMap()
{
$base = parent::resourceAbilityMap();
return array_merge($base, $this->guardedMethods);
}
protected function resourceMethodsWithoutModels()
{
$base = parent::resourceMethodsWithoutModels();
return array_merge($base, $this->methodsWithoutModels);
}
}
Контроллер модели (PostController)
Контроллер модели будет содержать в себе следущее:
- Определение метода getModelClass() который должен отдавать строковое имя класса модели.
- Свойство $methodsWithoutModels в котором запишем пары значений «метод контроллера» => «метод политики» для дополнительных методов. Стандартные методы уже учтены. Здесь стоит отметить что данные пары значений можно называть по разному, но я для удобства предлагаю называть по принципу ключ=значение. Данную реализацию можно еще улучшить путем автоматической генерации данных пар, но это уже вам виднее, в зависимости от задачи.
- Свойство $methodsWithoutModels в котором запишем имена дополнительных методов, которые не оперируют экземпляром модели. В нашем случае import
- Чтобы было более понятно — я подписал какие именно методы имеют экземпляр модели, а какие нет. Повторюсь — действия внутри методов контроллера не входят в тематику этой статьи.
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends ModelController
{
/** @var array 'method' => 'policy'*/
protected $guardedMethods = [
'export' => 'export',
'import' => 'import',
];
protected $methodsWithoutModels = ['import'];
protected function getModelClass(): string
{
return Post::class;
}
public function index()
{ /** Не имеет экземпляра модели */ }
public function store(Request $request)
{ /** Имеет экземпляр модели */ }
public function show(Post $post)
{ /** Имеет экземпляр модели */ }
public function update(Request $request, Post $post)
{ /** Имеет экземпляр модели */ }
public function destroy(Post $post)
{ /** Имеет экземпляр модели */ }
public function import()
{ /** Не имеет экземпляра модели */ }
public function export(Post $post)
{ /** Имеет экземпляр модели */ }
}
Пути (Routes)
Настало время открыть доступ к нашим методам.
Переходим к файлу 'routes/api.php' (или 'routes/web.php', в зависимости от задачи) и прописываем там доступ к методам.
И несмотря на то, что эта задача достаточно тривиальна, все же стоит отметить два нюанса.
Первый — предпочтительно (а в нашем случае необходимо) располагать ваши дополнительные пути выше стандартных, генерируемых методом Route::apiResource('posts', 'PostController'). Это обусловлено принципом маршрутизации Laravel. Более частные случаи нужно располагать выше общих.
Второй — вовсе не обязательно прописывать посредника 'auth:api'. Система авторизации вполне может работать и на принципах, не зависящих от аутентификации пользователя.
<?php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
Route::group(['middleware' => ['auth:api']], static function () {
Route::post('posts/import', 'PostController@import')->name('posts.import');
Route::get('posts/{post}/export', 'PostController@export')->name('posts.export');
Route::apiResource('posts', 'PostController');
});
А сейчас проверим все ли правильно сделали. В консоли выполняем команду:
php artisan route:list
Если все сделано верно — видим результат (я сократил таблицу до необходимомого минимума)
+-----------------------+---------------------------+-------------------------------+ |URI |Action |Middleware | +-----------------------+---------------------------+-------------------------------+ |api/posts |...\PostController@index |...,can:viewAny,App\Models\Post| |api/posts |...\PostController@store |...,can:create,App\Models\Post | |api/posts/import |...\PostController@import |...,can:import,App\Models\Post | |api/posts/{post} |...\PostController@show |...,can:view,post | |api/posts/{post} |...\PostController@update |...,can:update,post | |api/posts/{post} |...\PostController@destroy |...,can:delete,post | |api/posts/{post}/export|...\PostController@export |...,can:export,post | +-----------------------+---------------------------+-------------------------------+
Следующий этап — настройка связки Шлюз(Gate)<->Политика(Policy). Но об этом уже во второй части.