rbac 教程

____發表於2019-12-20

說明

前後端分離的前提下,後臺介面使用多路由和多語言 rbac

本專案: github 地址

本專案前準備專案: github 地址 基於已開發好的 jwt 和多端路由

更新說明

本篇 rbac 教程有 2 個前提,不同於 laravel-admin 的 rbac

  1. 角色是後臺運營自己管理的
  2. 許可權本地和線上統一,方便維護

安裝 spatie/laravel-permission 擴充套件

composer require spatie/laravel-permission

rbac 教程

釋出 migration 檔案

php artisan vendor:publish --provider="Spatie.ermission.ermissionServiceProvider" --tag="migrations"
rbac 教程
此命令會在 database/migrations 下生成 xxxx_create_permission_tables.php 檔案
rbac 教程

填充 root 使用者

備註:root 使用者將作為超級管理員,不受許可權限制
php artisan make:migration seed_admins_table
rbac 教程
rbac 教程

php artisan make:migration seed_users_table
rbac 教程
rbac 教程

說明

  • 關於許可權驗證, 我是通過路由名和許可權名一一對應的

  • 關於許可權入庫,我將許可權寫在語言檔案裡(為了多語言功能,並且方便管理),通過一份命令檔案將許可權入庫,具體邏輯見下面說明

  • 許可權預設只有 2 級

許可權語言檔案

resources/language/en 下 新建 permission/admin.phppermission/api.php

備註:此處檔名必須和路由檔名一致

resources/language/en/permission/admin.php

<?php

return [
    // 管理員
    [
        'value' => 'admin.admins.index',
        'title' => 'Admin',
        'children' => [
            ['value' => 'admin.admins.show', 'title' => 'View'],
            ['value' => 'admin.admins.store', 'title' => 'Add'],
            ['value' => 'admin.admins.update', 'title' => 'Update'],
            ['value' => 'admin.admins.destroy', 'title' => 'Delete'],
            ['value' => 'admin.admins.syncRoles', 'title' => 'Associated Role'],
        ]
    ],
    // 角色
    [
        'value' => 'admin.roles.index',
        'title' => 'Role',
        'children' => [
            ['value' => 'admin.roles.show', 'title' => 'View'],
            ['value' => 'admin.roles.store', 'title' => 'Add'],
            ['value' => 'admin.roles.update', 'title' => 'Update'],
            ['value' => 'admin.roles.destroy', 'title' => 'Delete'],
            ['value' => 'admin.roles.syncPermissions', 'title' => 'Association Permissions'],
        ],
    ],
    // 許可權
    [
        'value' => 'admin.permissions.index',
        'title' => 'Permission',
    ],
];

resources/language/zh-CN/permission/admin.php

<?php

return [
    // 管理員
    [
        'value' => 'admin.admins.index',
        'title' => '管理員',
        'children' => [
            ['value' => 'admin.admins.show', 'title' => '檢視'],
            ['value' => 'admin.admins.store', 'title' => '新增'],
            ['value' => 'admin.admins.update', 'title' => '修改'],
            ['value' => 'admin.admins.destroy', 'title' => '刪除'],
            ['value' => 'admin.admins.syncRoles', 'title' => '關聯角色'],
        ]
    ],
    // 角色
    [
        'value' => 'admin.roles.index',
        'title' => '角色',
        'children' => [
            ['value' => 'admin.roles.show', 'title' => '檢視'],
            ['value' => 'admin.roles.store', 'title' => '新增'],
            ['value' => 'admin.roles.update', 'title' => '修改'],
            ['value' => 'admin.roles.destroy', 'title' => '刪除'],
            ['value' => 'admin.roles.syncPermissions', 'title' => '關聯許可權'],
        ],
    ],
    // 許可權
    [
        'value' => 'admin.permissions.index',
        'title' => '許可權',
    ],
];

整體目錄結構如下
rbac 教程

許可權入庫命令檔案

php artisan make:command SeedPermission
rbac 教程

config/filesystems.php 下找到 disks => [···]

加入如下程式碼

 'disks' => [
        ·
        ·
        ·
        'root' => [
            'driver' => 'local',
            'root' => '/'
        ]
    ]

修改 app/Commands/SeedPermission.php 為如下程式碼

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Permission;

