Laravel實現許可權控制

健康搬磚人發表於2020-09-30

一、RBAC

RBAC: role base access control 基於角色的使用者訪問許可權控制許可權,就是許可權分配給角色,角色又分配給使用者。

一個使用者對應一個角色,一個角色對應多個許可權,一個使用者對應使用者組,一個使用者組對應多個許可權。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-Be9NmnKt-1601438441622)(1.jpg)]

二、認證授權邏輯

登入邏輯:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-OI9DwHAm-1601438441624)(2.jpg)]

許可權控制邏輯:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-oCiQK0wb-1601438441627)(3.jpg)]

三、具體實現

建立表的遷移檔案

使用者:

  • 建立model和遷移檔案:
php artisan make:model Models/User -m
  • 修改遷移檔案:
class CreateUsersTable extends Migration
{

    /**
     * 後臺使用者表
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->bigIncrements('id');
            // 角色
            $table->unsignedInteger('role_id')->default(0)->comment('角色ID');
            $table->string('username', 50)->comment("賬號");
            $table->string('truename', 20)->default('未知')->comment("賬號");
            $table->string('password', 255)->comment('密碼');
            $table->string('email', 50)->nullable()->comment('郵箱');
            $table->string('phone', 15)->default('')->comment('手機號碼');
            $table->enum('sex', ['先生','女士'])->default('先生')->comment('性別');
            $table->char('last_ip', 15)->default('')->comment('登入IP');
            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
}

角色:

  • 建立model和遷移檔案:
php artisan make:model Models/Role -m
  • 修改遷移檔案:
class CreateRolesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('roles', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name', 20)->comment('角色名稱');

            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('roles');
    }
}

許可權:

  • 建立model和遷移檔案:
php artisan make:model Models/Node -m
  • 修改遷移檔案:
class CreateNodesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('nodes', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name',50)->comment('節點名稱');
            $table->string('route_name', 100)->nullable()->comment('路由別名,許可權認證標識');
            $table->unsignedInteger('pid')->default(0)->comment('上級ID');
            $table->enum('is_menu', ['0','1'])->comment('是否是選單');
            $table->softDeletes();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('nodes');
    }
}
  • 新增使用者、角色一對一關聯關係
class User extends Authenticatable
{
    public function role()
    {
        return $this->belongsTo(Role::class,'role_id');
    }
}
  • 新增角色、節點多對多關聯關係
class Role extends Model
{
    public function nodes()
    {
        // 參1 關聯模型
        // 參2 中間表的表名,沒有字首
        // 參3 本模型對應的外來鍵ID
        // 參4 關聯模型對應的外來鍵ID
        return $this->belongsToMany(
            Node::class,
            'role_node',
            'role_id',
            'node_id');
    }
}
  • 新增節點展示方法
class Node extends Model
{
    /**
     * 獲取所有許可權節點
     *
     * @return array
     */
    public function getAllList(){
        $data = self::get()->toArray();
        return $this->treeLevel($data);
    }

    /**
     * 陣列的合併,並加上html標識字首
     *
     * @param array $data
     * @param int $pid
     * @param string $html
     * @param int $level
     * @return array
     */
    public function treeLevel(array $data, int $pid = 0, string $html = '--', int $level = 0) {
        static $arr = [];
        foreach ($data as $val) {
            if ($pid == $val['pid']) {
                // 重複一個字元多少次
                $val['html'] = str_repeat($html, $level * 2);
                $val['level'] = $level + 1;
                $arr[] = $val;
                $this->treeLevel($data, $val['id'], $html, $val['level']);
            }
        }
        return $arr;
    }


    /**
     * 資料多層級
     *
     * @param array $data
     * @param int $pid
     * @return array
     */
    public function subTree(array $data, int $pid = 0) {
        // 返回的結果
        $arr = [];
        foreach ($data as $val) {
            // 給定的PID是當前記錄的上級ID
            if ($pid == $val['pid']) {
                // 遞迴
                $val['sub'] = $this->subTree($data,$val['id']);
                $arr[] = $val;
            }
        }
        return $arr;
    }

}

建立控制器

php artisan make:controller Api/Admin/NodeController
php artisan make:controller Api/Admin/UserController
php artisan make:controller Api/Admin/RoleController

修改路由檔案

