
以專案驅動學習,以實踐檢驗真知
前言
權限這一概念可以說是隨處可見:等級不夠進入不了某個論壇版塊、對別人發的文章我只能點贊評論但不能洗掉或修改、朋友圈一些我看得了一些看不了,一些能看七天內的動態一些能看到所有動態等等等等,
每個系統的權限功能都不盡相同,各有其自身的業務特點,對權限管理的設計也都各有特色,不過不管是怎樣的權限設計,大致可歸為三種:頁面權限(選單級)、操作權限(按鈕級)、資料權限,按維度劃分的話就是:粗顆粒權限、細顆粒權限,
本文的重點是權限,為了方便演示我會省略非權限相關的代碼,比如登錄認證、密碼加密等等,如果對于登錄認證(Authentication)相關知識不太清楚的話,可以先看我上一篇寫的【專案實踐】在用安全框架前,我想先讓你手擼一個登陸認證,和上篇一樣,本文的目的是帶大家了解權限授權(Authorization)的核心,所以直接帶你手擼權限授權,不會用上安全框架,核心搞清楚后,什么安全框架理解使用起來都會非常容易,
我會從最簡單、最基礎的講解起,由淺入深、一步一步帶大家實作各個功能,讀完文章你能識訓:
- 權限授權的核心概念
- 頁面權限、操作權限、資料權限的設計與實作
- 權限模型的演進與使用
- 介面掃描與
SQL攔截
并且本文所有代碼、SQL陳述句都放在了Github上,克隆下來即可運行,不止有后端介面,前端頁面也是有的哦!
基礎知識
登錄認證(Authentication)是對用戶的身份進行確認,權限授權(Authorization)是對用戶能否問某個資源進行確認,比如你輸入賬號密碼登錄到某個論壇,這就是認證,你這個賬號是管理員所以想進哪個板塊就進哪個板塊,這就是授權,權限授權通常發生在登錄認證成功之后,即先得確認你是誰,然后再確認你能訪問什么,再舉個例子大家就清楚了:
系統:你誰啊?
用戶:我張三啊,這是我賬號密碼你看看
系統:哎喲,賬號密碼沒錯,看來是法外狂徒張三!你要干嘛呀(登錄認證)
張三:我想進金庫看看哇
系統:滾犢子,你只能進看守所,其他地方哪也去不了(權限授權)
可以看到權限的概念一點都不難,它就像是一個防火墻,保護資源不受侵害(沒錯,平常我們總說的網路防火墻也是權限的一種體現,不得不說網路防火墻這名字起得真貼切),現在其實已經說清楚權限的本質是什么了,就是保護資源,無論是怎樣的功能要求,權限其核心都是圍繞在資源二字上,不能訪問論壇版塊,此時版塊是資源;不能進入某些區域,此時區域是資源……
進行權限系統的設計,第一步就是考慮要保護什么資源,再接著思考如何保護這個資源,這句話是本文的重點,接下來我會詳細地詮釋這句話!
保護什么資源,決定了你的權限粒度,怎樣保護資源,決定了你的.....
實作
我們使用SpringBoot搭建Web專案,MySQL和Mybatis-plus來進行資料存盤與操作,下面是我們要用的必備依賴包:
<dependencies>
<!--web依賴包, web應用必備-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--MySQL,連接MySQL必備-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--MyBatis-plus,ORM框架,訪問并操作資料庫-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
</dependencies>
在設計權限相關的表之前,肯定是先得有一個最基礎的用戶表,欄位很簡單就三個,主鍵、用戶名、密碼:

對應的物體類和SQL建表陳述句我就不寫了,大家一看表結構都知道該咋寫(github上我放了完整的SQL建表檔案),
接下來我們就先實作一種非常簡單的權限控制!
頁面權限
頁面權限非常容易理解,就是有這個權限的用戶才能訪問這個頁面,沒這個權限的用戶就無法訪問,它是以整個頁面為維度,對權限的控制并沒有那么細,所以是一種粗顆粒權限,
最直觀的一個例子就是,有權限的用戶就會顯示所有選單,無權限的用戶就只會顯示部分選單:

這些選單都對應著一個頁面,控制了導航選單就相當于控制住了頁面入口,所以頁面權限通常也可稱為選單權限,
權限核心
就像之前所說,要設計一個權限系統第一步就是要考慮 保護什么資源,頁面權限這種要保護的資源那必然是頁面嘛,一個頁面(選單)對應一個URI地址,當用戶登錄的時候判斷這個用戶擁有哪些頁面權限,自然而然就知道要渲染出什么導航選單了!這些理清楚后表的設計自然浮現眼前:

這個資源表非常簡單但目前足夠用了,假設我們頁面/選單的URI映射如下:

我們要設定用戶的權限話,只要將用戶id和URI對應起來即可:

上面的資料就表明,id為1的用戶擁有所有的權限,id為2的用戶只擁有資料管理權限(首頁我們就讓所有用戶都能進,畢竟一個用戶你至少還是得讓他能看到一些最基本的東西嘛),至此,我們就完成了頁面權限的資料庫表設計!
資料干巴巴放在那毫無作用,所以接下來我們就要進行代碼的撰寫來使用這些資料,代碼實作分為后端和前端,在前后端沒有分離的時候,邏輯的處理和頁面的渲染都是在后端進行,所以整體的邏輯鏈路是這樣的:
用戶登錄后訪問頁面,我們來撰寫一下頁面介面:
@Controller // 注意哦,這里不是@RestController,代表回傳的都是頁面視圖
public class ViewController {
@Autowired
private ResourceService resourceService;
@GetMapping("/")
public String index(HttpServletRequest request) {
// 選單名映射字典,key為uri路徑,value為選單名稱,方便視圖根據uri路徑渲染選單名
Map<String, String> menuMap = new HashMap<>();
menuMap.put("/user/account", "用戶管理");
menuMap.put("/user/role", "權限管理");
menuMap.put("/data", "資料管理");
request.setAttribute("menuMap", menuMap);
// 獲取當前用戶的所有頁面權限,并將資料放到request物件中好讓視圖渲染
Set<String> menus = resourceService.getCurrentUserMenus();
request.setAttribute("menus", menus);
return "index";
}
}
index.html:
<!--這個語法為thymeleaf語法,和JSP一樣是一種后端模板引擎技術-->
<ul>
<!--首頁讓所有人都能看到,就直接渲染-->
<li>首頁</li>
<!--根據權限資料渲染對應的選單-->
<li th:each="i : ${menus}">
[[${menuMap.get(i)}]]
</li>
</ul>
這里只是大概演示一下是如何渲染的,就不寫代碼的全貌了,重點是思路,不用過多糾結代碼的細節
前后端未分離的模式下,至此頁面權限的基本功能已經完成了,
那現在前后端分離模式下,后端只負責提供JSON資料,頁面渲染是前端的事,此時整體的邏輯鏈路就發生了變化:

