若依管理系統RuoYi-Vue(二):許可權系統設計詳解

狂盜一枝梅發表於2021-02-20

若依Vue系統中的許可權管理部分的功能都集中在了系統管理選單模組中,如下圖所示。其中許可權部分主要涉及到了使用者管理、角色管理、選單管理、部門管理這四個部分。

image-20210218152741496

一、若依Vue系統中的許可權分類

根據觀察,若依Vue系統中的許可權分為以下幾類

  • 選單許可權:使用者登入系統之後能看到哪些選單
  • 按鈕許可權:使用者在一個頁面上能看到哪些按鈕,比如新增、刪除等按鈕
  • 介面許可權:使用者帶著認證資訊請求後端介面,是否有許可權訪問,該介面和前端頁面上的按鈕一一對應
  • 資料許可權:使用者有許可權訪問後端某個介面,但是不同的使用者相同的介面相同的入參,根據許可權大小不同,返回的結果應當不一樣——許可權大的能夠看到的資料更多。

1.選單許可權

這個比較好理解,擁有不同許可權的使用者登入系統之後看到的選單是不一樣的,從新建選單到給一個使用者分配選單許可權,上一篇文章已經講過,不贅述。

使用者登入之後會請求後端的com.ruoyi.web.controller.system.SysLoginController#getRouters介面獲取登入使用者的選單資料:

select distinct m.menu_id, m.parent_id, m.menu_name, m.path, m.component, m.visible, m.status, ifnull(m.perms,'') as perms, m.is_frame, m.is_cache, m.menu_type, m.icon, m.order_num, m.create_time
		from sys_menu m
			 left join sys_role_menu rm on m.menu_id = rm.menu_id
			 left join sys_user_role ur on rm.role_id = ur.role_id
			 left join sys_role ro on ur.role_id = ro.role_id
			 left join sys_user u on ur.user_id = u.user_id
		where u.user_id = #{userId} and m.menu_type in ('M', 'C') and m.status = 0  AND ro.status = 0
		order by m.parent_id, m.order_num

選單型別(M目錄 C選單 F按鈕);選單狀態(0顯示 1隱藏)

這是典型的使用者-角色-選單模型。

前端會根據該介面返回的資料渲染出不同的選單。

2.按鈕許可權

新增按鈕許可權和新增選單差不多,下圖是我在新聞列表頁面上新增了一個按鈕叫做新聞新增,該按鈕的許可權分配和選單的許可權分配方法是一樣的。

image-20210218164124429

image-20210218164412579

3.介面許可權

每一個按鈕基本上都會對應著一個後端的介面,前端會根據許可權標誌顯示或者隱藏按鈕,但是如果使用者不點選按鈕,直接通過http請求工具請求後端咋辦?所以介面許可權也是要有的,該許可權和按鈕上許可權完全一致。

若依系統使用了SpringSecurity框架,介面許可權都是基於註解@PreAuthorize實現的,比如,使用者管理頁面中的修改使用者按鈕對應的後端介面長這個樣子

    @PreAuthorize("@ss.hasPermi('system:user:edit')")
    @Log(title = "使用者管理", businessType = BusinessType.UPDATE)
    @PutMapping
    public AjaxResult edit(@Validated @RequestBody SysUser user)
    {
        ...
    }

和其對應的前端按鈕許可權標誌一樣

image-20210218165254074

如果沒有許可權訪問介面,則會返回類似如下資訊:

{
"msg": "請求訪問:/system/user/list,認證失敗,無法訪問系統資源",
"code": 401
}

4.資料許可權

使用者有許可權訪問後端某個介面,但是不同的使用者相同的介面相同的入參,根據許可權大小不同,返回的結果應當不一樣——許可權大的能夠看到的資料更多。

體現在若依Vue系統中,舉個例子,現在若以系統中有兩個使用者,一個是超級管理員admin,一個是普通使用者kdyzm