新增角色、節點、使用者列表

    Route::post('/admin/user/login', 'AuthController@login')->name('admin.index');
    Route::get('/admin/user/logout', 'AuthController@logout')->name('admin.logout');

    Route::group(['prefix' => '/admin','middleware' => 'adminAuth','as' => 'admin.'], function () {
        // 角色列表
        Route::group(['prefix' => 'role','as' => 'role.'], function () {
            Route::get('', 'RoleController@search')->name('search');
            Route::get('/{id}', 'RoleController@show')->where('id', '\d+')->name('show');
            Route::put('/{id}', 'RoleController@update')->where('id', '\d+')->name('update');
            Route::post('/', 'RoleController@store')->name('store');
            Route::delete('/{id}', 'RoleController@destroy')->where('id', '\d+')->name('destroy');
            // 獲取某一角色的許可權列表
            Route::get('/node/{id}', 'RoleController@nodeList')->name('node');
            // 更新某一角色的許可權列表
            Route::post('/node/{role}', 'RoleController@saveNode')->name('node');
        });

        // 節點列表
        Route::group(['prefix' => 'node','as' => 'node.'], function () {
            Route::get('', 'NodeController@search')->name('search');
            Route::get('/{id}', 'NodeController@show')->where('id', '\d+')->name('show');
            Route::put('/{id}', 'NodeController@update')->where('id', '\d+')->name('update');
            Route::post('/', 'NodeController@store')->name('store');
            Route::delete('/{id}', 'NodeController@destroy')->where('id', '\d+')->name('destroy');
        });

        // 使用者列表
        Route::group(['prefix' => 'user','as' => 'user.'], function () {
            Route::get('', 'UserController@search')->name('search');
            Route::get('/{id}', 'UserController@show')->where('id', '\d+')->name('show');
            Route::put('/{id}', 'UserController@update')->where('id', '\d+')->name('update');
            Route::post('/', 'UserController@store')->name('store');
            Route::delete('/{id}', 'UserController@destroy')->where('id', '\d+')->name('destroy');
        });

    });// end-auth

路由別名

命名規則: 格式為XXX.YYY.ZZZ,以角色介面為例,假設路由介面為 api/admin/role/search ,則設定該路由的別名為 admin.role.search,以此類推, admin.node.searchadmin.role.update

設定路由別名主要是為了許可權鑑定的時候方便處理對路由的鑑權。

擴充FormRequest驗證

使用自定義 FormRequest 類,該類整合自 Http\Request,但是針對每一種請求都要定義一個 FormRequest,比較麻煩,因此在控制器方法裡只注入一個 Request,根據模板設計模式,針對不同的場景,擴充不同的驗證規則。

  • 第一步:建立AbstractRequest類
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Str;

class AbstractRequest extends FormRequest
{
    public $scenes = [];
    public $currentScene;               //當前場景
    public $autoValidate = false;       //是否注入之後自動驗證
    public $extendRules;
    public $messages;                   //錯誤訊息提示

    public function authorize()
    {
        return true;
    }

    /**
     * 設定場景
     *
     * @param $scene
     * @return $this
     */
    public function scene($scene)
    {
        $this->currentScene = $scene;
        return $this;
    }

    /**
     * 使用擴充套件rule
     *
     * @param string $name
     * @return AbstractRequest
     */
    public function with($name = '')
    {
        if (is_array($name)) {
            $this->extendRules = array_merge($this->extendRules[], array_map(function ($v) {
                return Str::camel($v);
            }, $name));
        } else if (is_string($name)) {
            $this->extendRules[] = Str::camel($name);
        }

        return $this;
    }

    /**
     * 覆蓋自動驗證方法
     */
    public function validateResolved()
    {
        if ($this->autoValidate) {
            $this->handleValidate();
        }
    }

    /**
     * 驗證方法
     *
     * @param string $scene
     * @throws \Illuminate\Auth\Access\AuthorizationException
     * @throws \Illuminate\Validation\ValidationException
     */
    public function validate($scene = '')
    {
        if ($scene) {
            $this->currentScene = $scene;
        }
        $this->handleValidate();
    }

    /**
     * 重寫返回錯誤資訊格式
     *
     * @param Validator $validator
     */
    public function failedValidation($validator)
    {

        $error= $validator->errors()->all();
        // $error = $validator;
        throw  new HttpResponseException(response()->json(['code'=>404,'message'=>$error[0]]));

    }

    /**
     * 根據場景獲取規則
     *
     * @return array|mixed
     */
    public function getRules()
    {
        $rules = $this->container->call([$this, 'rules']);
        $newRules = [];
        if ($this->extendRules) {
            $extendRules = array_reverse($this->extendRules);
            foreach ($extendRules as $extendRule) {
                if (method_exists($this, "{$extendRule}Rules")) {   //合併場景規則
                    $rules = array_merge($rules, $this->container->call(
                        [$this, "{$extendRule}Rules"]
                    ));
                }
            }
        }
        if ($this->currentScene && isset($this->scenes[$this->currentScene])) {
            $sceneFields = is_array($this->scenes[$this->currentScene])
                ? $this->scenes[$this->currentScene] : explode(',', $this->scenes[$this->currentScene]);
            foreach ($sceneFields as $field) {
                if (array_key_exists($field, $rules)) {
                    $newRules[$field] = $rules[$field];
                }
            }
            return $newRules;
        }
        return $rules;
    }

