Laravel 5
Введение в Laravel Auth Gates
На основе статьи https://laravel-news.com/authorization-gates
Laravel Gate представляет собой элегантный механизм для управления доступом пользователей к ресурсам и разрешения действий над ними.
До версии 5.1 программисты обычно использовали такие ACL пакеты как Entrust и Sentinel на ряду с посредниками (middlewares) для авторизации. Недостаток таких решений в том, что разрешения, которыми вы наделяете пользователей, являются всего лишь флагами. В некоторых случаях они не позволяют кодировать сложную логику, поэтому мы вынуждены были дописывать реальную проверку доступа в контроллерах.
В Laravel Gate неважно как именно выглядят ваши модели, вы свободны написать спецификации доступа любой сложности. Вы даже можете использовать ACL пакеты вместе с Laravel Gate. Самое главное в том, что вы отделяете логику доступа от бизнес-логики, устраняя тем самым беспорядок в контроллерах.
Пример работы с Laravel Gate
В этой статье мы создадим приложение для публикации статей. Для пользователей будут предусмотрены две роли (авторы и редакторы) со следующими правами:
-авторы могут создавать статью;
-авторы могут редактировать свои статьи;
-редакторы могут редактировать любые статьи;
-редакторы могут опубликовывать статьи.
Создание проекта
Сначала создадим новый проект Laravel 5.4.
Если вы установили Laravel installer, то
1 |
laravel new blog |
Иначе
1 |
composer create-project --prefer-dist laravel/laravel blog |
Базовая настройка
Создадим базу данных (например, blogbase с utf8mb4_unicode_ci) и укажем параметры доступа в .env файле (для openserver это root и пустой пароль по умолчанию):
1 2 3 4 5 6 7 8 9 10 |
... APP_URL=http://localhost:8000 ... DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=blogbase DB_USERNAME=root DB_PASSWORD= ... |
Таблицы базы данных
Далее создадим модель Post через консоль:
1 |
php artisan make:model Post -m -c |
Параметры -m и -c дополнительно создают миграцию и контроллер для модели.
Далее в файле миграции добавим строки в up-метод:
1 2 3 4 5 6 7 8 9 10 11 |
Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->string('title'); $table->string('slug')->unique(); $table->text('body'); $table->boolean('published')->default(false); $table->unsignedInteger('user_id'); $table->timestamps(); $table->foreign('user_id')->references('id')->on('users'); }); |
Нам понадобятся еще таблица roles и сводная (pivot) таблица user_roles. Мы планируем указывать права в таблице roles аналогично пакету Sentinel.
1 |
php artisan make:model Role -m |
1 2 3 4 5 6 7 |
Schema::create('roles', function (Blueprint $table) { $table->increments('id'); $table->string('name'); $table->string('slug')->unique(); $table->jsonb('permissions')->default('{}'); // jsonb deletes duplicates $table->timestamps(); }); |
1 |
php artisan make:migration create_role_users_table |
1 2 3 4 5 6 7 8 9 10 11 |
Schema::create('role_users', function (Blueprint $table) { $table->unsignedInteger('user_id'); $table->unsignedInteger('role_id'); $table->timestamps(); $table->unique(['user_id','role_id']); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade'); }); ... |
Генерация тестовых данных
1 |
php artisan make:seeder RolesSeeder |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
use Illuminate\Database\Seeder; use App\Role; class RolesSeeder extends Seeder { public function run() { $author = Role::create([ 'name' => 'Author', 'slug' => 'author', 'permissions' => [ 'create-post' => true, ] ]); $editor = Role::create([ 'name' => 'Editor', 'slug' => 'editor', 'permissions' => [ 'update-post' => true, 'publish-post' => true, ] ]); } } |
И не забудем вызвать RolesSeeder из DatabaseSeeder:
1 |
$this->call(\RolesSeeder::class); |
Модели User и Role
Мы не может запустить генерацию без настройки моделей. Давайте добавим fillable-поля в app/Role модель, укажем тип permissions и создадим отношения между app\Role и app\User моделями.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
class Role extends Model { protected $fillable = [ 'name', 'slug', 'permissions', ]; protected $casts = [ 'permissions' => 'array', ]; public function users() { return $this->belongsToMany(User::class, 'role_users'); } public function hasAccess(array $permissions) : bool { foreach ($permissions as $permission) { if ($this->hasPermission($permission)) return true; } return false; } private function hasPermission(string $permission) : bool { return $this->permissions[$permission] ?? false; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
class User extends Authenticatable { use Notifiable; protected $fillable = [ 'name', 'email', 'password', ]; protected $hidden = [ 'password', 'remember_token', ]; public function roles() { return $this->belongsToMany(Role::class, 'role_users'); } /** * Checks if User has access to $permissions. */ public function hasAccess(array $permissions) : bool { // check if the permission is available in any role foreach ($this->roles as $role) { if($role->hasAccess($permissions)) { return true; } } return false; } /** * Checks if the user belongs to role. */ public function inRole(string $roleSlug) { return $this->roles()->where('slug', $roleSlug)->count() == 1; } } |
И вот теперь мы можем запустить миграцию с генерацией:
1 |
php artisan migrate --seed |
Авторизация
Laravel позволяет без лишних усилий получить controllers, routes и views для стандартной системы авторизации с помощью команды:
1 |
php artisan make:auth |
Регистрация
Далее мы должны в форме регистрации добавить выбор роли пользователя.
В Controllers/Auth/RegisterController.php переопределим метод showRegistrationForm:
1 2 3 4 5 6 7 8 9 |
Use App/Role; ... public function showRegistrationForm() { $roles = Role::orderBy('name')->pluck('name', 'id'); return view('auth.register', compact('roles')); } |
Добавим select в resources/views/auth/register.blade.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
... <div class="form-group{{ $errors->has('role') ? ' has-error' : '' }}"> <label for="role" class="col-md-4 control-label">User role</label> <div class="col-md-6"> <select id="role" class="form-control" name="role" required> @foreach($roles as $id => $role) <option value="{{$id}}">{{$role}}</option> @endforeach </select> @if ($errors->has('role')) <span class="help-block"> <strong>{{ $errors->first('role') }}</strong> </span> @endif </div> </div> ... |
Осталось добавить валидацию для нового поля. Для этого изменим метод validator в RegisterController:
1 2 3 4 5 6 7 8 9 10 11 |
... protected function validator(array $data) { return Validator::make($data, [ 'name' => 'required|max:255', 'email' => 'required|email|max:255|unique:users', 'password' => 'required|min:6|confirmed', 'role' => 'required|exists:roles,id', // validating role ]); } ... |
Переопределим метод create в контроллере (этот метод трейта RegisterUsers) и добавим роль к зарегистрированному пользователю.
1 2 3 4 5 6 7 8 9 10 11 12 |
... protected function create(array $data) { $user = User::create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => bcrypt($data['password']), ]); $user->roles()->attach($data['role']); return $user; } ... |
Изменим редирект в RegisterController и в LoginController:
1 2 3 |
... protected $redirectTo = '/'; ... |
Запуск приложения
После запуска сервера (с помощью php artisan serve, например), вы сможете регистрировать нового пользователя с указанием его роли прямо в браузере (ссылка на форму регистрации …/register).
Определение политик
Изменим app/Providers/AuthServiceProvider.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
use App\Post; ... public function boot() { $this->registerPolicies(); $this->registerPostPolicies(); } public function registerPostPolicies() { Gate::define('create-post', function ($user) { return $user->hasAccess(['create-post']); }); Gate::define('update-post', function ($user, Post $post) { return $user->hasAccess(['update-post']) or $user->id == $post->user_id; }); Gate::define('publish-post', function ($user) { return $user->hasAccess(['publish-post']); }); Gate::define('see-all-drafts', function ($user) { return $user->inRole('editor'); }); } |
Routes
Изменим routes/web.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
Auth::routes(); Route::get('/', 'PostController@index'); Route::get('/posts', 'PostController@index')->name('list_posts'); Route::group(['prefix' => 'posts'], function () { Route::get('/drafts', 'PostController@drafts') ->name('list_drafts') ->middleware('auth'); Route::get('/show/{id}', 'PostController@show') ->name('show_post'); Route::get('/create', 'PostController@create') ->name('create_post') ->middleware('can:create-post'); Route::post('/create', 'PostController@store') ->name('store_post') ->middleware('can:create-post'); Route::get('/edit/{post}', 'PostController@edit') ->name('edit_post') ->middleware('can:update-post,post'); Route::post('/edit/{post}', 'PostController@update') ->name('update_post') ->middleware('can:update-post,post'); // using get to simplify Route::get('/publish/{post}', 'PostController@publish') ->name('publish_post') ->middleware('can:publish-post'); }); |
Модель Post
Определим fillable-поля, добавим scope и связи:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
... class Post extends Model { protected $fillable = [ 'title', 'slug', 'body', 'user_id', ]; public function owner() { return $this->belongsTo(User::class); } public function scopePublished($query) { return $query->where('published', true); } public function scopeUnpublished($query) { return $query->where('published', false); } } |
PostController
Добавим метод index для отображения списка всех опубликованных статей:
1 2 3 4 5 6 7 8 |
use App\Post; ... public function index() { $posts = Post::published()->paginate(); return view('posts.index', compact('posts')); } ... |
Далее изменим resources/views/home.blade.php и переименуем в resources/views/posts/index.blade.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
@extends('layouts.app') @section('content') <div class="container"> <div class="row"> <div class="col-md-8 col-md-offset-2"> <div class="panel panel-default"> <div class="panel-heading"> Posts @can('create-post') <a class="pull-right btn btn-sm btn-primary" href="{{ route('create_post') }}">New</a> @endcan </div> <div class="panel-body"> <div class="row"> @foreach($posts as $post) <div class="col-sm-6 col-md-4"> <div class="thumbnail"> <div class="caption"> <h3><a href="{{ route('edit_post', ['id' => $post->id]) }}">{{ $post->title }}</a></h3> <p>{{ str_limit($post->body, 50) }}</p> @can('update-post', $post) <p> <a href="{{ route('edit_post', ['id' => $post->id]) }}" class="btn btn-sm btn-default" role="button">Edit</a> </p> @endcan </div> </div> </div> @endforeach </div> </div> </div> </div> </div> </div> @endsection |
Если мы посещаем страницу со статьями в гостевом режиме, то мы не должны видеть кнопку new.
Создание статей
Добавим метод create в PostController:
1 2 3 4 5 6 |
... public function create() { return view('posts.create'); } ... |
Создадим файл posts/create.blade.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
@extends('layouts.app') @section('content') <div class="container"> <div class="row"> <div class="col-md-8 col-md-offset-2"> <div class="panel panel-default"> <div class="panel-heading">New Post</div> <div class="panel-body"> <form class="form-horizontal" role="form" method="POST" action="{{ route('store_post') }}"> {{ csrf_field() }} <div class="form-group{{ $errors->has('title') ? ' has-error' : '' }}"> <label for="title" class="col-md-4 control-label">Title</label> <div class="col-md-6"> <input id="title" type="text" class="form-control" name="title" value="{{ old('title') }}" required autofocus> @if ($errors->has('title')) <span class="help-block"> <strong>{{ $errors->first('title') }}</strong> </span> @endif </div> </div> <div class="form-group{{ $errors->has('body') ? ' has-error' : '' }}"> <label for="body" class="col-md-4 control-label">Body</label> <div class="col-md-6"> <textarea name="body" id="body" cols="30" rows="10" class="form-control" required>{{ old('body') }}</textarea> @if ($errors->has('body')) <span class="help-block"> <strong>{{ $errors->first('body') }}</strong> </span> @endif </div> </div> <div class="form-group"> <div class="col-md-6 col-md-offset-4"> <button type="submit" class="btn btn-primary"> Create </button> <a href="{{ route('list_posts') }}" class="btn btn-primary"> Cancel </a> </div> </div> </form> </div> </div> </div> </div> </div> @endsection |
Далее добавим метод store для сохранения статьи. Дополнительно нам потребуется создать StorePost для валидации с помощью команды php artisan make:request StorePost.
1 2 3 4 5 6 7 8 9 10 11 12 |
use App\Http\Requests\StorePost as StorePostRequest; use Auth; ... public function store(StorePostRequest $request) { $data = $request->only('title', 'body'); $data['slug'] = str_slug($data['title']); $data['user_id'] = Auth::user()->id; $post = Post::create($data); return redirect()->route('edit_post', ['id' => $post->id]); } ... |
Файл app/Http/Requests/StorePost.php:
1 2 3 4 5 6 7 8 9 10 11 12 |
public function authorize() { return true; // gate will be responsible for access } public function rules() { return [ 'title' => 'required|unique:posts', 'body' => 'required', ]; } |
Черновики
Авторы могут создавать статьи, затем редакторы публикуют их. Для этого мы создадим страницу для черновиков или неопубликованных статей, которая будет доступна только аутентифицированным пользователям.
Для показа черновиков добавим метод drafts в PostController:
1 2 3 4 5 6 7 8 9 10 11 12 |
use Gate; ... public function drafts() { $postsQuery = Post::unpublished(); if(Gate::denies('see-all-drafts')) { $postsQuery = $postsQuery->where('user_id', Auth::user()->id); } $posts = $postsQuery->paginate(); return view('posts.drafts', compact('posts')); } ... |
И создадим вид posts/drafts.blade.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
@extends('layouts.app') @section('content') <div class="container"> <div class="row"> <div class="col-md-8 col-md-offset-2"> <div class="panel panel-default"> <div class="panel-heading"> Drafts <a class="btn btn-sm btn-default pull-right" href="{{ route('list_posts') }}">Return</a> </div> <div class="panel-body"> <div class="row"> @foreach($posts as $post) <div class="col-sm-6 col-md-4"> <div class="thumbnail"> <div class="caption"> <h3><a href="{{ route('show_post', ['id' => $post->id]) }}">{{ $post->title }}</a></h3> <p>{{ str_limit($post->body, 50) }}</p> <p> @can('publish-post') <a href="{{ route('publish_post', ['id' => $post->id]) }}" class="btn btn-sm btn-default" role="button">Publish</a> @endcan <a href="{{ route('edit_post', ['id' => $post->id]) }}" class="btn btn-default" role="button">Edit</a> </p> </div> </div> </div> @endforeach </div> </div> </div> </div> </div> </div> @endsection |
Еще нам необходимо добавить ссылку в layouts/app.blade.php для доступа к черновику:
1 2 3 4 5 |
... <ul class="dropdown-menu" role="menu"> <li> <a href="{{ route('list_drafts') }}">Drafts</a> ... |
Редактирование статей
Давайте добавим возможность редактировать и публиковать статьи. Новые методы в PostController:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
use App\Http\Requests\UpdatePost as UpdatePostRequest; ... public function edit(Post $post) { return view('posts.edit', compact('post')); } public function update(Post $post, UpdatePostRequest $request) { $data = $request->only('title', 'body'); $data['slug'] = str_slug($data['title']); $post->fill($data)->save(); return back(); } |
Дополнительно создадим UpdatePost с помощью php artisan make:request UpdatePost.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
use Illuminate\Validation\Rule; ... public function authorize() { return true; } public function rules() { $id = $this->route('post')->id; return [ 'title' => [ 'required', Rule::unique('posts')->where('id', '<>', $id), ], 'body' => 'required', ]; } |
Теперь вид posts/edit.blade.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
@extends('layouts.app') @section('content') <div class="container"> <div class="row"> <div class="col-md-8 col-md-offset-2"> <div class="panel panel-default"> <div class="panel-heading">Update Post</div> <div class="panel-body"> <form class="form-horizontal" role="form" method="POST" action="{{ route('update_post', ['post' => $post->id]) }}"> {{ csrf_field() }} <div class="form-group{{ $errors->has('title') ? ' has-error' : '' }}"> <label for="title" class="col-md-4 control-label">Title</label> <div class="col-md-6"> <input id="title" type="text" class="form-control" name="title" value="{{ old('title', $post->title) }}" required autofocus> @if ($errors->has('title')) <span class="help-block"> <strong>{{ $errors->first('title') }}</strong> </span> @endif </div> </div> <div class="form-group{{ $errors->has('body') ? ' has-error' : '' }}"> <label for="body" class="col-md-4 control-label">Body</label> <div class="col-md-6"> <textarea name="body" id="body" cols="30" rows="10" class="form-control" required>{{ old('body', $post->body) }}</textarea> @if ($errors->has('body')) <span class="help-block"> <strong>{{ $errors->first('body') }}</strong> </span> @endif </div> </div> <div class="form-group"> <div class="col-md-6 col-md-offset-4"> <button type="submit" class="btn btn-primary"> Update </button> @can('publish-post') <a href="{{ route('publish_post', ['post' => $post->id]) }}" class="btn btn-primary"> Publish </a> @endcan <a href="{{ route('list_posts') }}" class="btn btn-primary"> Cancel </a> </div> </div> </form> </div> </div> </div> </div> </div> @endsection |
Публикация черновиков
Для простоты мы сделаем публикацию через get-запрос. Добавим метод publish в PostController:
1 2 3 4 5 6 7 8 |
... public function publish(Post $post) { $post->published = true; $post->save(); return back(); } ... |
Показ статьи
Добавим метод show в PostController:
1 2 3 4 5 6 |
... public function show($id) { $post = Post::published()->findOrFail($id); return view('posts.show', compact('post')); } |
И вид posts/show.blade.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@extends('layouts.app') @section('content') <div class="container"> <div class="row"> <div class="col-md-8 col-md-offset-2"> <div class="panel panel-default"> <div class="panel-heading"> {{ $post->title }} <a class="btn btn-sm btn-default pull-right" href="{{ route('list_posts') }}">Return</a> </div> <div class="panel-body"> {{ $post->body }} </div> </div> </div> </div> </div> @endsection |
404
Добавим еще один вид errors/404.blade.php для сообщения о несуществующей странице и возможности перехода на предыдущую страницу:
1 2 3 4 5 6 |
<html> <body> <h1>404</h1> <a href="/">Back</a> </body> </html> |
Итоги
Наше приложение позволяет пользователям выполнять только те действия, на которые у них есть разрешения. При этом мы не использовали никаких сторонних пакетов. И самое важное: мы отделили логику доступа от бизнес-логики. Если вдруг ACL спецификации изменятся, нам не придется изменять контроллеры.
@перевод www.itmathrepetitor.ru