image-20210218173156066

它們兩者均有使用者管理、選單管理、角色管理許可權,所以它們能夠看到相應的選單並作出相應的操作,比如刪除、新增、修改等。這裡有個問題,kdyzm應當只能看到一部分資料,而超級管理員應當能夠看到所有資料,在若依系統中,是通過對角色資料許可權修改進行控制的。

image-20210218173632200

image-20210218173725363

所以,相同的許可權,超級管理員能夠看到的使用者數量和普通使用者kdyzm能夠看到的使用者數量是不一樣的。

超級管理員看到的使用者管理頁面:

image-20210218174025420

普通使用者kdyzm看到的使用者管理頁面:

image-20210218174122328

二、各種型別許可權實現原理

1.選單許可權

選單許可權很簡單,實際上就是簡單的使用者-角色-選單模型,那麼選單是什麼時候載入的呢?ruoyi-ui\src\permission.js,載入的邏輯在這個檔案中。

permission.js檔案中設定了導航守衛,每次路由發生變化的時候就會觸發router.beforeEach的回撥函式。

router.beforeEach((to, from, next) => {
  NProgress.start()
  if (getToken()) {
    /* has token*/
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else {
      if (store.getters.roles.length === 0) {
        // 判斷當前使用者是否已拉取完user_info資訊
        store.dispatch('GetInfo').then(res => {
          // 拉取user_info
          const roles = res.roles
          store.dispatch('GenerateRoutes', { roles }).then(accessRoutes => {
            // 根據roles許可權生成可訪問的路由表
            router.addRoutes(accessRoutes) // 動態新增可訪問路由表
            next({ ...to, replace: true }) // hack方法 確保addRoutes已完成
          })
        }).catch(err => {
            store.dispatch('LogOut').then(() => {
              Message.error(err)
              next({ path: '/' })
            })
          })
      } else {
        next()
      }
    }
  } else {
    // 沒有token
    if (whiteList.indexOf(to.path) !== -1) {
      // 在免登入白名單,直接進入
      next()
    } else {
      next(`/login?redirect=${to.fullPath}`) // 否則全部重定向到登入頁
      NProgress.done()
    }
  }
})

