rbac 教程

说明

前后端分离的前提下,后台接口使用多路由和多语言 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_permissiontables.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.olicies.olePolicy;
use Spatie.ermission.odels.ole;
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 协议》,转载必须注明作者和本文链接
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 2

rbac可以参考laravel-admin是怎么做

1个月前 评论
____ (楼主) 1个月前

朋友,你的GitHub地址是404,可以处理一下吗?我想看看你的源码

2周前 评论
____ (楼主) 2周前
walt-white (作者) 2周前
walt-white (作者) 2周前

请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!