    /**
     * 覆蓋設定 自定義驗證器
     *
     * @param $factory
     * @return mixed
     */
    public function validator($factory)
    {
        return $factory->make(
            $this->validationData(), $this->getRules(),
            $this->messages, $this->attributes()
        );
    }

    /**
     * 最終驗證方法
     *
     * @throws \Illuminate\Auth\Access\AuthorizationException
     * @throws \Illuminate\Validation\ValidationException
     */
    protected function handleValidate()
    {
        if (!$this->passesAuthorization()) {
            $this->failedAuthorization();
        }
        $instance = $this->getValidatorInstance();
        if ($instance->fails()) {
            $this->failedValidation($instance);
        }
    }

}
  • 第二步:
  • 建立UserRequest繼承AbstractRequest
class UserRequest extends AbstractRequest
{
    function __construct()
    {
        $this->messages = $this->messages();
    }

    // 不同驗證場景
    public $scenes = [
        'login' => 'username,password',
    ];

    /**
     * 獲取已定義驗證規則的錯誤訊息
     *
     * @return array
     */
    public function messages()
    {
        return [
            'name.required' => '使用者名稱稱不得為空',
            'name.unique' => '使用者名稱稱不得重複',
            'password.required' => '密碼不得為空',
        ];
    }

    /**
     * 全部的驗證規則
     *
     * @return array
     */
    public function rules()
    {
        $id = $this->route('id'); //獲取當前需要排除的id,這裡的 id 是 路由 {} 中的引數
        $rules = [
            'password' => 'required|string'
        ];
        // 修改節點時的驗證規則
        if($id){
            $rules['username'] = 'required|string|unique:users,username,'.$id;
            return $rules;
        }
        // 新增節點時的驗證規則
        $rules['username'] = 'required|string|unique:users,username';
        return $rules;
    }

    public function loginRules(){
        return [
            'password' => 'required|string',
            'username' => 'required|string',
        ];
    }

}
  • 建立NodeRequest繼承AbstractRequest
class NodeRequest extends AbstractRequest
{

    function __construct()
    {
        $this->messages = $this->messages();
    }

    /**
     * 獲取已定義驗證規則的錯誤訊息
     *
     * @return array
     */
    public function messages()
    {
        return [
            'name.required' => '節點名稱不得為空',
            'name.unique' => '節點名稱不得重複',
            'route_name.required' => '路由別名不得為空',
            'route_name.unique' => '路由別名不得重複',
            'pid.required' => '上級節點不得為空',
            'pid.numeric' => '上級節點必須為數字',
            'is_menu.required' => '是否是選單不得為空',
            'is_menu.numeric' => '是否是選單型別必須為數字',
            'is_menu.in' => '是否是選單值必須為0或1',
        ];
    }

    /**
     * 全部的驗證規則
     *
     * @return array
     */
    public function rules()
    {
        $id = $this->route('id'); //獲取當前需要排除的id,這裡的 id 是 路由 {} 中的引數
        $rules = [
            'pid' => 'required|numeric',
            'is_menu' => 'required|numeric|in:0,1',
            'status' => 'required|numeric|in:0,1'
        ];

        // 修改節點時的驗證規則
        if($id){
            $rules['name'] = 'required|string|unique:nodes,name,'.$id;
            $rules['route_name'] = 'required|string|unique:nodes,route_name,'.$id;
            return $rules;
        }
        // 新增節點時的驗證規則
        $rules['name'] = 'required|string|unique:nodes,name';
        $rules['route_name'] = 'required|string|unique:nodes,route_name';
        return $rules;
    }

}
  • 建立RoleRequest繼承AbstractRequest
class RoleRequest extends AbstractRequest
{
    function __construct()
    {
        $this->messages = $this->messages();
    }

    /**
     * 獲取已定義驗證規則的錯誤訊息
     *
     * @return array
     */
    public function messages()
    {
        return [
            'name.required' => '角色名稱不得為空',
            'name.unique' => '角色名稱不得重複',
        ];
    }