class SeedPermission extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'seed-permission';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = '填充許可權';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        // 獲取 zh-CN 語言包中的許可權下所有檔案
        $files = Storage::disk('root')->files(resource_path('lang/zh-CN/permission'));

        try {
            is_null(!$files);
        } catch (\Exception $e) {
            report($e);
            return false;
        }

        DB::transaction(function () use ($files) {
            foreach ($files as $file) {
                // 獲取守衛名稱
                $guardName = basename($file, '.php');
                $array = include(resource_path('lang/zh-CN/permission/') . $guardName . '.php');

                $values = [];
                foreach ($array as $arr) {
                    $values[] = $arr['value'];
                    if (isset($arr['children']) && is_array($arr['children'])) {
                        foreach ($arr['children'] as $child) {
                            $values[] = $child['value'];
                        }
                    }
                }
                // 獲取資料庫中的許可權
                $permissions = Permission::select('name')
                    ->where('guard_name', $guardName)
                    ->get()
                    ->pluck('name');
                // 篩選出不同的許可權
                $diff = collect($values)->diff($permissions);
                foreach ($diff as $item) {
                    Permission::create(['name' => $item, 'guard_name' => $guardName]);
                }
                // 反向刪除
                $diff2 = collect($permissions)->diff($values);
                foreach ($diff2 as $item) {
                    Permission::where(['name' => $item, 'guard_name' => $guardName])->delete();
                }
            }
        });

        $this->info('許可權已更新');
    }
}

執行 php artisan seed-permission
rbac 教程
可以看到資料庫 permissions 表已更新
rbac 教程

說明

關於許可權的驗證, 上文已說過,是通過許可權名和路由名一一對應驗證,考慮大部分名稱統一,所以我寫了一箇中介軟體,

如果某些路由不需要驗證,或者某些路由和其他路由共用,則會有一份許可權路由檔案單獨處理。具體見以下程式碼邏輯

建立中介軟體

語言中介軟體

php artisan make:middleware Locale
rbac 教程

app/Http/Middleware/Locale.php

<?php

namespace App\Http\Middleware;

use Closure;

class Locale
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        // 是否設定語言引數,沒有的情況下預設使用中文
        $language = $request->has('language') ? $request->language : 'zh-CN';
        app()->setLocale($language);
        return $next($request);
    }
}

許可權驗證中介軟體

php artisan make:middleware CheckPermissions
rbac 教程

app/Http/Middleware/CheckPermissions.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use Spatie\Permission\Guard;

class CheckPermissions
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        // 超級管理員預設通過所有許可權
        if (Auth::user()->isRoot()) {
            return $next($request);
        }

        // 獲取當前路由名稱
        $currentRouteName = Route::currentRouteName();

        // 獲取當前守衛名稱
        $guardName = Guard::getDefaultName(self::class);
        // 引入當前守衛的許可權檔案
        $routes = include(app_path('Permissions/') . $guardName . '.php');

        // 替換設定了關聯關係的許可權
        if (is_array($routes) && key_exists($currentRouteName, $routes)) {
            $currentRouteName = $routes[$currentRouteName];
        }

        // 當路由不為 null 時,驗證許可權
        if (!is_null($currentRouteName)) {
            Gate::authorize($currentRouteName);
        }

        return $next($request);
}

app/Admin.php 增加如下程式碼

  /**
     * 判斷是否是 root 使用者
     *
     * @return bool
     */
    public function isRoot()
    {
        return $this->name === 'root';
    }

app 下新建 Permissions/admin.phpPermissions/api.php

備註:此處檔名必須和路由檔名一致

app/Permissions/admin.php

<?php
return [
    'admin.admins.current.show' => null,
    'admin.admins.current.update' => null,
];

app/Permissions/api.php

<?php

return [
    'api.users.current.show' => null,
    'api.users.current.update' => null,
];

如上所述,這裡我們為特殊的路由額外設定關聯

註冊中介軟體

app/Http/Kernel.php 中找到 protected $routeMiddleware = [···]

protected $routeMiddleware = [
        ·
        ·
        ·
        // 設定語言中介軟體
        'locale' => \App\Http\Middleware\Locale::class,
        // 檢查許可權
        'check.permissions' => CheckPermissions::class
    ];

修改 routes/admin.php

<?php

