若依Vue系統中的權限管理部分的功能都集中在了系統管理菜單模塊中,如下圖所示。其中權限部分主要涉及到了用戶管理、角色管理、菜單管理、部門管理這四個部分。
一、若依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.按鈕權限
新增按鈕權限和新增菜單差不多,下圖是我在新聞列表頁面上新增了一個按鈕叫做新聞新增
,該按鈕的權限分配和菜單的權限分配方法是一樣的。
3.接口權限
每一個按鈕基本上都會對應着一個後端的接口,前端會根據權限標誌顯示或者隱藏按鈕,但是如果用戶不點擊按鈕,直接通過http請求工具請求後端咋辦?所以接口權限也是要有的,該權限和按鈕上權限完全一致。
若依系統使用了SpringSecurity框架,接口權限都是基於註解@PreAuthorize
實現的,比如,用戶管理頁面中的修改用戶按鈕對應的後端接口長這個樣子
@PreAuthorize("@ss.hasPermi('system:user:edit')")
@Log(title = "用戶管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody SysUser user)
{
...
}
和其對應的前端按鈕權限標誌一樣
如果沒有權限訪問接口,則會返回類似如下信息:
{
"msg": "請求訪問:/system/user/list,認證失敗,無法訪問系統資源",
"code": 401
}
4.數據權限
用戶有權限訪問後端某個接口,但是不同的用戶相同的接口相同的入參,根據權限大小不同,返回的結果應當不一樣——權限大的能夠看到的數據更多。
體現在若依Vue系統中,舉個例子,現在若以系統中有兩個用戶,一個是超級管理員admin,一個是普通用戶kdyzm
它們兩者均有用戶管理、菜單管理、角色管理權限,所以它們能夠看到相應的菜單並作出相應的操作,比如刪除、新增、修改等。這裏有個問題,kdyzm應當只能看到一部分數據,而超級管理員應當能夠看到所有數據,在若依系統中,是通過對角色數據權限
修改進行控制的。
所以,相同的權限,超級管理員能夠看到的用戶數量和普通用戶kdyzm能夠看到的用戶數量是不一樣的。
超級管理員看到的用戶管理頁面:
普通用戶kdyzm看到的用戶管理頁面:
二、各種類型權限實現原理
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 ");
}
}
}
簡單來說,這段代碼的邏輯就是用戶所在的部門權限越高,數據權限範圍越大,查出來的結果集將會越大。
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
這實際上是通過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文件中,效果如下
沒錯,新聞列表的標題,角色管理的頁面。。。
之後對頁面中元素進行修改,使其和上面創建的表結構一致,修改後的頁面樣子
這是預期中的樣子,但是內容還是角色管理頁面的內容。
2.創建按鈕權限
上一步已經完成了頁面外觀的改造,接下來需要修改頁面內容了,首先需要把按鈕權限給加上
按照這個樣子添加按鈕權限,之後把權限標誌分配到前端頁面中
3.使用代碼生成代碼
在系統工具-代碼生成頁面中生成news表對應的相關實體類、mapper、xml對象等,可以極大的簡化開發過程。
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操作。
2.普通用戶測試
這裏用用戶kdyzm進行測試,在測試之前,先看下kdyzm的角色
可以看到該用戶是運營角色,那麼修改角色權限,只給查詢、修改、新增權限,不給導出和刪除權限,如下所示
這時候切換登錄用戶爲kdyzm,看看新聞列表頁面
可以看到,kdyzm在新聞列表頁面中,看不到導出導出按鈕和刪除按鈕,符合預期設想。
好了,若依Vue權限詳解部分到此結束了,下一篇文章將會講解若依代碼生成器生成原理和代碼分析
我的博客原文地址:若依管理系統RuoYi-Vue(二):權限系統設計詳解 ,歡迎大家關注呀