    /**
     * 全部的驗證規則
     *
     * @return array
     */
    public function rules()
    {
        $id = $this->route('id'); //獲取當前需要排除的id,這裡的 id 是 路由 {} 中的引數
        $rules = [];

        // 修改節點時的驗證規則
        if($id){
            $rules['name'] = 'required|string|unique:roles,name,'.$id;
            return $rules;
        }
        // 新增節點時的驗證規則
        $rules['name'] = 'required|string|unique:roles,name';
        return $rules;
    }
}

至此,驗證規則全部寫完。

參考部落格:https://learnku.com/laravel/t/31215

中介軟體過濾

  • 建立中介軟體
php artisan make:middleware AdminAuthenticated 

Kernel.php檔案裡$routeMiddleware新增

        // 後臺
        'adminAuth' => \App\Http\Middleware\AdminAuthenticated::class,
  • 白名單

考慮到業務本身的原因,這裡新增一個許可權白名單,在config目錄下建立rbac.php檔案,配置使用者白名單以及路由白名單,以便後續業務的延申擴充。

<?php
    return [
        // 超級管理員
        "super" => 'admin',
        // 預設允許通過的路由
        "allow_route" => [
            'admin.index',
            'admin.logout'
        ]
    ];

需要使用時,config('rbac.super')、config('rbac.allow_route')讀取白名單的資訊即可

  • 中介軟體過濾:
    public function handle($request, Closure $next, $guard = null)
    {
        if (!auth()->check()){
            return response()->json(['code'=>401, 'msg' => '您未登入!']);
        }
        $allow_node = session('admin.auth');
        $auths = is_array($allow_node) ? array_filter($allow_node):[];
        // 合併陣列
        $auths = array_merge($auths, config('rbac.allow_route'));
        // 當前訪問的路由
        $currentRoute = $request->route()->getName();
        $request->auths = $auths;
        // 許可權判斷
        if (auth()->user()->username != config('rbac.super') &&
            !in_array($currentRoute, $auths)){
            return response()->json(['code' => 400, 'msg' => '您沒有許可權訪問']);
        }

        return $next($request);
    }

控制器方法

  • 登入
    public function login(UserRequest $request)
    {
        $request->scene('login') ->with('login')->validate();
        $data = $request->input();
        $user = User::where('username', $data['username'])->first();
        if (!$user)
            return $this->json_output(404, '此使用者不存在!');
        if (auth()->attempt(['username' => $data['username'], 'password' => $data['password']])) {
            $user->last_login_ip = $request->ip();
            $user->save();
            // config配置rbac白名單
            if (config('rbac.super') != $data['username']){
                $userModel = auth()->user();
                $roleModel = $userModel->role;
                $nodeArr = $roleModel->nodes()->pluck('name','nodes.id')->toArray();
                // 許可權保持到session中
                session(['admin.auth' => $nodeArr]);
            }else{
                session(['admin.auth' => true]);
            }
            return $this->json_output(200, '登入成功', ['user' => $user]);
        }
        // 登入失敗
        return $this->json_output(403, '賬號或者密碼錯誤');
    }
  • 退出登入
    public function logout()
    {
        auth()->logout();
        return $this->json_output(200, '登出成功');
    }
  • 角色控制器RoleController
class RoleController extends Controller{
    // 此處省略增刪查改的方法 ...
    
    public function nodeList($id)
    {
        $role = Role::find($id);
        // 獲取所有節點許可權
        $nodeAll = (new Node)->getAllList();
        $nodes = $role->nodes()->pluck('nodes.id')->toArray();
        return $this->json_output(200, '許可權資訊',compact('nodeAll', 'nodes', 'role'));
    }

    public function saveNode(Request $request, Role $role)
    {
        // 關聯模型的資料同步
        // sync 方法接收一個 ID 陣列以替換中間表的記錄。
        // 中間表記錄中,所有未在 ID 陣列中的記錄都將會被移除。
        // 所以該操作結束後,只有給出陣列的 ID 會被保留在中間表中:
        $role->nodes()->sync($request->get('node'));
        return $this->json_output(200, '更新許可權成功');
    }   
}
  • 使用者控制器UserController
class UserController extends Controller
{
    // 此處省略部分增刪查改的方法 ...
    
    public function store(UserRequest $request)
    {
        $data = $request->input();
        $request->validate();
        $user = new User;
        foreach ($data as $key => $value) {
            if (is_null($value)) continue;
            if ($key == 'password') {
                // 此處加密為了跟attempt()方法對應
                $user->$key = bcrypt($value);
                continue;
            }
            $user->$key = $value;
        }
        $user->save();
        return $this->json_output(200, '建立成功', $user);
    }
}
  • 節點控制器NodeController
class NodeController extends Controller{
        // 此處省略增刪查改的方法 ...
}

相關文章