Route::group([
    'prefix' => 'v1',
    'middleware' => ['bindings', 'locale']
], function () {
    // 登入介面
    Route::group([
    ], function () {
        // 獲取 token
        Route::post('authorizations', 'AuthorizationsController@store')
            ->name('admin.authorizations.store');
        // 重新整理 token
        Route::put('authorizations/current', 'AuthorizationsController@update')
            ->name('admin.authorizations.update');
        // 刪除 token
        Route::delete('authorizations/current', 'AuthorizationsController@destroy')
            ->name('admin.authorizations.destroy');
    });

    // 需要 token 驗證的介面
    Route::group([
        'middleware' => [
            // 此處認證的是 admin 守衛
            'auth:admin',
            'check.permissions'],
    ], function () {
        /****************************************************管理員*******************************************************/
        // 列表
        Route::get('admins', 'AdminsController@index')
            ->name('admin.admins.index');
        // 檢視當前使用者
        Route::get('admins/current/show', 'AdminsController@currentShow')
            ->name('admin.admins.current.show');
        // 詳情
        Route::get('admins/{admin}', 'AdminsController@show')
            ->name('admin.admins.show');
        // 新增
        Route::post('admins', 'AdminsController@store')
            ->name('admin.admins.store');
        // 修改當前使用者
        Route::patch('admins/current/update', 'AdminsController@currentUpdate')
            ->name('admin.admins.current.update');
        // 修改
        Route::patch('admins/{admin}', 'AdminsController@update')
            ->name('admin.admins.update');
        // 刪除
        Route::delete('admins/{admin}', 'AdminsController@destroy')
            ->name('admin.admins.destroy');
        // 關聯角色
        Route::post('admins/{admin}/syncRoles', 'AdminsController@syncRoles')
            ->name('admin.admins.syncRoles');

        /****************************************************角色*******************************************************/
        // 列表
        Route::get('roles', 'RolesController@index')
            ->name('admin.roles.index');
        // 詳情
        Route::get('roles/{role}', 'RolesController@show')
            ->name('admin.roles.show');
        // 新增
        Route::post('roles', 'RolesController@store')
            ->name('admin.roles.store');
        // 修改
        Route::patch('roles/{role}', 'RolesController@update')
            ->name('admin.roles.update');
        // 刪除
        Route::delete('roles/{role}', 'RolesController@destroy')
            ->name('admin.roles.destroy');
        // 關聯許可權
        Route::post('roles/{role}/syncPermissions', 'RolesController@syncPermissions')
            ->name('admin.roles.syncPermissions');

        /****************************************************許可權*******************************************************/
        // 列表
        Route::get('permissions', 'PermissionsController@index')
            ->name('admin.permissions.index');
    });
});

首先我們來驗證一下

用 admin1 的 token 請求

rbac 教程

此時 403, 訪問被拒絕

用 root 的 token 請求

rbac 教程

成功返回資料

接下來我們給 admin1 使用者新增一個角色,並且該角色擁有檢視管理員列表的許可權

建立角色控制器

php artisan make:controller Admin/RolesController

rbac 教程

app/Http/Controllers/Admin/RolesController.php

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Requests\Admin\RoleRequest;
use App\Http\Resources\RoleResource;
use Illuminate\Http\Request;
use Spatie\Permission\Guard;
use Spatie\Permission\Models\Role;

class RolesController extends Controller
{
    /**
     * 列表
     *
     * @param Request $request
     * @param Role $role
     * @return mixed
     */
    public function index(Request $request, Role $role)
    {
        // 預設 20 頁
        $limit = $request->has('limit') ? $request->limit : 20;

        // 獲取當前守衛名稱
        $guardName = Guard::getDefaultName(self::class);
        $roles = $role->where('guard_name', $guardName)->paginate($limit);
        return RoleResource::collection($roles);
    }

    /**
     * 詳情
     *
     * @param $role
     * @return RoleResource
     */
    public function show($role)
    {
        return new RoleResource($role);
    }

    /**
     * 新增
     *
     * @param RoleRequest $request
     * @param Role $role
     * @return RoleResource
     */
    public function store(RoleRequest $request, Role $role)
    {
        $role = $role::create($request->all());
        return new RoleResource($role);
    }