注意if (store.getters.roles.length === 0) {這段邏輯,可以看出,如果不重新整理當前頁面,就算給使用者新增了新的選單許可權,使用者也看不到新的選單。

2.按鈕許可權

按鈕許可權設定上和選單許可權基本上是一樣的,是附著於頁面中的細粒度許可權。按鈕許可權體現在如果使用者沒有相應的許可權,則看不到相關的按鈕。這個是咋實現的呢?

先看下系統管理下的選單管理中的修改、新增和刪除按鈕前端vue程式碼

<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
        <template slot-scope="scope">
          <el-button size="mini" 
            type="text" 
            icon="el-icon-edit" 
            @click="handleUpdate(scope.row)"
            v-hasPermi="['system:menu:edit']"
          >修改</el-button>
          <el-button 
            size="mini" 
            type="text" 
            icon="el-icon-plus" 
            @click="handleAdd(scope.row)"
            v-hasPermi="['system:menu:add']"
          >新增</el-button>
          <el-button
            size="mini"
            type="text"
            icon="el-icon-delete"
            @click="handleDelete(scope.row)"
            v-hasPermi="['system:menu:remove']"
          >刪除</el-button>
        </template>
      </el-table-column>

el-button上有個屬性v-hasPermi,這實際上是vue的自定義指令,屬性值就是建立按鈕的時候定義的那個許可權標誌。其定義在src/directive/permission/index.js檔案

import hasRole from './hasRole'
import hasPermi from './hasPermi'

const install = function(Vue) {
  Vue.directive('hasRole', hasRole)
  Vue.directive('hasPermi', hasPermi)
}

if (window.Vue) {
  window['hasRole'] = hasRole
  window['hasPermi'] = hasPermi
  Vue.use(install); // eslint-disable-line
}

export default install

其具體實現邏輯就在同目錄的hasPermi.js檔案中

import store from '@/store'

export default {
  inserted(el, binding, vnode) {
    const { value } = binding
    const all_permission = "*:*:*";
    const permissions = store.getters && store.getters.permissions

    if (value && value instanceof Array && value.length > 0) {
      const permissionFlag = value

      const hasPermissions = permissions.some(permission => {
        return all_permission === permission || permissionFlag.includes(permission)
      })

      if (!hasPermissions) {
        el.parentNode && el.parentNode.removeChild(el)
      }
    } else {
      throw new Error(`請設定操作許可權標籤值`)
    }
  }
}

注意程式碼 el.parentNode && el.parentNode.removeChild(el),可以看到,如果沒有按鈕許可權,則會將按鈕本身從dom中移除。

3.介面許可權

介面許可權和前端的按鈕許可權一一對應。為的是防止使用者繞過按鈕直接請求後端介面獲取資料。在若依Vue系統中,是使用SpringSecurity的註解@PreAuthorize實現的。

雖然只是一個註解,但是它是SpringSecurity+JWT整合的結晶~這個之後再細談。

4.資料許可權

資料許可權實現的關鍵在於com.ruoyi.framework.aspectj.DataScopeAspect類。該類是一個切面類,凡是加上com.ruoyi.common.annotation.DataScope註解的方法,在執行的時候都會被它攔截。

該切面定義了五種許可權範圍

name code desc
DATA_SCOPE_ALL 1 全部資料許可權
DATA_SCOPE_CUSTOM 2 自定資料許可權
DATA_SCOPE_DEPT 3 部門資料許可權
DATA_SCOPE_DEPT_AND_CHILD 4 部門及以下資料許可權
DATA_SCOPE_SELF 5 僅本人資料許可權

該切面的核心邏輯是“拼SQL”,方法執行之前,會給引數的一個params屬性新增一個dataScope鍵值對,key為"dataScope",值為AND (" + sqlString.substring(4) + ")"樣式的一段SQL,這段SQL會根據當前使用者所在的部門以及當前使用者角色的許可權範圍發生變化。

StringBuilder sqlString = new StringBuilder();
        for (SysRole role : user.getRoles())
        {
            String dataScope = role.getDataScope();
            if (DATA_SCOPE_ALL.equals(dataScope))
            {
                sqlString = new StringBuilder();
                break;
            }
            else if (DATA_SCOPE_CUSTOM.equals(dataScope))
            {
                sqlString.append(StringUtils.format(
                        " OR {}.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = {} ) ", deptAlias,
                        role.getRoleId()));
            }
            else if (DATA_SCOPE_DEPT.equals(dataScope))
            {
                sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId()));
            }
            else if (DATA_SCOPE_DEPT_AND_CHILD.equals(dataScope))
            {
                sqlString.append(StringUtils.format(
                        " OR {}.dept_id IN ( SELECT dept_id FROM sys_dept WHERE dept_id = {} or find_in_set( {} , ancestors ) )",
                        deptAlias, user.getDeptId(), user.getDeptId()));
            }
            else if (DATA_SCOPE_SELF.equals(dataScope))
            {
                if (StringUtils.isNotBlank(userAlias))
                {
                    sqlString.append(StringUtils.format(" OR {}.user_id = {} ", userAlias, user.getUserId()));
                }
                else
                {
                    // 資料許可權為僅本人且沒有userAlias別名不查詢任何資料
                    sqlString.append(" OR 1=0 ");
                }
            }
        }

簡單來說,這段程式碼的邏輯就是使用者所在的部門許可權越高,資料許可權範圍越大,查出來的結果集將會越大。

image-20210219105946524

DataScope註解分別加到了部門列表查詢、角色列表查詢、使用者列表查詢的介面上,很明顯,這幾個介面需要根據不同的人查出不同的結果。