那么用戶登錄成功的同時,后端要將用戶的權限資料回傳給前端,這是我們登錄介面:
@RestController // 注意,這里是@RestController,代表該類所有介面回傳的都是JSON資料
public class LoginController {
@Autowired
private UserService userService;
@PostMapping("/login")
public Set<String> login(@RequestBody UserParam user) {
// 這里簡單點就只回傳一個權限路徑集合
return userService.login(user);
}
}
具體的業務方法:
@Service
public class UserServiceImpl implements UserService {
@Autowired
private ResourceMapper resourceMapper;
@Autowired
private UserMapper userMapper;
@Override
public Set<String> login(UserParam userParam) {
// 根據前端傳遞過來的賬號密碼從資料庫中查詢用戶資料
// 該方法SQL陳述句:select * from user where user_name = #{userName} and password = #{password}
User user = userMapper.selectByLogin(userParam.getUsername(), userParam.getPassword());
if (user == null) {
throw new ApiException("賬號或密碼錯誤");
}
// 回傳該用戶的權限路徑集合
// 該方法的SQL陳述句:select path from resource where user_id = #{userId}
return resourceMapper.getPathsByUserId(user.getId());
}
}
后端的介面咱們就撰寫完畢了,前端在登錄成功后會收到后端傳遞過來的JSON資料:
[
"/user/account",
"/user/role",
"/data"
]
這時候后端不需要像之前那樣將選單名映射也傳遞給前端,前端自己會存盤一個映射字典,前端將這個權限存盤在本地(比如LocalStorage),然后根據權限資料渲染選單,前后端分離模式下的權限功能就這樣完成了,我們來看一下效果:

到目前為止,頁面權限的基本邏輯鏈路就介紹完畢了,是不是非常簡單?基本的邏輯弄清楚之后,剩下的不過就是非常普通的增刪改查:當我想要讓一個用戶的權限變大時就對這個用戶的權限資料進行增加,想讓一個用戶的權限變小時就對這個用戶的權限資料進行洗掉……接下來我們就完成這一步,讓系統的用戶能夠對權限進行管理,否則干什么都要直接操作資料庫那肯定是不行的,
首先,肯定是得先讓用戶能夠看到一個資料串列然后才能進行操作,我新增了一些資料來方便展示效果:

這里分頁、新增賬戶、洗掉賬戶的代碼怎么寫我就不講解了,就講一下對權限進行編輯的介面:
@RestController
public class LoginController {
@Autowired
private ResourceService resourceService;
@PutMapping("/menus")
private String updateMenus(@RequestBody UserMenusParam param) {
resourceService.updateMenus(param);
return "操作成功";
}
}
接受前端傳遞過來的引數非常簡單,就一個用戶id和將要設定的選單路徑集合:
// 省去getter、setter
public class UserMenusParam {
private Long id;
private Set<String> menus;
}
業務類的代碼如下:
@Override
public void updateMenus(UserMenusParam param) {
// 先根據用戶id洗掉原有的該用戶權限資料
resourceMapper.removeByUserId(param.getId());
// 如果權限集合為空就代表洗掉所有權限,不用走后面新增流程了
if (Collections.isEmpty(param.getMenus())) {
return;
}
// 根據用戶id新增權限資料
resourceMapper.insertMenusByUserId(param.getId(), param.getMenus());
}
洗掉權限資料和新增權限資料的SQL陳述句如下:
<mapper namespace="com.rudecrab.rbac.mapper.ResourceMapper">
<!--根據用戶id洗掉該用戶所有權限-->
<delete id="deleteByUserId">
delete from resource where user_id = #{userId}
</delete>
<!--根據用戶id增加選單權限-->
<insert id="insertMenusByUserId">
insert into resource(user_id, path) values
<foreach collection="menus" separator="," item="menu">
(#{userId}, #{menu})
</foreach>
</insert>
</mapper>
如此就完成了權限資料編輯的功能:

可以看到root用戶之前是只能訪問資料管理,對其進行權限編輯后,他就也能訪問賬戶管理了,現在我們的頁面權限管理功能才算完成,
是不是感覺非常簡單,我們僅僅用了兩張表就完成了一個權限管理功能,
ACL模型
兩張表十分方便且容易理解,系統小資料量小這樣玩沒啥,如果資料量大就有其弊端所在:
- 資料重復極大
- 消耗存盤資源,比如
/user/account,我有多少用戶有這權限我就得存盤多少個這樣的字串,要知道這還是最簡單的資源資訊呢,只有一個路徑,有些資源的資訊可有很多喲:資源名稱、型別、等級、介紹等等等等 - 更改資源成本過大,比如
/data我要改成/info,那現有的那些權限資料都要跟著改
- 消耗存盤資源,比如
- 設計不合理
- 無法直觀描述資源,剛才我們只弄了三個資源,如果我系統中想添加第四、五...種資源是沒有辦法的,因為現在的資源都是依賴于用戶而存在,根本不能獨立存盤起來
- 表的釋義不清,現在我們的
resource表與其說是在描述資源,倒不如說是在描述用戶和資源的關系,
為了解決上述問題,我們應當對當前表設計進行改良,要將資源和用戶和資源的關系拎清,用戶和資源的關系是多對多的,一個用戶可以有多個權限,一個權限下可以有多個用戶,我們一般都用中間表來描述這種多對多關系,然后資源表就不用來描述關系了,只用來描述資源, 這樣我們新的表設計就出來了:建立中間表,改進資源表!
我們先來對資源表進行改造,id、user_id、path這是之前的三個欄位,user_id并不是用來描述資源的,所以我們將它洗掉,然后我們再額外加一個name欄位用來描述資源名稱(非必須),改造后此時資源表如下:

表里的內容就專門用來放資源:

資源表搞定了咱們建立一個中間表用來描述用戶和權限的關系,中間表很簡單就只存用戶id和資源id:

之前的權限關系在中間表里就是這樣存盤的了:

現在的資料表明,id為1的用戶擁有id為1、2、3的權限,即用戶1擁有賬戶管理、角色管理、資料管理權限,id為2的用戶只擁有id為3的資源權限,即用戶2擁有資料管理權限!
整個表設計就如此升級完畢了,現在我們的表如下:

由于表發生了變化,那么之前我們的代碼也要進行相應的調整,調整也很簡單,就是之前所有關于權限的操作都是操作resource表,我們改成操作user_resource表即可,左邊是老代碼,右邊是改進后的代碼:

其中重點就是之前我們都是操作資源表的path字串,前后端之間傳遞權限資訊也是傳遞的path字串,現在都改為操作資源表的id(Java代碼中記得也改過來,這里我就只演示SQL),
這里要單獨解釋一下,前后端只傳遞資源id的話,前端是咋根據這個id渲染頁面呢?又是怎樣根據這個id顯示資源名稱的呢?這是因為前端本地有存盤一個映射字典,字典里有資源的資訊,比如id對應哪個路徑、名稱等等,前端拿到了用戶的id后根據字典進行判斷就可以做到相應的功能了,
這個映射字典在實際開發中有兩種管理模式,一種是前后端采取約定的形式,前端自己就在代碼里造好了字典,如果后續資源有什么變化,前后端人員溝通一下就好了,這種方式只適合權限資源特別簡單的情況,還一種就是后端提供一個介面,介面回傳所有的資源資料,每當用戶登錄或進入系統首頁的時候前端呼叫介面同步一下資源字典就好了!我們現在就用這種方式,所以還得寫一個介面出來才行:
/**
* 回傳所有資源資料
*/
@GetMapping("/resource/list")
public List<Resource> getList() {
// SQL陳述句非常簡單:select * from resource
return resourceService.list();
}
現在,我們的權限設計才像點樣子,這種用戶和權限資源系結關系的模式就是ACL模型,即Access Control List訪問控制串列,其特點是方便、易于理解,適合權限功能簡單的系統,
我們乘熱打鐵,繼續將整個設計再升級一下!
RBAC模型
我這里為了方便演示所以沒有設定過多的權限資源(就是導航選單),所以整個權限系統用起來好像也挺方便的,不過一旦權限資源多了起來目前的設計有點捉襟見肘了,假設我們有100個權限資源,A用戶要設定50個權限,BCD三個用戶也要設定這同樣的50個權限,那么我必須為每個用戶都重復操作50下才行!這種需求還特別特別常見,比如銷售部門的員工都擁有同樣的權限,每新來一個員工我就得給其一步一步重復地去設定權限,并且我要是更改這個銷售部門的權限,那么旗下所有員工的權限都得一一更改,極其繁瑣:

計算機科學領域的任何問題都可以通過增加一個間接的中間層來解決
現在我們的權限關系是和用戶系結的,所以每有一個新用戶我們就得為其設定一套專屬的權限,既然很多用戶的權限都是相同的,那么我再封裝一層出來,屏蔽用戶和權限之間的關系不就搞定了:

這樣有新的用戶時只需要將其和這個封裝層系結關系,即可擁有一整套權限,將來就算權限更改也很方便,這個封裝層我們將它稱為角色!角色非常容易理解,銷售人員是一種角色、后勤是一種角色,角色和權限系結,用戶和角色系結,就像上圖顯示的一樣,
既然加了一層角色,我們的表設計也要跟著改變,毋庸置疑,肯定得有一個角色表來專門描述角色資訊,簡單點就兩個欄位主鍵id、角色名稱,這里添加兩個角色資料以作演示:

剛才說的權限是和角色掛鉤的,那么之前的user_resource表就要改成role_resource,然后用戶又和角色掛鉤,所以還得來一個user_role表:

上面的資料表明,id為1的角色(超級管理員)擁有三個權限資源,id為2的角色(資料管理員)只有一個權限資源, 然后用戶1擁有超級管理員角色,用戶2擁有資料管理員角色:

如果還有一個用戶想擁有超級管理員的所有權限,只需要將該用戶和超級管理員角色系結即可!這樣我們就完成了表的設計,現在我們資料庫表如下:

這就是非常著名且非常流行的RBAC模型,即Role-Based Access Controller基于角色訪問控制模型!它能滿足絕大多數的權限要求,是業界最常用的權限模型之一,光說不練假把式,現在表也設計好了,咱們接下來改進我們的代碼并且和前端聯調起來,完成一個基于角色的權限管理系統!
現在我們系統中有三個物體:用戶、角色、資源(權限),之前我們是有一個用戶頁面,在那一個頁面上就可以進行權限管理,現在我們多了角色這個概念,就還得添加一個角色頁面:


老樣子 分頁、新增、洗掉的代碼我就不講解了,重點還是講一下關于權限操作的代碼,
之前咱們的用戶頁面是直接操作權限的,現在我們要改成操作角色,所以SQL陳述句要按如下撰寫:
<mapper namespace="com.rudecrab.rbac.mapper.RoleMapper">
<!--根據用戶id批量新增角色-->
<insert id="insertRolesByUserId">
insert into user_role(user_id, role_id) values
<foreach collection="roleIds" separator="," item="roleId">
(#{userId}, #{roleId})
</foreach>
</insert>
<!--根據用戶id洗掉該用戶所有角色-->
<delete id="deleteByUserId">
delete from user_role where user_id = #{userId}
</delete>
<!--根據用戶id查詢角色id集合-->
<select id="selectIdsByUserId" resultType="java.lang.Long">
select role_id from user_role where user_id = #{userId}
</select>
</mapper>
除了用戶對角色的操作,我們還得有一個介面是拿用戶id直接獲取該用戶的所有權限,這樣前端才好根據當前用戶的權限進行頁面渲染,之前我們是將resource和user_resource連表查詢出用戶的所有權限,現在我們將user_role和role_resource連表拿到權限id,左邊是我們以前代碼右邊是我們改后的代碼:

關于用戶這一塊的操作到此就完成了,我們接著來處理角色相關的操作,角色這里的思路和之前是一樣的,之前用戶是怎樣直接操作權限的,這里角色就怎樣操作權限:
<mapper namespace="com.rudecrab.rbac.mapper.ResourceMapper">
<!--根據角色id批量增加權限-->
<insert id="insertResourcesByRoleId">
insert into role_resource(role_id, resource_id) values
<foreach collection="resourceIds" separator="," item="resourceId">
(#{roleId}, #{resourceId})
</foreach>
</insert>
<!--根據角色id洗掉該角色下所有權限-->
<delete id="deleteByRoleId">
delete from role_resource where role_id = #{roleId}
</delete>
<!--根據角色id獲取權限id-->
<select id="selectIdsByRoleId" resultType="java.lang.Long">
select resource_id from role_resource where role_id = #{roleId}
</select>
</mapper>
注意哦,這里前后端傳遞的也都是id,既然是id那么前端就得有映射字典才好渲染,所以我們這兩個介面是必不可少的:
/**
* 回傳所有資源資料
*/
@GetMapping("/resource/list")
public List<Resource> getList() {
// SQL陳述句非常簡單:select * from resource
return resourceService.list();
}
/**
* 回傳所有角色資料
*/
@GetMapping("/role/list")
public List<Role> getList() {
// SQL陳述句非常簡單:select * from role
return roleService.list();
}
字典有了,操作角色的方法有了,操作權限的方法也有了,至此我們就完成了基于RBAC模型的頁面權限功能:

root用戶擁有資料管理員的權限,一開始資料管理員只能看到資料管理頁面,后面我們為資料管理員又添加了賬戶管理的頁面權限,root用戶不做任何更改就可以看到賬戶管理頁面了!
無論幾張表,權限的核心還是我之前展示的那流程圖,思路掌握了怎樣的模型都是OK的,
不知道大家發現沒有,在前后端分離的模式下,后端在登錄的時候將權限資料甩給前端后就再也不管了,如果此時用戶的權限發生變化是無法通知前端的,并且資料存盤在前端也容易被用戶直接篡改,所以很不安全,前后端分離不像未分離一樣,頁面請求都得走后端,后端可以很輕松的就對每個頁面請求其進行安全判斷:
@Controller
public class ViewController {
@Autowired
private ResourceService resourceService;
// 這些邏輯都可以放在過濾器統一做,這里只是為了方便演示
@GetMapping("/user/account")
public String userAccount() {
// 先從快取或資料庫中取出當前登錄用戶的權限資料
List<String> menus = resourceService.getCurrentUserMenus();
// 判斷有沒有權限
if (list.contains("/user/account")) {
// 有權限就回傳正常頁面
return "user-account";
}
// 沒有權限就回傳404頁面
return "404";
}
}
首先權限資料存盤在后端,被用戶直接篡改的可能就被屏蔽了,并且每當用戶訪問頁面的時候后端都要實時查詢資料,當用戶權限資料發生變更時也能即時同步,
這么一說難道前后端分離模式下就得認栽了?當然不是,其實有一個騷操作就是前端發起每一次后端請求時,后端都將最新的權限資料回傳給前端,這樣就能避免上述問題了,不過這個方法會給網路傳輸帶來極大的壓力,既不優雅也不明智,所以一般都不這么干,折中的辦法就是當用戶進入某個頁面時重新獲取一次權限資料,比如首頁,不過這也不太安全,畢竟只要用戶不進入首頁那還是沒用,
那么又優雅又明智又安全的方式是什么呢,就是我們接下來要講的操作權限了!
操作權限
操作權限就是將操作視為資源,比如洗掉操作,有些人可以有些人不行,于后端來說,操作就是一個介面,于前端來說,操作往往是一個按鈕,所以操作權限也被稱為按鈕權限,是一種細顆粒權限,
在頁面上比較直觀的體現就是沒有這個洗掉權限的人就不會顯示該按鈕,或者該按鈕被禁用:

前端實作按鈕權限還是和之前導航選單渲染一樣的,拿當前用戶的權限資源id和權限資源字典對比,有權限就渲染出來,無權限就不渲染,
前端關于權限的邏輯和之前一樣,那操作權限怎么就比頁面權限安全了呢?這個安全主要體現在后端上,頁面渲染不走后端,但介面可必須得走后端,那只要走后端那就好辦了,我們只需要對每個介面進行一個權限判斷就OK了嘛!
基本實作
咱們之前都是針對頁面權限進行的設計,現在擴展操作權限的話我們要對現有的resource資源表進行一個小小的擴展,加一個type欄位來區分頁面權限和操作權限

這里我們用0來表示頁面權限,用1來表示操作權限,
表擴展完畢,我們接下來就要添加操作權限型別的資料,剛才也說了,于后端而言操作就是一個介面,那么我們就要將 介面路徑 作為我們的權限資源,大家一看就都明白了:

DELETE:/API/user分為兩個部分組成,DELETE:表示該介面的請求方式,比如GET、POST等,/API/user則是介面路徑了,兩者組合起來就能確定一個介面請求!
資料有了,我們接著在代碼中進行權限安全判斷,注意看注釋:
@RestController
@RequestMapping("/API/user")
public class UserController {
...省略自動注入的service代碼
@DeleteMapping
public String deleteUser(Long[] ids) {
// 拿到所有權限路徑 和 當前用戶擁有的權限路徑
Set<String> allPaths = resourceService.getAllPaths();
Set<String> userPaths = resourceService.getPathsByUserId(UserContext.getCurrentUserId());
// 第一個判斷:所有權限路徑中包含該介面,才代表該介面需要權限處理,所以這是先決條件,
// 第二個判斷:判斷該介面是不是屬于當前用戶的權限范圍,如果不是,則代表該介面用戶沒有權限
if (allPaths.contains("DELETE:/API/user") && !userPaths.contains("DELETE:/API/user")) {
throw new ApiException(ResultCode.FORBIDDEN);
}
// 走到這代表該介面用戶是有權限的,則進行正常的業務邏輯處理
userService.removeByIds(Arrays.asList(ids));
return "操作成功";
}
...省略其他介面宣告
}
和前端聯調后,前端就根據權限隱藏了相應的操作按鈕:

按鈕是隱藏了,可如果用戶篡改本地權限資料,導致不該顯示的按鈕顯示了出來,或者用戶知道了介面繞過頁面自行呼叫怎么辦?反正不管怎樣,他最終都是要呼叫我們介面的,那我們就呼叫介面來試下效果:

可以看到,繞過前端的安全判斷也是沒有用的!
然后還有一個我們之前說的問題,如果當前用戶權限被人修改了,如何實時和前端同步呢?比如,一開始A用戶的角色是有洗掉權限的,然后被一個管理員將他的該權限給去除了,可此時A用戶不重新登錄的話還是能看到洗掉按鈕,
其實有了操作權限后,用戶就算能看到不屬于自己的按鈕也不損害安全性,他點擊后還是會提示無權限,只是說用戶體驗稍微差點罷了! 頁面也是一樣,頁面只是一個容器,用來承載資料的,而資料是要通過介面來呼叫的,比如圖中演示的分頁資料,我們就可以將分頁查詢介面也做一個權限管理嘛,這樣用戶就算繞過了頁面權限,來到了賬戶管理板塊,照樣看不到絲毫資料!
至此,我們就完成了按鈕級的操作權限,是不是很簡單?再次啰嗦:只要掌握了核心思路,實作起來真的很簡單,不要想復雜了,
知道我風格的讀者就知道,我接下來又要升級了!沒錯,現在我們這種實作方式太簡陋、太麻煩了,我們現在都是手動添加的資源資料,寫一個介面我就要手動加一個資料,要知道一個系統中成百上千個介面太正常了,那我手動添加不得起飛咯?那有什么辦法,我寫介面的同時就自動將資源資料給生成呢,那就是我接下來要講的介面掃描!
介面掃描
SpringMVC提供了一個非常方便的類RequestMappingInfoHandlerMapping,這個類可以拿到所有你宣告的web介面資訊,這個拿到后剩下的事不就非常簡單了,就是通過代碼將介面資訊批量添加到資料庫唄!不過我們也不是要真的將所有介面都添加到權限資源中去,我們要的是那些需要權限處理的介面生成權限資源,有些介面不需要權限處理那自然就不生成了,所以我們得想一個辦法來標記一下該介面是否需要被權限管理!
我們的介面都是通過方法來宣告的,標記方法最方便的方式自然就是注解嘛!那我們先來自定義一個注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE}) // 表明該注解可以加在類或方法上
public @interface Auth {
/**
* 權限id,需要唯一
*/
long id();
/**
* 權限名稱
*/
String name();
}
這個注解為啥這樣設計我等下再說,現在只需要曉得,只要介面方法加上了這個注解,我們就被視其為是需要權限管理的:
@RestController
@RequestMapping("/API/user")
@Auth(id = 1000, name = "用戶管理")
public class UserController {
...省略自動注入的service代碼
@PostMapping
@Auth(id = 1, name = "新增用戶")
public String createUser(@RequestBody UserParam param) {
...省略業務代碼
return "操作成功";
}
@DeleteMapping
@Auth(id = 2, name = "洗掉用戶")
public String deleteUser(Long[] ids) {
...省略業務代碼
return "操作成功";
}
@PutMapping
@Auth(id = 3, name = "編輯用戶")
public String updateRoles(@RequestBody UserParam param) {
...省略業務代碼
return "操作成功";
}
@GetMapping("/test/{id}")
@Auth(id = 4,name = "用于演示路徑引數")
public String testInterface(@PathVariable("id") String id) {
...省略業務代碼
return "操作成功";
}
...省略其他介面宣告
}
在講介面掃描和介紹注解設計前,我們先看一下最終的效果,看完效果后再去理解就事半功倍:

可以看到,上面代碼中我在類和方法上都加上了我們自定義的Auth注解,并在注解中設定了id和name的值,這個name好理解,就是資源資料中的資源名稱嘛,可注解里為啥要設計id呢,資料庫主鍵id不是一般都是用自增嘛,這是因為我們人為控制資源的主鍵id有很多好處,
首先是id和介面路徑的映射特別穩定,如果要用自增的話,我一個介面一開始的權限id是4,一大堆角色系結在這個資源4上面了,然后我業務需求有一段時間不需要該介面做權限管理,于是我將這個資源4洗掉一段時間,后續再加回來,可資料再加回來的時候id就變成5,之前與其系結的角色又得重新設定資源,非常麻煩!如果這個id是固定的話,我將這個介面權限一加回來,之前所有設定好的權限都可以無感知地生效,非常非常方便,所以,id和介面路徑的映射從一開始就要穩定下來,不要輕易變更!
至于類上加上Auth注解是方便模塊化管理介面權限,一個Controller類咱們就視為一套介面模塊,最終介面權限的id就是模塊id + 方法id,大家想一想如果不這么做的話,我要保證每一個介面權限id唯一,我就得記得各個類中所有方法的id,一個一個累加地去設定新id,比如上一個方法我設定到了101,接著我就要設定102、103...,只要一沒注意就設定重了,可如果按照Controller類分好組后就特別方便管理了,這個類是1000、下一個類是2000,然后類中所有方法就可以獨立地按照1、2、3來設定,極大避免了心智負擔!
介紹了這么久注解的設計,我們再講解介面掃描的具體實作方式!這個掃描肯定是發生在我新介面寫完了,重新編譯打包重啟程式的時候!并且就只在程式啟動的時候做一次掃描,后續運行期間是不可能再重復掃描的,重復掃描沒有任何意義嘛!既然是在程式啟動時進行的邏輯操作,那么我們就可以使用SpringBoot提供的ApplicationRunner介面來進行處理,重寫該介面的方法會在程式啟動時被執行,(程式啟動時執行指定邏輯有很多種辦法,并不局限于這一個,具體使用根據需求來)
我們現在就來創建一個類實作該介面,并重寫其中的run方法,在其中寫上我們的介面掃描邏輯,注意,下面代碼邏輯現在不用每一行都去理解,大概知道這么個寫法就行,重點是看注釋理解其大概意思,將來再慢慢研究:
@Component
public class ApplicationStartup implements ApplicationRunner {
@Autowired
private RequestMappingInfoHandlerMapping requestMappingInfoHandlerMapping;
@Autowired
private ResourceService resourceService;
@Override
public void run(ApplicationArguments args) throws Exception {
// 掃描并獲取所有需要權限處理的介面資源(該方法邏輯寫在下面)
List<Resource> list = getAuthResources();
// 先洗掉所有操作權限型別的權限資源,待會再新增資源,以實作全量更新(注意哦,資料庫中不要設定外鍵,否則會洗掉失敗)
resourceService.deleteResourceByType(1);
// 如果權限資源為空,就不用走后續資料插入步驟
if (Collections.isEmpty(list)) {
return;
}
// 將資源資料批量添加到資料庫
resourceService.insertResources(list);
}
/**
* 掃描并回傳所有需要權限處理的介面資源
*/
private List<Resource> getAuthResources() {
// 接下來要添加到資料庫的資源
List<Resource> list = new LinkedList<>();
// 拿到所有介面資訊,并開始遍歷
Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingInfoHandlerMapping.getHandlerMethods();
handlerMethods.forEach((info, handlerMethod) -> {
// 拿到類(模塊)上的權限注解
Auth moduleAuth = handlerMethod.getBeanType().getAnnotation(Auth.class);
// 拿到介面方法上的權限注解
Auth methodAuth = handlerMethod.getMethod().getAnnotation(Auth.class);
// 模塊注解和方法注解缺一個都代表不進行權限處理
if (moduleAuth == null || methodAuth == null) {
return;
}
// 拿到該介面方法的請求方式(GET、POST等)
Set<RequestMethod> methods = info.getMethodsCondition().getMethods();
// 如果一個介面方法標記了多個請求方式,權限id是無法識別的,不進行處理
if (methods.size() != 1) {
return;
}
// 將請求方式和路徑用`:`拼接起來,以區分介面,比如:GET:/user/{id}、POST:/user/{id}
String path = methods.toArray()[0] + ":" + info.getPatternsCondition().getPatterns().toArray()[0];
// 將權限名、資源路徑、資源型別組裝成資源物件,并添加集合中
Resource resource = new Resource();
resource.setType(1)
.setPath(path)
.setName(methodAuth.name())
.setId(moduleAuth.id() + methodAuth.id());
list.add(resource);
});
return list;
}
}
這樣,我們就完成了介面掃描啦!后續只要寫新介面需要權限處理時,只要加上Auth注解就可以啦!最終插入的資料就是之前展示的資料效果圖啦!
到這你以為就完了嘛,作為老套路人哪能這么輕易結束,我要繼續優化!
咱們現在是核心邏輯 + 介面掃描,不過還不夠,現在我們每一個權限安全判斷都是寫在方法內,且這個邏輯判斷代碼都是一樣的,我有多少個介面需要權限處理我就得寫多少重復代碼,這太惡心了:
@PutMapping
@Auth(id = 1, name = "新增用戶")
public String deleteUser(@RequestBody UserParam param) {
Set<String> allPaths = resourceService.getAllPaths();
Set<String> userPaths = resourceService.getPathsByUserId(UserContext.getCurrentUserId());
if (allPaths.contains("PUT:/API/user") && !userPaths.contains("PUT:/API/user")) {
throw new ApiException(ResultCode.FORBIDDEN);
}
...省略業務邏輯代碼
return "操作成功";
}
@DeleteMapping
@Auth(id = 2, name = "洗掉用戶")
public String deleteUser(Long[] ids) {
Set<String> allPaths = resourceService.getAllPaths();
Set<String> userPaths = resourceService.getPathsByUserId(UserContext.getCurrentUserId());
if (allPaths.contains("DELETE:/API/user") && !userPaths.contains("DELETE:/API/user")) {
throw new ApiException(ResultCode.FORBIDDEN);
}
...省略業務邏輯代碼
return "操作成功";
}
這種重復代碼,之前也提過一嘴了,當然要用攔截器來做統一處理嘛!
攔截器
攔截器中的代碼和之前介面方法中寫的邏輯判斷大致一樣,還是一樣,看注釋理解大概思路即可:
public class AuthInterceptor extends HandlerInterceptorAdapter {
@Autowired
private ResourceService resourceService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 如果是靜態資源,直接放行
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 獲取請求的最佳匹配路徑,這里的意思就是我之前資料演示的/API/user/test/{id}路徑引數
// 如果用uri判斷的話就是/API/user/test/100,就和路徑引數匹配不上了,所以要用這種方式獲得
String pattern = (String)request.getAttribute(
HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
// 將請求方式(GET、POST等)和請求路徑用 : 拼接起來,等下好進行判斷,最終拼成字串的就像這樣:DELETE:/API/user
String path = request.getMethod() + ":" + pattern;
// 拿到所有權限路徑 和 當前用戶擁有的權限路徑
Set<String> allPaths = resourceService.getAllPaths();
Set<String> userPaths = resourceService.getPathsByUserId(UserContext.getCurrentUserId());
// 第一個判斷:所有權限路徑中包含該介面,才代表該介面需要權限處理,所以這是先決條件,
// 第二個判斷:判斷該介面是不是屬于當前用戶的權限范圍,如果不是,則代表該介面用戶沒有權限
if (allPaths.contains(path) && !userPaths.contains(path)) {
throw new ApiException(ResultCode.FORBIDDEN);
}
// 有權限就放行
return true;
}
}
攔截器類寫好之后,別忘了要使其生效,這里我們直接讓SpringBoot啟動類實作WevMvcConfigurer介面來做:
@SpringBootApplication
public class RbacApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(RbacApplication.class, args);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加權限攔截器,并排除登錄介面(如果有登錄攔截器,權限攔截器記得放在登錄攔截器后面)
registry.addInterceptor(authInterceptor()).excludePathPatterns("/API/login");
}
// 這里一定要用如此方式創建攔截器,否則攔截器中的自動注入不會生效
@Bean
public AuthInterceptor authInterceptor() {return new AuthInterceptor();};
}
這樣,我們之前介面方法中的權限判斷的相關代碼都可以去除啦!
至此,我們才算對頁面級權限 + 按鈕級權限有了一個比較不錯的實作!
注意,攔截器中獲取權限資料現在是直接查的資料庫,實際開發中一定一定要將權限資料存在快取里(如Redis),否則每個介面都要訪問一遍資料庫,壓力太大了!這里為了減少心智負擔,我就不整合Redis了
資料權限
前面所介紹的頁面權限和操作權限都屬于功能權限,我們接下來要講的就是截然不同的資料權限,
功能權限和資料權限最大的不同就在于,前者是判斷有沒有某權限,后者是判斷有多少權限,功能權限對資源的安全判斷只有YES和NO兩種結果,要么你就有這個權限要么你就沒有,而資源權限所要求的是,在同一個資料請求中,根據不同的權限范圍回傳不同的資料集,
舉一個最簡單的資料權限例子就是:現在串列里本身有十條資料,其中有四條我沒有權限,那么我就只能查詢出六條資料,接下來我就帶大家來實作這個功能!
硬編碼
我們現在來模擬一個業務場景:一個公司在各個地方成立了分部,每個分部都有屬于自己分公司的訂單資料,沒有相應權限是看不到的,每個人只能查看屬于自己權限的訂單,就像這樣:


都是同樣的分頁串列頁面,不同的人查出來了不同的結果,
這個分頁查詢功能沒什么好說的,資料庫表的設計也非常簡單,我們建一個資料表data和一個公司表company,data資料表中其他欄位不是重點,主要是要有一個company_id欄位用來關聯company公司表,這樣才能將資料分類,才能后續進行權限的劃分:

我們權限劃分也很簡單,就和之前一樣的,建一個中間表即可,這里為了演示,就直接將用戶和公司直接掛鉤了,建一個user_company表來表示用戶擁有哪些公司資料權限:

上面資料表明id為1的用戶擁有id為1、2、3、4、5的公司資料權限,id為2的用戶擁有id為4、5的公司資料權限,
我相信大家經過了功能權限的學習后,這點表設計已經信手拈來了,表設計和資料準備好后,接下來就是我們關鍵的權限功能實作,
首先,我們得梳理一下普通的分頁查詢是怎樣的,我們要對data進行分頁查詢,SQL陳述句會按照如下撰寫:
-- 按照創建時間降序排序
SELECT * FROM `data` ORDER BY create_time DESC LIMIT ?,?
這個沒什么好說的,正常查詢資料然后進行limit限制以達到分頁的效果,那么我們要加上資料過濾功能,只需要在SQL上進行過濾不就搞定了:
-- 只查詢指定公司的資料
SELECT * FROM `data` where company_id in (?, ?, ?...) ORDER BY create_time DESC LIMIT ?,?
我們只需要先將用戶所屬的公司id全部查出來,然后放到分頁陳述句中的in中即可達到效果,
我們不用in條件判斷,使用連表也是可以達到效果的:
-- 連接 用戶-公司 關系表,查詢指定用戶關聯的公司資料
SELECT
*
FROM
`data`
INNER JOIN user_company uc ON data.company_id = uc.company_id AND uc.user_id = ?
ORDER BY
create_time DESC
LIMIT ?,?
當然,不用連表用子查詢也可以實作,這里就不過多展開了,總之,能夠達到過濾效果的SQL陳述句有很多,根據業務特點優化就好,
到這里我其實就已經介紹完一種非常簡單粗暴的資料權限實作方式了:硬編碼!即,直接修改我們原有的SQL陳述句,自然而然就達到效果了嘛~
不過這種方式對原有代碼入侵太大了,每個要權限過濾的介面我都得修改,嚴重影響了開閉原則,有啥辦法可以不對原有介面進行修改嗎?當然是有的,這就是我接下來要介紹的Mybatis攔截插件,
Mybatis攔截插件
Mybatis提供了一個Interceptor介面,通過實作該介面可以定義我們自己的攔截器,這個攔截器可以對SQL陳述句進行攔截,然后擴展/修改,許多分頁、分庫分表、加密解密等插件都是通過該介面完成的!
我們只需要攔截到原有的SQL陳述句后,添加上我們額外的陳述句,不就和剛才硬編碼一樣實作了效果?這里我先給大家看一下我已經寫好了的攔截器效果:

可以看到,紅框框起來的部分就是在原SQL上添加的陳述句!這個攔截并不僅限于分頁查詢,只要我們寫好陳述句擴展規則,其他陳述句都是可以攔截擴展的!
接下來我就貼上攔截器的代碼,注意這個代碼大家不用過多地去糾結,大概瞟一眼知道有這么個玩意就行了,因為現在我們的重點是整體思路,先跟著我的思路來,代碼有的是時間再看:
@Component
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class DataInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 拿到mybatis的一些物件,等下要操作
StatementHandler statementHandler = PluginUtils.realTarget(invocation.getTarget());
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// id為執行的mapper方法的全路徑名,如com.rudecrab.mapper.UserMapper.insertUser
String id = mappedStatement.getId();
log.info("mapper: ==> {}", id);
// 如果不是指定的方法,直接結束攔截
// 如果方法多可以存到一個集合里,然后判斷當前攔截的是否存在集合中,這里為了演示只攔截一個mapper方法
if (!"com.rudecrab.rbac.mapper.DataMapper.selectPage".equals(id)) {
return invocation.proceed();
}
// 獲取到原始sql陳述句
String sql = statementHandler.getBoundSql().getSql();
log.info("原始SQL陳述句: ==> {}", sql);
// 決議并回傳新的SQL陳述句
sql = getSql(sql);
// 修改sql
metaObject.setValue("delegate.boundSql.sql", sql);
log.info("攔截后SQL陳述句:==>{}", sql);
return invocation.proceed();
}
/**
* 決議SQL陳述句,并回傳新的SQL陳述句
* 注意,該方法使用了JSqlParser來操作SQL,該依賴包Mybatis-plus已經集成了,如果要單獨使用,請先自行匯入依賴
*
* @param sql 原SQL
* @return 新SQL
*/
private String getSql(String sql) {
try {
// 決議陳述句
Statement stmt = CCJSqlParserUtil.parse(sql);
Select selectStatement = (Select) stmt;
PlainSelect ps = (PlainSelect) selectStatement.getSelectBody();
// 拿到表資訊
FromItem fromItem = ps.getFromItem();
Table table = (Table) fromItem;
String mainTable = table.getAlias() == null ? table.getName() : table.getAlias().getName();
List<Join> joins = ps.getJoins();
if (joins == null) {
joins = new ArrayList<>(1);
}
// 創建連表join條件
Join join = new Join();
join.setInner(true);
join.setRightItem(new Table("user_company uc"));
// 第一個:兩表通過company_id連接
EqualsTo joinExpression = new EqualsTo();
joinExpression.setLeftExpression(new Column(mainTable + ".company_id"));
joinExpression.setRightExpression(new Column("uc.company_id"));
// 第二個條件:和當前登錄用戶id匹配
EqualsTo userIdExpression = new EqualsTo();
userIdExpression.setLeftExpression(new Column("uc.user_id"));
userIdExpression.setRightExpression(new LongValue(UserContext.getCurrentUserId()));
// 將兩個條件拼接起來
join.setOnExpression(new AndExpression(joinExpression, userIdExpression));
joins.add(join);
ps.setJoins(joins);
// 修改原陳述句
sql = ps.toString();
} catch (JSQLParserException e) {
e.printStackTrace();
}
return sql;
}
}
SQL攔截器寫好后就會非常方便了,之前寫好的代碼不用修改,直接用攔截器進行統一處理即可!如此,我們就完成了一個簡單的資料權限功能!是不是感覺太簡單了點,這么一會就將資料權限介紹完啦?
說簡單也確實簡單,其核心一句話就可以表明:對SQL進行攔截然后達到資料過濾的效果,但是!我這里只是演示了一個特別簡單的案例,考慮的層面特別少,如果需求一旦復雜起來那需要考慮的東西我這篇文章再加幾倍內容只怕也難以說完,
資料權限和業務關聯性極強,有很多自己行業特點的權限劃分維度,比如交易金額、交易時間、地區、年齡、用戶標簽等等等等,我們這只演示了一個部門維度的劃分而已,有些資料權限甚至要做到多個維度交叉,還要做到到能對某個欄位進行資料過濾(比如A管理員能看到手機號、交易金額,B管理員看不到),其難度和復雜度遠超功能權限,
所以對于資料權限,一定是需求在先,技術手段再跟上,至于你是要用Mybatis還是其他什么框架,你是要用子查詢還是用連表,都沒有定式而言,一定得根據具體的業務需求來制定針對性的資料過濾方案!
總結
到這里,關于權限的講解就接近尾聲了,其實本文說了那么多也就只是在闡述以下幾點:
- 權限的本質就是保護資源
- 權限設計的核心就是 保護什么資源、如何保護資源
- 核心掌握后,根據具體的業務需求來制定方案即可,萬變不離其宗
代碼從來就不是重點,重點的是思路!如果還有一些地方不太理解的也沒關系,可以參考專案效果來幫助理解思路,本文所有代碼、SQL陳述句都放在了Github上,克隆下來即可運行,不止有后端介面,前端頁面也是有的哦!我會持續更多【專案實踐】的!
這兩篇文章講的是不使用安全框架,手擼認證和授權的功能,那么接下來的文章就講解如何使用安全框架Spring Scurity實作認證和授權,敬請期待!

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/247051.html
標籤:Java
下一篇:檔案上傳的單元測驗怎么寫?