    /**
     * 修改
     *
     * @param RoleRequest $request
     * @param Role $role
     * @return RoleResource
     * @throws \Illuminate\Auth\Access\AuthorizationException
     */
    public function update(RoleRequest $request, Role $role)
    {
        $role->update($request->all());
        return new RoleResource($role);
    }

    /**
     * 刪除
     *
     * @param Role $role
     * @return \Illuminate\Http\Response
     * @throws \Exception
     */
    public function destroy(Role $role)
    {

        $role->delete();
        return response()->noContent();
    }

    /**
     * 給角色新增許可權
     *
     * @param RoleRequest $request
     * @param Role $role
     * @return RoleResource
     */
    public function syncPermissions(RoleRequest $request, Role $role)
    {
        $role->syncPermissions($request->permissions);
        return new RoleResource($role);
    }
}

建立資源

php artisan make:resource RoleResource

我們用 root 使用者建立一個角色

rbac 教程

然後我們為該角色新增檢視管理員列表和詳情的許可權

rbac 教程

接下來我們為 admin1 使用者關聯角色

修改 app/Admin.php

use Spatie\Permission\Traits\HasRoles;

class Admin extends Authenticatable implements JWTSubject
{
    use HasRoles;

    // admin 表我們使用的是 admin 守衛
    protected $guard_name = 'admin';
}

修改 app/Http/Controllers/Admin/AdminsController.php

 /**
     * 為管理員新增角色
     *
     * @param Request $request
     * @param Admin $admin
     * @return AdminResource
     */
    public function syncRoles(Request $request, Admin $admin)
    {
        $admin->syncRoles($request->roles);
        return new AdminResource($admin);
    }

rbac 教程

此時我們再用 admin1 的 token 去請求管理員列表介面時便會返回資料

許可權列表檢視

php artisan make:controller Admin/PermissionsController

rbac 教程

app/Http/Controllers/Admin/PermissionsController.php

<?php

namespace App\Http\Controllers\Admin;

use Illuminate\Http\Request;

class PermissionsController extends Controller
{
    /**
     * 列表
     *
     * @return array|\Illuminate\Contracts\Translation\Translator|string|null
     */
    public function index()
    {
        $permissions = trans('permission/admin', [], app()->getLocale());
        return response()->json(['data' => $permissions]);
    }
}

rbac 教程
rbac 教程

補充

此時我們角色表是可以跨端操作的,所以我加了 policy 不允許跨端操作

php artisan make:policy RolePolicy

rbac 教程

app/Providers/AuthServiceProvider.php 找到 protected $policies = [···];

use App\Policies\RolePolicy;
use Spatie\Permission\Models\Role;

protected $policies = [
    Role::class => RolePolicy::class
];

修改 app/Policies/RolePolicy.php

use Spatie\Permission\Guard;
use Spatie\Permission\Models\Role;

     /**
     * 不允許跨守衛操作
     *
     * @param Role $role
     * @return bool
     */
    public function authorize($current, Role $role)
    {
        return $role->guard_name == Guard::getDefaultName(self::class);
    }

app/Http/Controllers/Admin/RolesController.php 中找到 show(), update(), destroy()

加入以下程式碼

$this->authorizeForUser(Auth::guard('admin')->user(), 'authorize', $role);

總結

另外一個端以及一些驗證參見具體程式碼,邏輯整體不變。

還有點缺陷的是角色多語言並未實現,之前考慮的一個思路是在 roles 表裡橫向擴充套件,比如 name_en, name_zh_CN。

參考教程

spatie/laravel-permission




分割線

  1. 補充上次未完成的角色多語言

  2. 給使用者關聯角色 bug 修復

思路

感覺不是很好直接修改 name 欄位,所以我在 roles 表加了一個 name_en 的欄位表示英文語言角色名,之前的 name 保留不變,作為中文名。

新增 name_en 欄位

php artisan make:migration add_name_en_to_roles_table –table=roles

database/migrations/xxxx_add_name_en_to_roles_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AddNameEnToRolesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('roles', function (Blueprint $table) {
            $table->string('name_en')->nullable()->after('name')->comment('英文名稱');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('roles', function (Blueprint $table) {
            $table->dropColumn('name_en');
        });
    }
}

修改 Admin 模型

app/Admin.php

   // 許可權名稱語言
    CONST ROLE_NAME = [
        'zh-CN' => 'name',
        'en' => 'name_en'
    ];