以使用者列表查詢為例,執行sql為

    <select id="selectUserList" parameterType="SysUser" resultMap="SysUserResult">
		select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, d.dept_name, d.leader from sys_user u
		left join sys_dept d on u.dept_id = d.dept_id
		where u.del_flag = '0'
		<if test="userName != null and userName != ''">
			AND u.user_name like concat('%', #{userName}, '%')
		</if>
		<if test="status != null and status != ''">
			AND u.status = #{status}
		</if>
		<if test="phonenumber != null and phonenumber != ''">
			AND u.phonenumber like concat('%', #{phonenumber}, '%')
		</if>
		<if test="params.beginTime != null and params.beginTime != ''"><!-- 開始時間檢索 -->
			AND date_format(u.create_time,'%y%m%d') >= date_format(#{params.beginTime},'%y%m%d')
		</if>
		<if test="params.endTime != null and params.endTime != ''"><!-- 結束時間檢索 -->
			AND date_format(u.create_time,'%y%m%d') <= date_format(#{params.endTime},'%y%m%d')
		</if>
		<if test="deptId != null and deptId != 0">
			AND (u.dept_id = #{deptId} OR u.dept_id IN ( SELECT t.dept_id FROM sys_dept t WHERE find_in_set(#{deptId}, ancestors) ))
		</if>
		<!-- 資料範圍過濾 -->
		${params.dataScope}
	</select>

其中,有這麼一段程式碼

<!-- 資料範圍過濾 -->
${params.dataScope}

實際上DataScopeAspect切面就只幹了填充params的dataScope屬性這麼一件事情。

三、若依Vue系統SpringSecurity+JWT

若依Vue系統中從使用者登入到後端介面許可權校驗,都是基於SpringSecurity+JWT實現的,其中,SpringSecurity是核心,jwt只是為了保證token合法性的一種手段(簽名防止篡改)。spring security整合的相關程式碼在ruoyi-framework模組的com.ruoyi.framework.security包以及com.ruoyi.framework.config.SecurityConfig類中。

SecurityConfig是核心配置類,所有的配置均在該類中。

1.使用者登入

使用者登入的邏輯在方法com.ruoyi.web.controller.system.SysLoginController#login中,一個典型的登入請求體如下所示

{
	"username": "admin",
	"password": "admin123",
	"code": "0",
	"uuid": "a9fdbcbcb28748b796b5b77ad71bbb97"
}

username和password分別是使用者名稱和密碼,code為驗證碼,uuid為驗證碼的唯一標識。登入成功之後會返回前端一個jwt令牌

{
	"msg": "操作成功",
	"code": 200,
	"token": "eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjIzZjRhNjJjLTY5NzMtNDcxZS04ZmU4LWJmYWQ4YzllNWFkMiJ9.9d3iIaNq62CkjTXlxFOQgdDMOAZiu5tAsEn0cEuV23opT6PAqu_CiaN7kQY8_XhlQrHX5RgZ2bH7LpsiKLLcSw"
}

在登入方法中,做了以下幾件事情

  • 根據uuid獲取redis中的驗證碼並對請求的驗證碼做驗證
  • 如果驗證碼沒問題,則對使用者名稱和密碼進行校驗
  • 如果使用者名稱和密碼校驗成功,則使用token作為key將使用者資訊儲存到redis
  • 使用jwt對token簽名並返回前端

在整個過程中,會丟擲一些自定義異常,比如

throw new CaptchaExpireException();
throw new CaptchaException();
throw new UserPasswordNotMatchException();
throw new CustomException(e.getMessage());

這些異常最終會被全域性異常處理器處理掉:com.ruoyi.framework.web.exception.GlobalExceptionHandler

2.介面許可權校驗

前端請求完成登入介面之後會將token儲存到cookie,key為Admin-Token,value是jwt令牌。登入邏輯:user.actions.Login

之後,每次請求後端介面的時候都會帶上Authentication Header

image-20210219140417903

這實際上是通過axios的請求攔截器實現的:詳情可見src/utils/request.js檔案

帶著Authentication Header的請求打到後端的時候會經過過濾器com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter,該過濾器做了以下幾件事情

  • 從請求頭中取出jwt令牌,並對其進行jwt驗籤,驗籤若是成功,則取出原始token
  • 根據token從redis中取出使用者資料
  • 將使用者資訊封裝成UsernamePasswordAuthenticationToken物件,並將該物件填充到Spring Security上下文中

填充到SpringSecurity上下文才能讓Controller介面上的@PreAuthorize註解發揮作用(存疑,這裡若依作者並非使用原生的SpringSecurity提供的spel表示式,也沒有用authorities,而是使用了PermissionService類)。

接著,Controller介面正式執行之前會進入com.ruoyi.framework.web.service.PermissionService#hasPermi方法判定許可權,這裡重新從redis中取出使用者資料並進行許可權校驗,許可權校驗失敗則不再執行介面中邏輯(存疑,這裡並沒有使用SpringSecurity上下文中的使用者資料,那麼JwtAuthenticationTokenFilter中的使用者資訊填充上下文中的程式碼是幹啥用的)。

四、實戰

上一篇文章講解了如何建立一個選單並建立頁面,但是是個空頁面

新聞列表

這篇文章將會講解如何實現增刪查改功能。

一切開始之前,新建表news,建表SQL如下

CREATE TABLE `news` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `title` varchar(128) NOT NULL COMMENT '新聞標題',
  `brief` varchar(256) DEFAULT NULL COMMENT '新聞概述',
  `content` text COMMENT '新聞正文',
  `create_time` datetime DEFAULT NULL,
  `create_by` varchar(64) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `update_by` varchar(64) DEFAULT NULL,
  `delete_flag` tinyint(1) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

1.前端頁面修改

可以仿照角色管理的頁面寫,直接將角色管理頁面的程式碼直接拷貝到news檔案中,效果如下

image-20210219145313946

沒錯,新聞列表的標題,角色管理的頁面。。。

還有這種操作?

之後對頁面中元素進行修改,使其和上面建立的表結構一致,修改後的頁面樣子

image-20210219154251035

這是預期中的樣子,但是內容還是角色管理頁面的內容。

2.建立按鈕許可權

上一步已經完成了頁面外觀的改造,接下來需要修改頁面內容了,首先需要把按鈕許可權給加上

image-20210219154710009

按照這個樣子新增按鈕許可權,之後把許可權標誌分配到前端頁面中

image-20210219160705122

3.使用程式碼生成程式碼

在系統工具-程式碼生成頁面中生成news表對應的相關實體類、mapper、xml物件等,可以極大的簡化開發過程。

image-20210219173625548

4.準備後端介面

將上一步程式碼生成器生成的NewsController拿過來改一改就行,修改後的程式碼如下所示:

package com.ruoyi.web.controller.business;

import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.system.domain.News;
import com.ruoyi.system.mapper.NewsMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.Date;
import java.util.List;

/**
 * @author kdyzm
 */
@RestController
@RequestMapping("/business/news")
public class NewsController extends BaseController {


    @Autowired
    private NewsMapper newsMapper;

    /**
     * 獲取新聞列表
     */
    @PreAuthorize("@ss.hasPermi('business:news:list')")
    @GetMapping("/list")
    public TableDataInfo list(News post) {
        startPage();
        List<News> list = newsMapper.selectNewsList(post);
        return getDataTable(list);
    }

    @Log(title = "新聞管理", businessType = BusinessType.EXPORT)
    @PreAuthorize("@ss.hasPermi('business:news:export')")
    @GetMapping("/export")
    public AjaxResult export(News post) {
        List<News> list = newsMapper.selectNewsList(post);
        ExcelUtil<News> util = new ExcelUtil<>(News.class);
        return util.exportExcel(list, "新聞資料");
    }

    /**
     * 根據新聞編號獲取詳細資訊
     */
    @PreAuthorize("@ss.hasPermi('business:news:query')")
    @GetMapping(value = "/{postId}")
    public AjaxResult getInfo(@PathVariable Long postId) {
        return AjaxResult.success(newsMapper.selectNewsById(postId));
    }

    /**
     * 新增新聞
     */
    @PreAuthorize("@ss.hasPermi('business:news:add')")
    @Log(title = "新聞管理", businessType = BusinessType.INSERT)
    @PostMapping
    public AjaxResult add(@Validated @RequestBody News post) {
        post.setCreateBy(SecurityUtils.getUsername());
        post.setCreateTime(new Date());
        return toAjax(newsMapper.insertNews(post));
    }

    /**
     * 修改新聞
     */
    @PreAuthorize("@ss.hasPermi('business:news:update')")
    @Log(title = "新聞管理", businessType = BusinessType.UPDATE)
    @PutMapping
    public AjaxResult edit(@Validated @RequestBody News post) {
        post.setUpdateBy(SecurityUtils.getUsername());
        return toAjax(newsMapper.updateNews(post));
    }

    /**
     * 刪除新聞
     */
    @PreAuthorize("@ss.hasPermi('business:news:delete')")
    @Log(title = "新聞管理", businessType = BusinessType.DELETE)
    @DeleteMapping("/{postIds}")
    public AjaxResult remove(@PathVariable Long[] postIds) {
        return toAjax(newsMapper.deleteNewsByIds(postIds));
    }
}

5.修改前端頁面請求地址

將生成的程式碼中的news.js檔案放到api目錄,並修改其中的介面路徑與後端介面地址一一對應。

import request from '@/utils/request'

// 查詢角色列表
export function listNews(query) {
  return request({
    url: '/business/news/list',
    method: 'get',
    params: query
  })
}

// 查詢角色詳細
export function getNews(roleId) {
  return request({
    url: '/business/news/' + roleId,
    method: 'get'
  })
}

// 新增角色
export function addNews(data) {
  return request({
    url: '/business/news',
    method: 'post',
    data: data
  })
}

// 修改角色
export function updateNews(data) {
  return request({
    url: '/business/news',
    method: 'put',
    data: data
  })
}


// 刪除角色
export function delNews(roleId) {
  return request({
    url: '/business/news/' + roleId,
    method: 'delete'
  })
}

// 匯出角色
export function exportNews(query) {
  return request({
    url: '/business/news/export',
    method: 'get',
    params: query
  })
}

然後修改頁面中的請求地址使用這裡的地址。

五、測試

1.超級管理員測試

超級管理員擁有最大許可權,所有許可權校驗都會跳過對超級管理員的許可權校驗。這裡先使用超級管理員進行測試可以規避許可權問題,大體上先看看能否跑的通。

下面是演示超級管理員的CRUD操作。

超級管理員演示CRUD操作

2.普通使用者測試

這裡用使用者kdyzm進行測試,在測試之前,先看下kdyzm的角色

image-20210220090846893

可以看到該使用者是運營角色,那麼修改角色許可權,只給查詢、修改、新增許可權,不給匯出和刪除許可權,如下所示

image-20210220091032435

這時候切換登入使用者為kdyzm,看看新聞列表頁面

image-20210220091233894

可以看到,kdyzm在新聞列表頁面中,看不到匯出匯出按鈕和刪除按鈕,符合預期設想。

好了,若依Vue許可權詳解部分到此結束了,下一篇文章將會講解若依程式碼生成器生成原理和程式碼分析

我的部落格原文地址:若依管理系統RuoYi-Vue(二):許可權系統設計詳解 ,歡迎大家關注呀

相關文章