Введение
Здравствуйте, дорогие Хабровчане.
Сегодня я представляю вашему вниманию заключающую часть цикла статей о продвинутой авторизации действий с ресурсами в Laravel. Чтобы лучше понимать о чем пойдет речь в этой статье — необходимо прочесть первую и вторую части.
На этот раз постановка задачи немного изменилась: Мы уже имеем авторизацию действий с ресурсами посредством авторизации методов соответствующих контроллеров. Задача состоит в том, чтобы авторизовать изменение/просмотр конкретных полей модели. Так же нужно реализовать возможность авторизовать редактирование/просмотр автором своих моделей.
Данный материал рассчитан на практикующих программистов, и будет сложен для понимания начинающему разработчику.
Часть 4. Авторизация действий с атрибутами
Теоретическая часть
Для реализации данного функционала я планирую воспользоваться встроенными трейтами Laravel HasAttributes, HidesAttributes, GuardsAttributes. С помощью этих трейтов фреймворк контролирует массовое заполнение и отображение атрибутов. Возможность форсированно заполнять/читать атрибуты я планирую оставить, так как если авторизовать полностью все действия чтения/записи — с большой вероятностью мы нарушим работоспособность программы.
Для реализации задачи нам необходимо:
- Переопределить методы getHidden(), getVisible() для сокрытия неавторизированных полей.
- Переопределить метод isFillable() для защиты от неавторизированной записи полей.
- Определить собственный метод authUpdate() который будет возвращать массив защищенных для записи полей.
- Определить собственный метод authView() который будет возвращать массив защищенных для чтения полей.
- Переопределить метод totallyGuarded() так как в результате дополнения логики защиты данный метод может ложно срабатывать.
Как и в любой задачи подобного рода необходимо решить для себя — написать расширяющий трейт, или создать родительский класс, от которого унаследовать целевой класс. В каждого метода есть свои плюсы и минусы. Но для меня главное, чтобы не было избыточности логики. То-есть постараться избежать проблемы Banana Monkey Jungle, и упростить поддержку проекта для приемников. И так как защита полей от записи/чтения достаточно точечная — я реализовал трейт. Но это дело вкуса.
Laravel предоставляет возможность воспользоваться такими свойствами:
- $visible
- $hidden
- $guarded
- $fillable
При этом нужно понимать приоритет использования данных полей. Когда Вы переопределяете поле $visible система считает что все остальные поля скрыты. По аналогии — когда Вы переопределяете поле $guarded система считает что все остальные поля разрешены к заполнению. Для сохранения обратной совместимости уже написанного кода проекта — эту логику нужно не изменять, а использовать.
Приступим к практике
Хорошим тоном будет определиться с системой хранения дополнительных трейтов/хелперов/сервисов в фреймворке, и в дальнейшем соблюдать свои же требования. Для трейтов я использую путь app/Extensions/Traits, потому создаю новый трейт app/Extensions/Traits/GuardedModel. Это не системный путь, и не имеет значения где именно хранить данный функционал (как к примеру было с политиками).
В трейте я определяю методы authView()(авторизовать просмотр) и authUpdate()(авторизовать редактирование), которые будут возвращать пустые массивы, и могут быть переопределены в целевой модели.
Дальше необходимо поставить себе на службу встроенные методы getVisible() и getHidden().
Но перед этим нужно определиться, как именно будет происходить сокрытие атрибутов. Я предлагаю создать собственный метод filterVisibility(), который будет запускаться перед тем, как отработает родительский(встроенный) метод фреймворка.
Метод filterVisibility() будет сначала добавлять все защищенные поля в массив $hidden с помощью встроенного механизма makeHidden(), проходиться по массиву защищенных полей, проверяя доступность каждого посредством нашего метода userCanViewAttribute($key) и формируя массив разрешенных. В последствии разрешать авторизованные поля с помощью встроенного механизма makeVisible()
Для определения доступности редактирования воспользуемся стандартным методом isFillable($key), который перед вызовом родительского метода будет проверять необходимость авторизовать запись данного поля, и при необходимости проверять доступность нашим методом userCanUpdateAttribute($key)
Методы userCanUpdateAttribute($key) и userCanViewAttribute($key) будут выполнять проверку авторизации. В моем случае с помощью метода $user->can() библиотеки spatie/laravel-permission. Вы же, как я и писал ранее, свободны использовать свои способы.
Трейт GuardedModel
<?php
namespace App\Extensions\Traits;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
trait GuardedModel
{
protected function authView(): array
{
return [];
}
protected function authUpdate(): array
{
return [];
}
public function getHidden(): array
{
$this->filterVisibility();
return parent::getHidden();
}
public function getVisible(): array
{
$this->filterVisibility();
return parent::getVisible();
}
public function isFillable($key)
{
if (in_array($key, $this->authView())) {
return $this->userCanUpdateAttribute($key);
}
return parent::isFillable($key);
}
private function filterVisibility(): void
{
$this->makeHidden($this->authView());
$authVisible = array_filter(
$this->authView(),
fn ($attr) => $this->userCanViewAttribute($attr)
);
$this->makeVisible($authVisible);
}
private function userCanViewAttribute(string $key): bool
{
/** @var User $user */
$user = auth()->user();
$ability = !empty($user) && $user->can("view-attr-$key-" . static::class);
return $ability;
}
private function userCanUpdateAttribute(string $key): bool
{
/** @var User $user */
$user = auth()->user();
$ability = !empty($user) && $user->can("update-attr-$key-" . static::class);
return $ability;
}
public function totallyGuarded()
{
$guarded = (
count($this->getFillable()) === 0
&& count($this->authView()) === 0
&& $this->getGuarded() == ['*']
);
return $guarded;
}
}
Стоит дополнительно отметить необходимость переопределить метод totallyGuarded(). Данный метод ответственный за выбрасывание исключения Illuminate\Database\Eloquent\MassAssignmentException в случаи если происходит попытка заполнения поля, когда все поля защищены и не открыты для заполнения. Таким образом если все поля пользователю не доступны к заполнению — будет сгенерировано исключение там, где оно не должно быть.
Также обратите внимание на вызов метода $user->can(«update-attr-$key-». static::class), а именно на формирование названия разрешения. Оно аналогично названию разрешения на всю модель, только с добавлением приставки attr. Именно по такому принципу нужно будет формировать разрешение при начальном посеве/использовании.
Расширим модель нашим трейтом, и определим методы authView() и authUpdate(). Для примера он будет возвращать поля user и user_id. Я умышленно взял именно атрибут user_id, чтобы продемонстрировать что user и user_id представлены разными полями. И ограничивать их просмотр нужно отдельно. Также, для примера, я добавил поля $guarded, $hidden, $fillable, что необходимо для настройки массового заполнения модели.
Модель Posts
<?php
namespace App\Models;
use App\Extensions\Traits\GuardedModel;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use GuardedModel;
protected $hidden = ['id'];
protected $guarded = ['created_at', 'updated_at'];
protected $fillable = ['title', 'description', 'user_id'];
protected function authView(): array
{
return ['user_id', 'user'];
}
protected function authUpdate(): array
{
return ['user_id'];
}
public function user()
{
return $this->belongsTo(User::class);
}
}
Часть 5. Авторизация действий со своими ресурсами
Сразу к практике
Данный функционал лишь немного расширяет тот который уже имеется. Для его реализации нам необходимо в целевой модели определить связь user(), а в миграции добавить поле user_id. Далее необходимо немного видоизменить политики. Мы дополним методы, которые принимают экземпляр модели. К примеру так $user->can('delete-self-'. $this->getModelClass()). Но перед тем как проверить доступность удалять свои модели, нужно узнать владельца модели. Для этого создадим в политике метод isOwner(User $user, Model $model). В сочетании этих методов мы можем определить доступность действия. Ниже я приведу только часть класса политики, чтобы не засорять статью повторениями, но при желании — полный код можно посмотреть в репозитории.
Общая политика ModelPolicy
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Database\Eloquent\Model;
abstract class ModelPolicy
{
use HandlesAuthorization;
abstract protected function getModelClass(): string;
public function delete(User $user, Model $model)
{
if ($user->can('delete-' . $this->getModelClass())) {
return true;
}
if ($user->can('delete-self-' . $this->getModelClass())) {
return $this->isOwner($user, $model);
}
return false;
}
private function isOwner(User $user, Model $model): bool
{
if (!empty($user) && method_exists($model, 'user')) {
return $user->getKey() === $model->getRelation('user')->getKey();
}
return false;
}
}
Отмечу также, что для избежания ошибок во время исполнения стоит ввести проверку доступности метода user() или аналогичного в целевой модели.
На этом пока все. Надеюсь, что эта статья была полезна. Мы имеем достаточно полную и гибкую систему авторизации действий. Если есть вопросы — обязательно отвечу. Ну и конечно же конструктивные замечания или предложения также приветствуются. При желании более детально реализацию данного учебного проекта можно посмотреть в репозитории.
Arik
Не кажется, что моделька (так сказать одноклеточная) начала много на себя брать?)
И теперь трейтом (умудрилась заразиться паразитом) теперь знатно зависима от auth()?
Для контекста web, api можно еще простить, но вот дальше как жить с ней не ясно.
Я пока сам не знаю как лучше реализовать, только начал изучать слои и все такое…
Ant-kul Автор
Насчет смешивания ответственности — Вы правы. Буду признателен, если поделитесь идеей, так как сам я не придумал как перегрузить ответственность на политики без оверкода, который, пожалуй, не мой выбор.