備註:這裡的鍵表示語言,值表示對應角色表中的多語言名稱欄位

修改 app/Http/Controllers/Admin/RolesController.php 為如下程式碼

  public function store(RoleRequest $request, Role $role)
    {
        // 如果使用 $request->all(), 這裡會把其他欄位(如 language)也存入表,然後報錯
        $role = $role::create($request->only(Admin::ROLE_NAME));
        return new RoleResource($role);
    }

  public function update(RoleRequest $request, Role $role)
    {
        $this->authorizeForUser(Auth::guard('admin')->user(), 'authorize', $role);
        $role->update($request->only(Admin::ROLE_NAME));
        return new RoleResource($role);
    }

修改 app/Http/Requests/Admin/RoleRequest.php 為如下程式碼

<?php

namespace App\Http\Requests\Admin;

use App\Admin;
use App\Rules\CheckPermission;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Spatie\Permission\Guard;

class RoleRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        // 獲取路由的名稱
        $routeName = $this->route()->getName();

        switch ($routeName) {
            case 'admin.roles.store':
                $rules = [];
                foreach (Admin::ROLE_NAME as $value) {
                    $rules[$value] = [
                        'required', 'string',
                        Rule::unique('roles')->where(function ($query) {
                            return $query->where('guard_name', Guard::getDefaultName(self::class));
                        })
                    ];
                }
                return $rules;
                break;
            case 'admin.roles.update':
                $rules = [];
                foreach (Admin::ROLE_NAME as $value) {
                    $rules[$value] = [
                        'string',
                        Rule::unique('roles')->where(function ($query) {
                            return $query->where('guard_name', Guard::getDefaultName(self::class));
                        })->ignore($this->role->id)
                    ];
                }
                return $rules;
                break;
            case 'admin.roles.syncPermissions':
                return [
                    'permissions' => 'required|array',
                    'permissions.*' => [
                        'required',
                        new CheckPermission()
                    ]
                ];
                break;
        }
    }
}

修改 app/Http/Resources/RoleResource.php 為如下程式碼

 public function toArray($request)
    {
        $data = parent::toArray($request);
        // 獲取當前語言角色名稱
        $data['default_name'] = $data[Admin::ROLE_NAME[app()->getLocale()]];
        return $data;
    }

備註:default_name 表示當前語言的名稱欄位

postman 測試

rbac 教程
rbac 教程

問題 1 修復 (重要)

前端同事在使用的時候發現如果角色名是純數字,在給使用者關聯角色時他其實是判斷的角色表的 id

vendor/spaite/laravel-permission/src/Traits/HasRoles.php 中找到 getStoredRole($role)

 protected function getStoredRole($role): Role
    {
        $roleClass = $this->getRoleClass();

        if (is_numeric($role)) {
            return $roleClass->findById($role, $this->getDefaultGuardName());
        }

        if (is_string($role)) {
            return $roleClass->findByName($role, $this->getDefaultGuardName());
        }

        return $role;
    }

可以看出確實是純數字的情況他會查詢 id,所以給在使用者關聯角色的時候我們直接使用 id 傳值即可

簡單的加一下 rule 驗證

php artisan make:rule CheckRole

app/Rules/CheckRole.php


  public function passes($attribute, $value)
    {
        $roles = Role::where('guard_name', Guard::getDefaultName(self::class))->get()->pluck('id')->toArray();
        return in_array($value, $roles);
    }

問題 2

之前看過一篇帖子說在同一個測試伺服器相同的許可權名稱會有衝突,需要加字首

目前還未驗證過,但是我先加了!

釋出配置檔案

php artisan vendor:publish --provider="Spatie.ermission.ermissionServiceProvider" --tag="config"

rbac 教程

在 config/permission.php 中找到 'cache => [··· 'key']'

修改為如下程式碼

‘key’ => env(‘SPATIE_PERMISSION_CACHE’, ‘spatie.permission.cache’),

.env 中加入

SPATIE_PERMISSION_CACHE=rbac

總結

到此應該整個專案完成。本文主要是提供一些思路和簡單的實現。具體實現可檢視專案, postman 檔案也已放入根目錄下。
如有錯誤,請指正。如果有更好的實現方法或者能優化的地方或者疑惑的地方,也歡迎討論。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章