以項(xiàng)目驅(qū)動(dòng)學(xué)習(xí)焊唬,以實(shí)踐檢驗(yàn)真知
前言
權(quán)限這一概念可以說(shuō)是隨處可見(jiàn):等級(jí)不夠進(jìn)入不了某個(gè)論壇版塊恋昼、對(duì)別人發(fā)的文章我只能點(diǎn)贊評(píng)論但不能刪除或修改、朋友圈一些我看得了一些看不了赶促,一些能看七天內(nèi)的動(dòng)態(tài)一些能看到所有動(dòng)態(tài)等等等等液肌。
每個(gè)系統(tǒng)的權(quán)限功能都不盡相同翼闹,各有其自身的業(yè)務(wù)特點(diǎn)庇麦,對(duì)權(quán)限管理的設(shè)計(jì)也都各有特色嚷节。不過(guò)不管是怎樣的權(quán)限設(shè)計(jì)比被,大致可歸為三種:頁(yè)面權(quán)限(菜單級(jí))、操作權(quán)限(按鈕級(jí))叁鉴、數(shù)據(jù)權(quán)限绵咱,按維度劃分的話就是:粗顆粒權(quán)限备蚓、細(xì)顆粒權(quán)限空幻。
本文的重點(diǎn)是權(quán)限,為了方便演示我會(huì)省略非權(quán)限相關(guān)的代碼容客,比如登錄認(rèn)證秕铛、密碼加密等等。如果對(duì)于登錄認(rèn)證(Authentication)相關(guān)知識(shí)不太清楚的話缩挑,可以先看我上一篇寫的【項(xiàng)目實(shí)踐】在用安全框架前但两,我想先讓你手?jǐn)]一個(gè)登陸認(rèn)證。和上篇一樣供置,本文的目的是帶大家了解權(quán)限授權(quán)(Authorization)的核心谨湘,所以直接帶你手?jǐn)]權(quán)限授權(quán),不會(huì)用上安全框架芥丧。核心搞清楚后紧阔,什么安全框架理解使用起來(lái)都會(huì)非常容易。
我會(huì)從最簡(jiǎn)單续担、最基礎(chǔ)的講解起擅耽,由淺入深、一步一步帶大家實(shí)現(xiàn)各個(gè)功能物遇。讀完文章你能收獲:
- 權(quán)限授權(quán)的核心概念
- 頁(yè)面權(quán)限乖仇、操作權(quán)限、數(shù)據(jù)權(quán)限的設(shè)計(jì)與實(shí)現(xiàn)
- 權(quán)限模型的演進(jìn)與使用
- 接口掃描與
SQL
攔截
并且本文所有代碼询兴、SQL
語(yǔ)句都放在了Github上乃沙,克隆下來(lái)即可運(yùn)行,不止有后端接口诗舰,前端頁(yè)面也是有的哦警儒!
基礎(chǔ)知識(shí)
登錄認(rèn)證(Authentication)是對(duì)用戶的身份進(jìn)行確認(rèn),權(quán)限授權(quán)(Authorization)是對(duì)用戶能否問(wèn)某個(gè)資源進(jìn)行確認(rèn)始衅。比如你輸入賬號(hào)密碼登錄到某個(gè)論壇冷蚂,這就是認(rèn)證缭保。你這個(gè)賬號(hào)是管理員所以想進(jìn)哪個(gè)板塊就進(jìn)哪個(gè)板塊,這就是授權(quán)蝙茶。權(quán)限授權(quán)通常發(fā)生在登錄認(rèn)證成功之后艺骂,即先得確認(rèn)你是誰(shuí),然后再確認(rèn)你能訪問(wèn)什么隆夯。再舉個(gè)例子大家就清楚了:
系統(tǒng):你誰(shuí)扒 ?
用戶:我張三啊蹄衷,這是我賬號(hào)密碼你看看
系統(tǒng):哎喲忧额,賬號(hào)密碼沒(méi)錯(cuò),看來(lái)是法外狂徒張三愧口!你要干嘛呀(登錄認(rèn)證)
張三:我想進(jìn)金庫(kù)看看哇
系統(tǒng):滾犢子睦番,你只能進(jìn)看守所,其他地方哪也去不了(權(quán)限授權(quán))
可以看到權(quán)限的概念一點(diǎn)都不難耍属,它就像是一個(gè)防火墻托嚣,保護(hù)資源不受侵害(沒(méi)錯(cuò),平常我們總說(shuō)的網(wǎng)絡(luò)防火墻也是權(quán)限的一種體現(xiàn)厚骗,不得不說(shuō)網(wǎng)絡(luò)防火墻這名字起得真貼切)∈酒簦現(xiàn)在其實(shí)已經(jīng)說(shuō)清楚權(quán)限的本質(zhì)是什么了,就是保護(hù)資源领舰。無(wú)論是怎樣的功能要求夫嗓,權(quán)限其核心都是圍繞在資源二字上。不能訪問(wèn)論壇版塊冲秽,此時(shí)版塊是資源舍咖;不能進(jìn)入某些區(qū)域,此時(shí)區(qū)域是資源……
進(jìn)行權(quán)限系統(tǒng)的設(shè)計(jì)劳跃,第一步就是考慮要保護(hù)什么資源谎仲,再接著思考如何保護(hù)這個(gè)資源。這句話是本文的重點(diǎn)刨仑,接下來(lái)我會(huì)詳細(xì)地詮釋這句話郑诺!
保護(hù)什么資源,決定了你的權(quán)限粒度杉武。怎樣保護(hù)資源辙诞,決定了你的.....
實(shí)現(xiàn)
我們使用SpringBoot
搭建Web項(xiàng)目,MySQL
和Mybatis-plus
來(lái)進(jìn)行數(shù)據(jù)存儲(chǔ)與操作轻抱。下面是我們要用的必備依賴包:
<dependencies>
<!--web依賴包, web應(yīng)用必備-->
<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框架,訪問(wèn)并操作數(shù)據(jù)庫(kù)-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
</dependencies>
在設(shè)計(jì)權(quán)限相關(guān)的表之前较店,肯定是先得有一個(gè)最基礎(chǔ)的用戶表士八,字段很簡(jiǎn)單就三個(gè),主鍵梁呈、用戶名婚度、密碼:
對(duì)應(yīng)的實(shí)體類和SQL建表語(yǔ)句我就不寫了,大家一看表結(jié)構(gòu)都知道該咋寫(github上我放了完整的SQL建表文件)官卡。
接下來(lái)我們就先實(shí)現(xiàn)一種非常簡(jiǎn)單的權(quán)限控制蝗茁!
頁(yè)面權(quán)限
頁(yè)面權(quán)限非常容易理解,就是有這個(gè)權(quán)限的用戶才能訪問(wèn)這個(gè)頁(yè)面寻咒,沒(méi)這個(gè)權(quán)限的用戶就無(wú)法訪問(wèn)哮翘,它是以整個(gè)頁(yè)面為維度,對(duì)權(quán)限的控制并沒(méi)有那么細(xì)毛秘,所以是一種粗顆粒權(quán)限饭寺。
最直觀的一個(gè)例子就是,有權(quán)限的用戶就會(huì)顯示所有菜單叫挟,無(wú)權(quán)限的用戶就只會(huì)顯示部分菜單:
這些菜單都對(duì)應(yīng)著一個(gè)頁(yè)面佩研,控制了導(dǎo)航菜單就相當(dāng)于控制住了頁(yè)面入口,所以頁(yè)面權(quán)限通常也可稱為菜單權(quán)限霞揉。
權(quán)限核心
就像之前所說(shuō),要設(shè)計(jì)一個(gè)權(quán)限系統(tǒng)第一步就是要考慮 保護(hù)什么資源晰骑,頁(yè)面權(quán)限這種要保護(hù)的資源那必然是頁(yè)面嘛适秩。一個(gè)頁(yè)面(菜單)對(duì)應(yīng)一個(gè)URI
地址,當(dāng)用戶登錄的時(shí)候判斷這個(gè)用戶擁有哪些頁(yè)面權(quán)限硕舆,自然而然就知道要渲染出什么導(dǎo)航菜單了秽荞!這些理清楚后表的設(shè)計(jì)自然浮現(xiàn)眼前:
這個(gè)資源表非常簡(jiǎn)單但目前足夠用了,假設(shè)我們頁(yè)面/菜單的URI
映射如下:
我們要設(shè)置用戶的權(quán)限話抚官,只要將用戶id和URI
對(duì)應(yīng)起來(lái)即可:
上面的數(shù)據(jù)就表明扬跋,id
為1
的用戶擁有所有的權(quán)限,id
為2
的用戶只擁有數(shù)據(jù)管理權(quán)限(首頁(yè)我們就讓所有用戶都能進(jìn)凌节,畢竟一個(gè)用戶你至少還是得讓他能看到一些最基本的東西嘛)钦听。至此,我們就完成了頁(yè)面權(quán)限的數(shù)據(jù)庫(kù)表設(shè)計(jì)倍奢!
數(shù)據(jù)干巴巴放在那毫無(wú)作用朴上,所以接下來(lái)我們就要進(jìn)行代碼的編寫來(lái)使用這些數(shù)據(jù)。代碼實(shí)現(xiàn)分為后端和前端卒煞,在前后端沒(méi)有分離的時(shí)候痪宰,邏輯的處理和頁(yè)面的渲染都是在后端進(jìn)行,所以整體的邏輯鏈路是這樣的:
用戶登錄后訪問(wèn)頁(yè)面,我們來(lái)編寫一下頁(yè)面接口:
@Controller // 注意哦衣撬,這里不是@RestController乖订,代表返回的都是頁(yè)面視圖
public class ViewController {
@Autowired
private ResourceService resourceService;
@GetMapping("/")
public String index(HttpServletRequest request) {
// 菜單名映射字典。key為uri路徑具练,value為菜單名稱乍构,方便視圖根據(jù)uri路徑渲染菜單名
Map<String, String> menuMap = new HashMap<>();
menuMap.put("/user/account", "用戶管理");
menuMap.put("/user/role", "權(quán)限管理");
c.put("/data", "數(shù)據(jù)管理");
request.setAttribute("menuMap", menuMap);
// 獲取當(dāng)前用戶的所有頁(yè)面權(quán)限,并將數(shù)據(jù)放到request對(duì)象中好讓視圖渲染
Set<String> menus = resourceService.getCurrentUserMenus();
request.setAttribute("menus", menus);
return "index";
}
}
index.html:
<!--這個(gè)語(yǔ)法為thymeleaf語(yǔ)法靠粪,和JSP一樣是一種后端模板引擎技術(shù)-->
<ul>
<!--首頁(yè)讓所有人都能看到蜡吧,就直接渲染-->
<li>首頁(yè)</li>
<!--根據(jù)權(quán)限數(shù)據(jù)渲染對(duì)應(yīng)的菜單-->
<li th:each="i : ${menus}">
[[${menuMap.get(i)}]]
</li>
</ul>
這里只是大概演示一下是如何渲染的,就不寫代碼的全貌了占键,重點(diǎn)是思路昔善,不用過(guò)多糾結(jié)代碼的細(xì)節(jié)
前后端未分離的模式下,至此頁(yè)面權(quán)限的基本功能已經(jīng)完成了畔乙。
那現(xiàn)在前后端分離模式下君仆,后端只負(fù)責(zé)提供JSON
數(shù)據(jù),頁(yè)面渲染是前端的事牲距,此時(shí)整體的邏輯鏈路就發(fā)生了變化:
那么用戶登錄成功的同時(shí)返咱,后端要將用戶的權(quán)限數(shù)據(jù)返回給前端,這是我們登錄接口:
@RestController // 注意牍鞠,這里是@RestController咖摹,代表該類所有接口返回的都是JSON數(shù)據(jù)
public class LoginController {
@Autowired
private UserService userService;
@PostMapping("/login")
public Set<String> login(@RequestBody UserParam user) {
// 這里簡(jiǎn)單點(diǎn)就只返回一個(gè)權(quán)限路徑集合
return userService.login(user);
}
}
具體的業(yè)務(wù)方法:
@Service
public class UserServiceImpl implements UserService {
@Autowired
private ResourceMapper resourceMapper;
@Autowired
private UserMapper userMapper;
@Override
public Set<String> login(UserParam userParam) {
// 根據(jù)前端傳遞過(guò)來(lái)的賬號(hào)密碼從數(shù)據(jù)庫(kù)中查詢用戶數(shù)據(jù)
// 該方法SQL語(yǔ)句:select * from user where user_name = #{userName} and password = #{password}
User user = userMapper.selectByLogin(userParam.getUsername(), userParam.getPassword());
if (user == null) {
throw new ApiException("賬號(hào)或密碼錯(cuò)誤");
}
// 返回該用戶的權(quán)限路徑集合
// 該方法的SQL語(yǔ)句:select path from resource where user_id = #{userId}
return resourceMapper.getPathsByUserId(user.getId());
}
}
后端的接口咱們就編寫完畢了,前端在登錄成功后會(huì)收到后端傳遞過(guò)來(lái)的JSON
數(shù)據(jù):
[
"/user/account",
"/user/role",
"/data"
]
這時(shí)候后端不需要像之前那樣將菜單名映射也傳遞給前端难述,前端自己會(huì)存儲(chǔ)一個(gè)映射字典萤晴。前端將這個(gè)權(quán)限存儲(chǔ)在本地(比如LocalStorage
),然后根據(jù)權(quán)限數(shù)據(jù)渲染菜單胁后,前后端分離模式下的權(quán)限功能就這樣完成了店读。我們來(lái)看一下效果:
到目前為止,頁(yè)面權(quán)限的基本邏輯鏈路就介紹完畢了攀芯,是不是非常簡(jiǎn)單屯断?基本的邏輯弄清楚之后,剩下的不過(guò)就是非常普通的增刪改查:當(dāng)我想要讓一個(gè)用戶的權(quán)限變大時(shí)就對(duì)這個(gè)用戶的權(quán)限數(shù)據(jù)進(jìn)行增加侣诺,想讓一個(gè)用戶的權(quán)限變小時(shí)就對(duì)這個(gè)用戶的權(quán)限數(shù)據(jù)進(jìn)行刪除……接下來(lái)我們就完成這一步殖演,讓系統(tǒng)的用戶能夠?qū)?quán)限進(jìn)行管理,否則干什么都要直接操作數(shù)據(jù)庫(kù)那肯定是不行的紧武。
首先剃氧,肯定是得先讓用戶能夠看到一個(gè)數(shù)據(jù)列表然后才能進(jìn)行操作,我新增了一些數(shù)據(jù)來(lái)方便展示效果:
這里分頁(yè)阻星、新增賬戶朋鞍、刪除賬戶的代碼怎么寫我就不講解了已添,就講一下對(duì)權(quán)限進(jìn)行編輯的接口:
@RestController
public class LoginController {
@Autowired
private ResourceService resourceService;
@PutMapping("/menus")
private String updateMenus(@RequestBody UserMenusParam param) {
resourceService.updateMenus(param);
return "操作成功";
}
}
接受前端傳遞過(guò)來(lái)的參數(shù)非常簡(jiǎn)單,就一個(gè)用戶id和將要設(shè)置的菜單路徑集合:
// 省去getter滥酥、setter
public class UserMenusParam {
private Long id;
private Set<String> menus;
}
業(yè)務(wù)類的代碼如下:
@Override
public void updateMenus(UserMenusParam param) {
// 先根據(jù)用戶id刪除原有的該用戶權(quán)限數(shù)據(jù)
resourceMapper.removeByUserId(param.getId());
// 如果權(quán)限集合為空就代表刪除所有權(quán)限更舞,不用走后面新增流程了
if (Collections.isEmpty(param.getMenus())) {
return;
}
// 根據(jù)用戶id新增權(quán)限數(shù)據(jù)
resourceMapper.insertMenusByUserId(param.getId(), param.getMenus());
}
刪除權(quán)限數(shù)據(jù)和新增權(quán)限數(shù)據(jù)的SQL語(yǔ)句如下:
<mapper namespace="com.rudecrab.rbac.mapper.ResourceMapper">
<!--根據(jù)用戶id刪除該用戶所有權(quán)限-->
<delete id="deleteByUserId">
delete from resource where user_id = #{userId}
</delete>
<!--根據(jù)用戶id增加菜單權(quán)限-->
<insert id="insertMenusByUserId">
insert into resource(user_id, path) values
<foreach collection="menus" separator="," item="menu">
(#{userId}, #{menu})
</foreach>
</insert>
</mapper>
如此就完成了權(quán)限數(shù)據(jù)編輯的功能:
可以看到root
用戶之前是只能訪問(wèn)數(shù)據(jù)管理
,對(duì)其進(jìn)行權(quán)限編輯后坎吻,他就也能訪問(wèn)賬戶管理
了缆蝉,現(xiàn)在我們的頁(yè)面權(quán)限管理功能才算完成。
是不是感覺(jué)非常簡(jiǎn)單瘦真,我們僅僅用了兩張表就完成了一個(gè)權(quán)限管理功能刊头。
ACL模型
兩張表十分方便且容易理解,系統(tǒng)小數(shù)據(jù)量小這樣玩沒(méi)啥诸尽,如果數(shù)據(jù)量大就有其弊端所在:
- 數(shù)據(jù)重復(fù)極大
- 消耗存儲(chǔ)資源原杂。比如
/user/account
,我有多少用戶有這權(quán)限我就得存儲(chǔ)多少個(gè)這樣的字符串您机。要知道這還是最簡(jiǎn)單的資源信息呢穿肄,只有一個(gè)路徑,有些資源的信息可有很多喲:資源名稱际看、類型咸产、等級(jí)、介紹等等等等 - 更改資源成本過(guò)大仲闽。比如
/data
我要改成/info
脑溢,那現(xiàn)有的那些權(quán)限數(shù)據(jù)都要跟著改
- 消耗存儲(chǔ)資源原杂。比如
- 設(shè)計(jì)不合理
- 無(wú)法直觀描述資源。剛才我們只弄了三個(gè)資源赖欣,如果我系統(tǒng)中想添加第四焚志、五...種資源是沒(méi)有辦法的,因?yàn)楝F(xiàn)在的資源都是依賴于用戶而存在畏鼓,根本不能獨(dú)立存儲(chǔ)起來(lái)
- 表的釋義不清。現(xiàn)在我們的
resource
表與其說(shuō)是在描述資源壶谒,倒不如說(shuō)是在描述用戶和資源的關(guān)系云矫。
為了解決上述問(wèn)題,我們應(yīng)當(dāng)對(duì)當(dāng)前表設(shè)計(jì)進(jìn)行改良汗菜,要將資源和用戶和資源的關(guān)系拎清让禀。用戶和資源的關(guān)系是多對(duì)多的,一個(gè)用戶可以有多個(gè)權(quán)限陨界,一個(gè)權(quán)限下可以有多個(gè)用戶巡揍,我們一般都用中間表來(lái)描述這種多對(duì)多關(guān)系。然后資源表就不用來(lái)描述關(guān)系了菌瘪,只用來(lái)描述資源腮敌。 這樣我們新的表設(shè)計(jì)就出來(lái)了:建立中間表阱当,改進(jìn)資源表!
我們先來(lái)對(duì)資源表進(jìn)行改造糜工,id
弊添、user_id
、path
這是之前的三個(gè)字段捌木,user_id
并不是用來(lái)描述資源的油坝,所以我們將它刪除。然后我們?cè)兕~外加一個(gè)name
字段用來(lái)描述資源名稱(非必須)刨裆,改造后此時(shí)資源表如下:
表里的內(nèi)容就專門用來(lái)放資源:
資源表搞定了咱們建立一個(gè)中間表用來(lái)描述用戶和權(quán)限的關(guān)系澈圈,中間表很簡(jiǎn)單就只存用戶id和資源id:
之前的權(quán)限關(guān)系在中間表里就是這樣存儲(chǔ)的了:
現(xiàn)在的數(shù)據(jù)表明,id為1
的用戶擁有id為1帆啃、2瞬女、3
的權(quán)限,即用戶1擁有賬戶管理链瓦、角色管理拆魏、數(shù)據(jù)管理
權(quán)限。id為2
的用戶只擁有id為3
的資源權(quán)限慈俯,即用戶2擁有數(shù)據(jù)管理
權(quán)限渤刃!
整個(gè)表設(shè)計(jì)就如此升級(jí)完畢了,現(xiàn)在我們的表如下:
由于表發(fā)生了變化贴膘,那么之前我們的代碼也要進(jìn)行相應(yīng)的調(diào)整卖子,調(diào)整也很簡(jiǎn)單,就是之前所有關(guān)于權(quán)限的操作都是操作resource
表刑峡,我們改成操作user_resource
表即可洋闽,左邊是老代碼,右邊是改進(jìn)后的代碼:
其中重點(diǎn)就是之前我們都是操作資源表的path
字符串突梦,前后端之間傳遞權(quán)限信息也是傳遞的path
字符串诫舅,現(xiàn)在都改為操作資源表的id
(Java代碼中記得也改過(guò)來(lái),這里我就只演示SQL)宫患。
這里要單獨(dú)解釋一下刊懈,前后端只傳遞資源id的話,前端是咋根據(jù)這個(gè)id渲染頁(yè)面呢娃闲?又是怎樣根據(jù)這個(gè)id顯示資源名稱的呢虚汛?這是因?yàn)榍岸吮镜赜写鎯?chǔ)一個(gè)映射字典,字典里有資源的信息皇帮,比如id對(duì)應(yīng)哪個(gè)路徑卷哩、名稱等等,前端拿到了用戶的id后根據(jù)字典進(jìn)行判斷就可以做到相應(yīng)的功能了属拾。
這個(gè)映射字典在實(shí)際開(kāi)發(fā)中有兩種管理模式将谊,一種是前后端采取約定的形式冷溶,前端自己就在代碼里造好了字典,如果后續(xù)資源有什么變化瓢娜,前后端人員溝通一下就好了挂洛,這種方式只適合權(quán)限資源特別簡(jiǎn)單的情況。還一種就是后端提供一個(gè)接口眠砾,接口返回所有的資源數(shù)據(jù)虏劲,每當(dāng)用戶登錄或進(jìn)入系統(tǒng)首頁(yè)的時(shí)候前端調(diào)用接口同步一下資源字典就好了!我們現(xiàn)在就用這種方式褒颈,所以還得寫一個(gè)接口出來(lái)才行:
/**
* 返回所有資源數(shù)據(jù)
*/
@GetMapping("/resource/list")
public List<Resource> getList() {
// SQL語(yǔ)句非常簡(jiǎn)單:select * from resource
return resourceService.list();
}
現(xiàn)在柒巫,我們的權(quán)限設(shè)計(jì)才像點(diǎn)樣子。這種用戶和權(quán)限資源綁定關(guān)系的模式就是ACL模型谷丸,即Access Control List訪問(wèn)控制列表堡掏,其特點(diǎn)是方便、易于理解刨疼,適合權(quán)限功能簡(jiǎn)單的系統(tǒng)泉唁。
我們乘熱打鐵,繼續(xù)將整個(gè)設(shè)計(jì)再升級(jí)一下揩慕!
RBAC模型
我這里為了方便演示所以沒(méi)有設(shè)置過(guò)多的權(quán)限資源(就是導(dǎo)航菜單)亭畜,所以整個(gè)權(quán)限系統(tǒng)用起來(lái)好像也挺方便的,不過(guò)一旦權(quán)限資源多了起來(lái)目前的設(shè)計(jì)有點(diǎn)捉襟見(jiàn)肘了迎卤。假設(shè)我們有100個(gè)權(quán)限資源拴鸵,A用戶要設(shè)置50個(gè)權(quán)限,BCD三個(gè)用戶也要設(shè)置這同樣的50個(gè)權(quán)限蜗搔,那么我必須為每個(gè)用戶都重復(fù)操作50下才行劲藐!這種需求還特別特別常見(jiàn),比如銷售部門的員工都擁有同樣的權(quán)限樟凄,每新來(lái)一個(gè)員工我就得給其一步一步重復(fù)地去設(shè)置權(quán)限聘芜,并且我要是更改這個(gè)銷售部門的權(quán)限,那么旗下所有員工的權(quán)限都得一一更改缝龄,極其繁瑣:
計(jì)算機(jī)科學(xué)領(lǐng)域的任何問(wèn)題都可以通過(guò)增加一個(gè)間接的中間層來(lái)解決
現(xiàn)在我們的權(quán)限關(guān)系是和用戶綁定的厉膀,所以每有一個(gè)新用戶我們就得為其設(shè)置一套專屬的權(quán)限。既然很多用戶的權(quán)限都是相同的二拐,那么我再封裝一層出來(lái),屏蔽用戶和權(quán)限之間的關(guān)系不就搞定了:
這樣有新的用戶時(shí)只需要將其和這個(gè)封裝層綁定關(guān)系凳兵,即可擁有一整套權(quán)限百新,將來(lái)就算權(quán)限更改也很方便。這個(gè)封裝層我們將它稱為角色庐扫!角色非常容易理解饭望,銷售人員是一種角色仗哨、后勤是一種角色,角色和權(quán)限綁定铅辞,用戶和角色綁定厌漂,就像上圖顯示的一樣。
既然加了一層角色斟珊,我們的表設(shè)計(jì)也要跟著改變苇倡。毋庸置疑,肯定得有一個(gè)角色表來(lái)專門描述角色信息囤踩,簡(jiǎn)單點(diǎn)就兩個(gè)字段主鍵id
旨椒、角色名稱
,這里添加兩個(gè)角色數(shù)據(jù)以作演示:
剛才說(shuō)的權(quán)限是和角色掛鉤的堵漱,那么之前的user_resource
表就要改成role_resource
综慎,然后用戶又和角色掛鉤,所以還得來(lái)一個(gè)user_role
表:
上面的數(shù)據(jù)表明勤庐,id為1
的角色(超級(jí)管理員)擁有三個(gè)權(quán)限資源示惊,id為2
的角色(數(shù)據(jù)管理員)只有一個(gè)權(quán)限資源。 然后用戶1
擁有超級(jí)管理員角色愉镰,用戶2
擁有數(shù)據(jù)管理員角色:
如果還有一個(gè)用戶想擁有超級(jí)管理員的所有權(quán)限米罚,只需要將該用戶和超級(jí)管理員角色綁定即可!這樣我們就完成了表的設(shè)計(jì)岛杀,現(xiàn)在我們數(shù)據(jù)庫(kù)表如下:
這就是非常著名且非常流行的RBAC模型阔拳,即Role-Based Access Controller基于角色訪問(wèn)控制模型!它能滿足絕大多數(shù)的權(quán)限要求类嗤,是業(yè)界最常用的權(quán)限模型之一糊肠。光說(shuō)不練假把式,現(xiàn)在表也設(shè)計(jì)好了遗锣,咱們接下來(lái)改進(jìn)我們的代碼并且和前端聯(lián)調(diào)起來(lái)货裹,完成一個(gè)基于角色的權(quán)限管理系統(tǒng)!
現(xiàn)在我們系統(tǒng)中有三個(gè)實(shí)體:用戶精偿、角色弧圆、資源(權(quán)限)。之前我們是有一個(gè)用戶頁(yè)面笔咽,在那一個(gè)頁(yè)面上就可以進(jìn)行權(quán)限管理搔预,現(xiàn)在我們多了角色這個(gè)概念,就還得添加一個(gè)角色頁(yè)面:
老樣子 分頁(yè)叶组、新增拯田、刪除的代碼我就不講解了,重點(diǎn)還是講一下關(guān)于權(quán)限操作的代碼甩十。
之前咱們的用戶頁(yè)面是直接操作權(quán)限的船庇,現(xiàn)在我們要改成操作角色吭产,所以SQL語(yǔ)句要按如下編寫:
<mapper namespace="com.rudecrab.rbac.mapper.RoleMapper">
<!--根據(jù)用戶id批量新增角色-->
<insert id="insertRolesByUserId">
insert into user_role(user_id, role_id) values
<foreach collection="roleIds" separator="," item="roleId">
(#{userId}, #{roleId})
</foreach>
</insert>
<!--根據(jù)用戶id刪除該用戶所有角色-->
<delete id="deleteByUserId">
delete from user_role where user_id = #{userId}
</delete>
<!--根據(jù)用戶id查詢角色id集合-->
<select id="selectIdsByUserId" resultType="java.lang.Long">
select role_id from user_role where user_id = #{userId}
</select>
</mapper>
除了用戶對(duì)角色的操作,我們還得有一個(gè)接口是拿用戶id直接獲取該用戶的所有權(quán)限鸭轮,這樣前端才好根據(jù)當(dāng)前用戶的權(quán)限進(jìn)行頁(yè)面渲染欲诺。之前我們是將resource
和user_resource
連表查詢出用戶的所有權(quán)限沉御,現(xiàn)在我們將user_role
和role_resource
連表拿到權(quán)限id沧奴,左邊是我們以前代碼右邊是我們改后的代碼:
關(guān)于用戶這一塊的操作到此就完成了奕污,我們接著來(lái)處理角色相關(guān)的操作。角色這里的思路和之前是一樣的吞鸭,之前用戶是怎樣直接操作權(quán)限的寺董,這里角色就怎樣操作權(quán)限:
<mapper namespace="com.rudecrab.rbac.mapper.ResourceMapper">
<!--根據(jù)角色id批量增加權(quán)限-->
<insert id="insertResourcesByRoleId">
insert into role_resource(role_id, resource_id) values
<foreach collection="resourceIds" separator="," item="resourceId">
(#{roleId}, #{resourceId})
</foreach>
</insert>
<!--根據(jù)角色id刪除該角色下所有權(quán)限-->
<delete id="deleteByRoleId">
delete from role_resource where role_id = #{roleId}
</delete>
<!--根據(jù)角色id獲取權(quán)限id-->
<select id="selectIdsByRoleId" resultType="java.lang.Long">
select resource_id from role_resource where role_id = #{roleId}
</select>
</mapper>
注意哦,這里前后端傳遞的也都是id
刻剥,既然是id
那么前端就得有映射字典才好渲染遮咖,所以我們這兩個(gè)接口是必不可少的:
/**
* 返回所有資源數(shù)據(jù)
*/
@GetMapping("/resource/list")
public List<Resource> getList() {
// SQL語(yǔ)句非常簡(jiǎn)單:select * from resource
return resourceService.list();
}
/**
* 返回所有角色數(shù)據(jù)
*/
@GetMapping("/role/list")
public List<Role> getList() {
// SQL語(yǔ)句非常簡(jiǎn)單:select * from role
return roleService.list();
}
字典有了,操作角色的方法有了造虏,操作權(quán)限的方法也有了御吞,至此我們就完成了基于RBAC模型的頁(yè)面權(quán)限功能:
root
用戶擁有數(shù)據(jù)管理員
的權(quán)限,一開(kāi)始數(shù)據(jù)管理員
只能看到數(shù)據(jù)管理
頁(yè)面漓藕,后面我們?yōu)?code>數(shù)據(jù)管理員又添加了賬戶管理
的頁(yè)面權(quán)限陶珠,root
用戶不做任何更改就可以看到賬戶管理
頁(yè)面了!
無(wú)論幾張表享钞,權(quán)限的核心還是我之前展示的那流程圖揍诽,思路掌握了怎樣的模型都是OK的。
不知道大家發(fā)現(xiàn)沒(méi)有栗竖,在前后端分離的模式下暑脆,后端在登錄的時(shí)候?qū)?quán)限數(shù)據(jù)甩給前端后就再也不管了,如果此時(shí)用戶的權(quán)限發(fā)生變化是無(wú)法通知前端的狐肢,并且數(shù)據(jù)存儲(chǔ)在前端也容易被用戶直接篡改添吗,所以很不安全。前后端分離不像未分離一樣份名,頁(yè)面請(qǐng)求都得走后端碟联,后端可以很輕松的就對(duì)每個(gè)頁(yè)面請(qǐng)求其進(jìn)行安全判斷:
@Controller
public class ViewController {
@Autowired
private ResourceService resourceService;
// 這些邏輯都可以放在過(guò)濾器統(tǒng)一做,這里只是為了方便演示
@GetMapping("/user/account")
public String userAccount() {
// 先從緩存或數(shù)據(jù)庫(kù)中取出當(dāng)前登錄用戶的權(quán)限數(shù)據(jù)
List<String> menus = resourceService.getCurrentUserMenus();
// 判斷有沒(méi)有權(quán)限
if (list.contains("/user/account")) {
// 有權(quán)限就返回正常頁(yè)面
return "user-account";
}
// 沒(méi)有權(quán)限就返回404頁(yè)面
return "404";
}
}
首先權(quán)限數(shù)據(jù)存儲(chǔ)在后端僵腺,被用戶直接篡改的可能就被屏蔽了鲤孵。并且每當(dāng)用戶訪問(wèn)頁(yè)面的時(shí)候后端都要實(shí)時(shí)查詢數(shù)據(jù),當(dāng)用戶權(quán)限數(shù)據(jù)發(fā)生變更時(shí)也能即時(shí)同步辰如。
這么一說(shuō)難道前后端分離模式下就得認(rèn)栽了普监?當(dāng)然不是,其實(shí)有一個(gè)騷操作就是前端發(fā)起每一次后端請(qǐng)求時(shí),后端都將最新的權(quán)限數(shù)據(jù)返回給前端鹰椒,這樣就能避免上述問(wèn)題了。不過(guò)這個(gè)方法會(huì)給網(wǎng)絡(luò)傳輸帶來(lái)極大的壓力呕童,既不優(yōu)雅也不明智漆际,所以一般都不這么干。折中的辦法就是當(dāng)用戶進(jìn)入某個(gè)頁(yè)面時(shí)重新獲取一次權(quán)限數(shù)據(jù)夺饲,比如首頁(yè)奸汇。不過(guò)這也不太安全,畢竟只要用戶不進(jìn)入首頁(yè)那還是沒(méi)用往声。
那么又優(yōu)雅又明智又安全的方式是什么呢擂找,就是我們接下來(lái)要講的操作權(quán)限了!
操作權(quán)限
操作權(quán)限就是將操作視為資源浩销,比如刪除操作贯涎,有些人可以有些人不行。于后端來(lái)說(shuō)慢洋,操作就是一個(gè)接口塘雳。于前端來(lái)說(shuō),操作往往是一個(gè)按鈕普筹,所以操作權(quán)限也被稱為按鈕權(quán)限败明,是一種細(xì)顆粒權(quán)限。
在頁(yè)面上比較直觀的體現(xiàn)就是沒(méi)有這個(gè)刪除權(quán)限的人就不會(huì)顯示該按鈕太防,或者該按鈕被禁用:
前端實(shí)現(xiàn)按鈕權(quán)限還是和之前導(dǎo)航菜單渲染一樣的妻顶,拿當(dāng)前用戶的權(quán)限資源id和權(quán)限資源字典對(duì)比,有權(quán)限就渲染出來(lái)蜒车,無(wú)權(quán)限就不渲染讳嘱。
前端關(guān)于權(quán)限的邏輯和之前一樣,那操作權(quán)限怎么就比頁(yè)面權(quán)限安全了呢醇王?這個(gè)安全主要體現(xiàn)在后端上呢燥,頁(yè)面渲染不走后端,但接口可必須得走后端寓娩,那只要走后端那就好辦了叛氨,我們只需要對(duì)每個(gè)接口進(jìn)行一個(gè)權(quán)限判斷就OK了嘛!
基本實(shí)現(xiàn)
咱們之前都是針對(duì)頁(yè)面權(quán)限進(jìn)行的設(shè)計(jì)棘伴,現(xiàn)在擴(kuò)展操作權(quán)限的話我們要對(duì)現(xiàn)有的resource
資源表進(jìn)行一個(gè)小小的擴(kuò)展寞埠,加一個(gè)type
字段來(lái)區(qū)分頁(yè)面權(quán)限和操作權(quán)限
這里我們用0
來(lái)表示頁(yè)面權(quán)限,用1
來(lái)表示操作權(quán)限焊夸。
表擴(kuò)展完畢仁连,我們接下來(lái)就要添加操作權(quán)限類型的數(shù)據(jù)。剛才也說(shuō)了,于后端而言操作就是一個(gè)接口饭冬,那么我們就要將 接口路徑 作為我們的權(quán)限資源使鹅,大家一看就都明白了:
DELETE:/API/user
分為兩個(gè)部分組成,DELETE:
表示該接口的請(qǐng)求方式昌抠,比如GET
患朱、POST
等,/API/user
則是接口路徑了炊苫,兩者組合起來(lái)就能確定一個(gè)接口請(qǐng)求裁厅!
數(shù)據(jù)有了,我們接著在代碼中進(jìn)行權(quán)限安全判斷侨艾,注意看注釋:
@RestController
@RequestMapping("/API/user")
public class UserController {
...省略自動(dòng)注入的service代碼
@DeleteMapping
public String deleteUser(Long[] ids) {
// 拿到所有權(quán)限路徑 和 當(dāng)前用戶擁有的權(quán)限路徑
Set<String> allPaths = resourceService.getAllPaths();
Set<String> userPaths = resourceService.getPathsByUserId(UserContext.getCurrentUserId());
// 第一個(gè)判斷:所有權(quán)限路徑中包含該接口执虹,才代表該接口需要權(quán)限處理,所以這是先決條件唠梨,
// 第二個(gè)判斷:判斷該接口是不是屬于當(dāng)前用戶的權(quán)限范圍袋励,如果不是,則代表該接口用戶沒(méi)有權(quán)限
if (allPaths.contains("DELETE:/API/user") && !userPaths.contains("DELETE:/API/user")) {
throw new ApiException(ResultCode.FORBIDDEN);
}
// 走到這代表該接口用戶是有權(quán)限的姻成,則進(jìn)行正常的業(yè)務(wù)邏輯處理
userService.removeByIds(Arrays.asList(ids));
return "操作成功";
}
...省略其他接口聲明
}
和前端聯(lián)調(diào)后插龄,前端就根據(jù)權(quán)限隱藏了相應(yīng)的操作按鈕:
按鈕是隱藏了,可如果用戶篡改本地權(quán)限數(shù)據(jù)科展,導(dǎo)致不該顯示的按鈕顯示了出來(lái)均牢,或者用戶知道了接口繞過(guò)頁(yè)面自行調(diào)用怎么辦?反正不管怎樣才睹,他最終都是要調(diào)用我們接口的徘跪,那我們就調(diào)用接口來(lái)試下效果:
可以看到,繞過(guò)前端的安全判斷也是沒(méi)有用的琅攘!
然后還有一個(gè)我們之前說(shuō)的問(wèn)題垮庐,如果當(dāng)前用戶權(quán)限被人修改了,如何實(shí)時(shí)和前端同步呢坞琴?比如哨查,一開(kāi)始A用戶的角色是有刪除權(quán)限
的,然后被一個(gè)管理員將他的該權(quán)限給去除了剧辐,可此時(shí)A用戶不重新登錄的話還是能看到刪除按鈕寒亥。
其實(shí)有了操作權(quán)限后,用戶就算能看到不屬于自己的按鈕也不損害安全性荧关,他點(diǎn)擊后還是會(huì)提示無(wú)權(quán)限溉奕,只是說(shuō)用戶體驗(yàn)稍微差點(diǎn)罷了! 頁(yè)面也是一樣忍啤,頁(yè)面只是一個(gè)容器加勤,用來(lái)承載數(shù)據(jù)的,而數(shù)據(jù)是要通過(guò)接口來(lái)調(diào)用的,比如圖中演示的分頁(yè)數(shù)據(jù)鳄梅,我們就可以將分頁(yè)查詢接口也做一個(gè)權(quán)限管理嘛叠国,這樣用戶就算繞過(guò)了頁(yè)面權(quán)限,來(lái)到了賬戶管理
板塊戴尸,照樣看不到絲毫數(shù)據(jù)煎饼!
至此,我們就完成了按鈕級(jí)的操作權(quán)限校赤,是不是很簡(jiǎn)單?再次啰嗦:只要掌握了核心思路筒溃,實(shí)現(xiàn)起來(lái)真的很簡(jiǎn)單马篮,不要想復(fù)雜了。
知道我風(fēng)格的讀者就知道怜奖,我接下來(lái)又要升級(jí)了浑测!沒(méi)錯(cuò),現(xiàn)在我們這種實(shí)現(xiàn)方式太簡(jiǎn)陋歪玲、太麻煩了迁央。我們現(xiàn)在都是手動(dòng)添加的資源數(shù)據(jù),寫一個(gè)接口我就要手動(dòng)加一個(gè)數(shù)據(jù)滥崩,要知道一個(gè)系統(tǒng)中成百上千個(gè)接口太正常了岖圈,那我手動(dòng)添加不得起飛咯?那有什么辦法钙皮,我寫接口的同時(shí)就自動(dòng)將資源數(shù)據(jù)給生成呢蜂科,那就是我接下來(lái)要講的接口掃描!
接口掃描
SpringMVC
提供了一個(gè)非常方便的類RequestMappingInfoHandlerMapping
短条,這個(gè)類可以拿到所有你聲明的web接口信息导匣,這個(gè)拿到后剩下的事不就非常簡(jiǎn)單了,就是通過(guò)代碼將接口信息批量添加到數(shù)據(jù)庫(kù)唄茸时!不過(guò)我們也不是要真的將所有接口都添加到權(quán)限資源中去贡定,我們要的是那些需要權(quán)限處理的接口生成權(quán)限資源,有些接口不需要權(quán)限處理那自然就不生成了可都。所以我們得想一個(gè)辦法來(lái)標(biāo)記一下該接口是否需要被權(quán)限管理缓待!
我們的接口都是通過(guò)方法來(lái)聲明的,標(biāo)記方法最方便的方式自然就是注解嘛汹粤!那我們先來(lái)自定義一個(gè)注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE}) // 表明該注解可以加在類或方法上
public @interface Auth {
/**
* 權(quán)限id命斧,需要唯一
*/
long id();
/**
* 權(quán)限名稱
*/
String name();
}
這個(gè)注解為啥這樣設(shè)計(jì)我等下再說(shuō),現(xiàn)在只需要曉得嘱兼,只要接口方法加上了這個(gè)注解国葬,我們就被視其為是需要權(quán)限管理的:
@RestController
@RequestMapping("/API/user")
@Auth(id = 1000, name = "用戶管理")
public class UserController {
...省略自動(dòng)注入的service代碼
@PostMapping
@Auth(id = 1, name = "新增用戶")
public String createUser(@RequestBody UserParam param) {
...省略業(yè)務(wù)代碼
return "操作成功";
}
@DeleteMapping
@Auth(id = 2, name = "刪除用戶")
public String deleteUser(Long[] ids) {
...省略業(yè)務(wù)代碼
return "操作成功";
}
@PutMapping
@Auth(id = 3, name = "編輯用戶")
public String updateRoles(@RequestBody UserParam param) {
...省略業(yè)務(wù)代碼
return "操作成功";
}
@GetMapping("/test/{id}")
@Auth(id = 4,name = "用于演示路徑參數(shù)")
public String testInterface(@PathVariable("id") String id) {
...省略業(yè)務(wù)代碼
return "操作成功";
}
...省略其他接口聲明
}
在講接口掃描和介紹注解設(shè)計(jì)前,我們先看一下最終的效果,看完效果后再去理解就事半功倍:
可以看到汇四,上面代碼中我在類和方法上都加上了我們自定義的Auth
注解接奈,并在注解中設(shè)置了id
和name
的值,這個(gè)name
好理解通孽,就是資源數(shù)據(jù)中的資源名稱嘛序宦。可注解里為啥要設(shè)計(jì)id
呢背苦,數(shù)據(jù)庫(kù)主鍵id
不是一般都是用自增嘛互捌。這是因?yàn)槲覀內(nèi)藶榭刂瀑Y源的主鍵id
有很多好處。
首先是id
和接口路徑的映射特別穩(wěn)定行剂,如果要用自增的話秕噪,我一個(gè)接口一開(kāi)始的權(quán)限id
是4
,一大堆角色綁定在這個(gè)資源4
上面了厚宰,然后我業(yè)務(wù)需求有一段時(shí)間不需要該接口做權(quán)限管理腌巾,于是我將這個(gè)資源4
刪除一段時(shí)間,后續(xù)再加回來(lái)铲觉,可數(shù)據(jù)再加回來(lái)的時(shí)候id
就變成5
澈蝙,之前與其綁定的角色又得重新設(shè)置資源,非常麻煩撵幽!如果這個(gè)id
是固定的話灯荧,我將這個(gè)接口權(quán)限一加回來(lái),之前所有設(shè)置好的權(quán)限都可以無(wú)感知地生效盐杂,非常非常方便漏麦。所以,id
和接口路徑的映射從一開(kāi)始就要穩(wěn)定下來(lái)况褪,不要輕易變更撕贞!
至于類上加上Auth
注解是方便模塊化管理接口權(quán)限,一個(gè)Controller
類咱們就視為一套接口模塊测垛,最終接口權(quán)限的id
就是模塊id
+ 方法id
捏膨。大家想一想如果不這么做的話,我要保證每一個(gè)接口權(quán)限id
唯一食侮,我就得記得各個(gè)類中所有方法的id
号涯,一個(gè)一個(gè)累加地去設(shè)置新id
。比如上一個(gè)方法我設(shè)置到了101
锯七,接著我就要設(shè)置102
链快、103
...,只要一沒(méi)注意就設(shè)置重了眉尸∮蛭希可如果按照Controller
類分好組后就特別方便管理了巨双,這個(gè)類是1000
、下一個(gè)類是2000
霉祸,然后類中所有方法就可以獨(dú)立地按照1
筑累、2
、3
來(lái)設(shè)置丝蹭,極大避免了心智負(fù)擔(dān)慢宗!
介紹了這么久注解的設(shè)計(jì),我們?cè)僦v解接口掃描的具體實(shí)現(xiàn)方式奔穿!這個(gè)掃描肯定是發(fā)生在我新接口寫完了镜沽,重新編譯打包重啟程序的時(shí)候!并且就只在程序啟動(dòng)的時(shí)候做一次掃描贱田,后續(xù)運(yùn)行期間是不可能再重復(fù)掃描的淘邻,重復(fù)掃描沒(méi)有任何意義嘛!既然是在程序啟動(dòng)時(shí)進(jìn)行的邏輯操作湘换,那么我們就可以使用SpringBoot提供的ApplicationRunner
接口來(lái)進(jìn)行處理,重寫該接口的方法會(huì)在程序啟動(dòng)時(shí)被執(zhí)行统阿。(程序啟動(dòng)時(shí)執(zhí)行指定邏輯有很多種辦法彩倚,并不局限于這一個(gè),具體使用根據(jù)需求來(lái))
我們現(xiàn)在就來(lái)創(chuàng)建一個(gè)類實(shí)現(xiàn)該接口扶平,并重寫其中的run
方法帆离,在其中寫上我們的接口掃描邏輯。注意结澄,下面代碼邏輯現(xiàn)在不用每一行都去理解哥谷,大概知道這么個(gè)寫法就行,重點(diǎn)是看注釋理解其大概意思麻献,將來(lái)再慢慢研究:
@Component
public class ApplicationStartup implements ApplicationRunner {
@Autowired
private RequestMappingInfoHandlerMapping requestMappingInfoHandlerMapping;
@Autowired
private ResourceService resourceService;
@Override
public void run(ApplicationArguments args) throws Exception {
// 掃描并獲取所有需要權(quán)限處理的接口資源(該方法邏輯寫在下面)
List<Resource> list = getAuthResources();
// 先刪除所有操作權(quán)限類型的權(quán)限資源们妥,待會(huì)再新增資源,以實(shí)現(xiàn)全量更新(注意哦勉吻,數(shù)據(jù)庫(kù)中不要設(shè)置外鍵监婶,否則會(huì)刪除失敗)
resourceService.deleteResourceByType(1);
// 如果權(quán)限資源為空齿桃,就不用走后續(xù)數(shù)據(jù)插入步驟
if (Collections.isEmpty(list)) {
return;
}
// 將資源數(shù)據(jù)批量添加到數(shù)據(jù)庫(kù)
resourceService.insertResources(list);
}
/**
* 掃描并返回所有需要權(quán)限處理的接口資源
*/
private List<Resource> getAuthResources() {
// 接下來(lái)要添加到數(shù)據(jù)庫(kù)的資源
List<Resource> list = new LinkedList<>();
// 拿到所有接口信息惑惶,并開(kāi)始遍歷
Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingInfoHandlerMapping.getHandlerMethods();
handlerMethods.forEach((info, handlerMethod) -> {
// 拿到類(模塊)上的權(quán)限注解
Auth moduleAuth = handlerMethod.getBeanType().getAnnotation(Auth.class);
// 拿到接口方法上的權(quán)限注解
Auth methodAuth = handlerMethod.getMethod().getAnnotation(Auth.class);
// 模塊注解和方法注解缺一個(gè)都代表不進(jìn)行權(quán)限處理
if (moduleAuth == null || methodAuth == null) {
return;
}
// 拿到該接口方法的請(qǐng)求方式(GET、POST等)
Set<RequestMethod> methods = info.getMethodsCondition().getMethods();
// 如果一個(gè)接口方法標(biāo)記了多個(gè)請(qǐng)求方式短纵,權(quán)限id是無(wú)法識(shí)別的带污,不進(jìn)行處理
if (methods.size() != 1) {
return;
}
// 將請(qǐng)求方式和路徑用`:`拼接起來(lái),以區(qū)分接口香到。比如:GET:/user/{id}鱼冀、POST:/user/{id}
String path = methods.toArray()[0] + ":" + info.getPatternsCondition().getPatterns().toArray()[0];
// 將權(quán)限名报破、資源路徑、資源類型組裝成資源對(duì)象雷绢,并添加集合中
Resource resource = new Resource();
resource.setType(1)
.setPath(path)
.setName(methodAuth.name())
.setId(moduleAuth.id() + methodAuth.id());
list.add(resource);
});
return list;
}
}
這樣泛烙,我們就完成了接口掃描啦!后續(xù)只要寫新接口需要權(quán)限處理時(shí)翘紊,只要加上Auth
注解就可以啦蔽氨!最終插入的數(shù)據(jù)就是之前展示的數(shù)據(jù)效果圖啦!
到這你以為就完了嘛帆疟,作為老套路人哪能這么輕易結(jié)束鹉究,我要繼續(xù)優(yōu)化!
咱們現(xiàn)在是核心邏輯 + 接口掃描踪宠,不過(guò)還不夠∽耘猓現(xiàn)在我們每一個(gè)權(quán)限安全判斷都是寫在方法內(nèi),且這個(gè)邏輯判斷代碼都是一樣的柳琢,我有多少個(gè)接口需要權(quán)限處理我就得寫多少重復(fù)代碼绍妨,這太惡心了:
@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);
}
...省略業(yè)務(wù)邏輯代碼
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);
}
...省略業(yè)務(wù)邏輯代碼
return "操作成功";
}
這種重復(fù)代碼,之前也提過(guò)一嘴了柬脸,當(dāng)然要用攔截器來(lái)做統(tǒng)一處理嘛他去!
攔截器
攔截器中的代碼和之前接口方法中寫的邏輯判斷大致一樣,還是一樣倒堕,看注釋理解大概思路即可:
public class AuthInterceptor extends HandlerInterceptorAdapter {
@Autowired
private ResourceService resourceService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 如果是靜態(tài)資源灾测,直接放行
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 獲取請(qǐng)求的最佳匹配路徑,這里的意思就是我之前數(shù)據(jù)演示的/API/user/test/{id}路徑參數(shù)
// 如果用uri判斷的話就是/API/user/test/100垦巴,就和路徑參數(shù)匹配不上了媳搪,所以要用這種方式獲得
String pattern = (String)request.getAttribute(
HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
// 將請(qǐng)求方式(GET、POST等)和請(qǐng)求路徑用 : 拼接起來(lái)骤宣,等下好進(jìn)行判斷秦爆。最終拼成字符串的就像這樣:DELETE:/API/user
String path = request.getMethod() + ":" + pattern;
// 拿到所有權(quán)限路徑 和 當(dāng)前用戶擁有的權(quán)限路徑
Set<String> allPaths = resourceService.getAllPaths();
Set<String> userPaths = resourceService.getPathsByUserId(UserContext.getCurrentUserId());
// 第一個(gè)判斷:所有權(quán)限路徑中包含該接口,才代表該接口需要權(quán)限處理憔披,所以這是先決條件鲜结,
// 第二個(gè)判斷:判斷該接口是不是屬于當(dāng)前用戶的權(quán)限范圍,如果不是活逆,則代表該接口用戶沒(méi)有權(quán)限
if (allPaths.contains(path) && !userPaths.contains(path)) {
throw new ApiException(ResultCode.FORBIDDEN);
}
// 有權(quán)限就放行
return true;
}
}
攔截器類寫好之后精刷,別忘了要使其生效,這里我們直接讓SpringBoot
啟動(dòng)類實(shí)現(xiàn)WevMvcConfigurer
接口來(lái)做:
@SpringBootApplication
public class RbacApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(RbacApplication.class, args);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加權(quán)限攔截器蔗候,并排除登錄接口(如果有登錄攔截器怒允,權(quán)限攔截器記得放在登錄攔截器后面)
registry.addInterceptor(authInterceptor()).excludePathPatterns("/API/login");
}
// 這里一定要用如此方式創(chuàng)建攔截器,否則攔截器中的自動(dòng)注入不會(huì)生效
@Bean
public AuthInterceptor authInterceptor() {return new AuthInterceptor();};
}
這樣锈遥,我們之前接口方法中的權(quán)限判斷的相關(guān)代碼都可以去除啦纫事!
至此勘畔,我們才算對(duì)頁(yè)面級(jí)權(quán)限 + 按鈕級(jí)權(quán)限有了一個(gè)比較不錯(cuò)的實(shí)現(xiàn)!
注意丽惶,攔截器中獲取權(quán)限數(shù)據(jù)現(xiàn)在是直接查的數(shù)據(jù)庫(kù)炫七,實(shí)際開(kāi)發(fā)中一定一定要將權(quán)限數(shù)據(jù)存在緩存里(如Redis),否則每個(gè)接口都要訪問(wèn)一遍數(shù)據(jù)庫(kù)钾唬,壓力太大了万哪!這里為了減少心智負(fù)擔(dān),我就不整合Redis了
數(shù)據(jù)權(quán)限
前面所介紹的頁(yè)面權(quán)限和操作權(quán)限都屬于功能權(quán)限抡秆,我們接下來(lái)要講的就是截然不同的數(shù)據(jù)權(quán)限奕巍。
功能權(quán)限和數(shù)據(jù)權(quán)限最大的不同就在于,前者是判斷有沒(méi)有某權(quán)限儒士,后者是判斷有多少權(quán)限的止。功能權(quán)限對(duì)資源的安全判斷只有YES和NO兩種結(jié)果,要么你就有這個(gè)權(quán)限要么你就沒(méi)有着撩。而資源權(quán)限所要求的是诅福,在同一個(gè)數(shù)據(jù)請(qǐng)求中,根據(jù)不同的權(quán)限范圍返回不同的數(shù)據(jù)集拖叙。
舉一個(gè)最簡(jiǎn)單的數(shù)據(jù)權(quán)限例子就是:現(xiàn)在列表里本身有十條數(shù)據(jù)氓润,其中有四條我沒(méi)有權(quán)限,那么我就只能查詢出六條數(shù)據(jù)憋沿。接下來(lái)我就帶大家來(lái)實(shí)現(xiàn)這個(gè)功能!
硬編碼
我們現(xiàn)在來(lái)模擬一個(gè)業(yè)務(wù)場(chǎng)景:一個(gè)公司在各個(gè)地方成立了分部沪猴,每個(gè)分部都有屬于自己分公司的訂單數(shù)據(jù)辐啄,沒(méi)有相應(yīng)權(quán)限是看不到的,每個(gè)人只能查看屬于自己權(quán)限的訂單运嗜,就像這樣:
都是同樣的分頁(yè)列表頁(yè)面壶辜,不同的人查出來(lái)了不同的結(jié)果。
這個(gè)分頁(yè)查詢功能沒(méi)什么好說(shuō)的担租,數(shù)據(jù)庫(kù)表的設(shè)計(jì)也非常簡(jiǎn)單砸民,我們建一個(gè)數(shù)據(jù)表data
和一個(gè)公司表company
,data
數(shù)據(jù)表中其他字段不是重點(diǎn)奋救,主要是要有一個(gè)company_id
字段用來(lái)關(guān)聯(lián)company
公司表岭参,這樣才能將數(shù)據(jù)分類,才能后續(xù)進(jìn)行權(quán)限的劃分:
我們權(quán)限劃分也很簡(jiǎn)單尝艘,就和之前一樣的演侯,建一個(gè)中間表即可。這里為了演示背亥,就直接將用戶和公司直接掛鉤了秒际,建一個(gè)user_company
表來(lái)表示用戶擁有哪些公司數(shù)據(jù)權(quán)限:
上面數(shù)據(jù)表明id為1
的用戶擁有id為1悬赏、2、3娄徊、4闽颇、5
的公司數(shù)據(jù)權(quán)限,id為2
的用戶擁有id為4寄锐、5
的公司數(shù)據(jù)權(quán)限兵多。
我相信大家經(jīng)過(guò)了功能權(quán)限的學(xué)習(xí)后,這點(diǎn)表設(shè)計(jì)已經(jīng)信手拈來(lái)了锐峭。表設(shè)計(jì)和數(shù)據(jù)準(zhǔn)備好后中鼠,接下來(lái)就是我們關(guān)鍵的權(quán)限功能實(shí)現(xiàn)。
首先沿癞,我們得梳理一下普通的分頁(yè)查詢是怎樣的援雇。我們要對(duì)data
進(jìn)行分頁(yè)查詢,SQL
語(yǔ)句會(huì)按照如下編寫:
-- 按照創(chuàng)建時(shí)間降序排序
SELECT * FROM `data` ORDER BY create_time DESC LIMIT ?,?
這個(gè)沒(méi)什么好說(shuō)的椎扬,正常查詢數(shù)據(jù)然后進(jìn)行limit
限制以達(dá)到分頁(yè)的效果惫搏。那么我們要加上數(shù)據(jù)過(guò)濾功能,只需要在SQL
上進(jìn)行過(guò)濾不就搞定了:
-- 只查詢指定公司的數(shù)據(jù)
SELECT * FROM `data` where company_id in (?, ?, ?...) ORDER BY create_time DESC LIMIT ?,?
我們只需要先將用戶所屬的公司id
全部查出來(lái)蚕涤,然后放到分頁(yè)語(yǔ)句中的in
中即可達(dá)到效果筐赔。
我們不用in
條件判斷,使用連表也是可以達(dá)到效果的:
-- 連接 用戶-公司 關(guān)系表揖铜,查詢指定用戶關(guān)聯(lián)的公司數(shù)據(jù)
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 ?,?
當(dāng)然茴丰,不用連表用子查詢也可以實(shí)現(xiàn),這里就不過(guò)多展開(kāi)了天吓』呒纾總之,能夠達(dá)到過(guò)濾效果的SQL
語(yǔ)句有很多龄寞,根據(jù)業(yè)務(wù)特點(diǎn)優(yōu)化就好汰规。
到這里我其實(shí)就已經(jīng)介紹完一種非常簡(jiǎn)單粗暴的數(shù)據(jù)權(quán)限實(shí)現(xiàn)方式了:硬編碼!即物邑,直接修改我們?cè)械?code>SQL語(yǔ)句溜哮,自然而然就達(dá)到效果了嘛~
不過(guò)這種方式對(duì)原有代碼入侵太大了,每個(gè)要權(quán)限過(guò)濾的接口我都得修改色解,嚴(yán)重影響了開(kāi)閉原則茂嗓。有啥辦法可以不對(duì)原有接口進(jìn)行修改嗎?當(dāng)然是有的科阎,這就是我接下來(lái)要介紹的Mybatis
攔截插件在抛。
Mybatis攔截插件
Mybatis
提供了一個(gè)Interceptor
接口,通過(guò)實(shí)現(xiàn)該接口可以定義我們自己的攔截器萧恕,這個(gè)攔截器可以對(duì)SQL
語(yǔ)句進(jìn)行攔截刚梭,然后擴(kuò)展/修改肠阱。許多分頁(yè)、分庫(kù)分表朴读、加密解密等插件都是通過(guò)該接口完成的屹徘!
我們只需要攔截到原有的SQL
語(yǔ)句后,添加上我們額外的語(yǔ)句衅金,不就和剛才硬編碼一樣實(shí)現(xiàn)了效果噪伊?這里我先給大家看一下我已經(jīng)寫好了的攔截器效果:
可以看到,紅框框起來(lái)的部分就是在原SQL
上添加的語(yǔ)句氮唯!這個(gè)攔截并不僅限于分頁(yè)查詢鉴吹,只要我們寫好語(yǔ)句擴(kuò)展規(guī)則,其他語(yǔ)句都是可以攔截?cái)U(kuò)展的惩琉!
接下來(lái)我就貼上攔截器的代碼豆励,注意這個(gè)代碼大家不用過(guò)多地去糾結(jié),大概瞟一眼知道有這么個(gè)玩意就行了瞒渠,因?yàn)楝F(xiàn)在我們的重點(diǎn)是整體思路良蒸,先跟著我的思路來(lái),代碼有的是時(shí)間再看:
@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的一些對(duì)象,等下要操作
StatementHandler statementHandler = PluginUtils.realTarget(invocation.getTarget());
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// id為執(zhí)行的mapper方法的全路徑名伍玖,如com.rudecrab.mapper.UserMapper.insertUser
String id = mappedStatement.getId();
log.info("mapper: ==> {}", id);
// 如果不是指定的方法嫩痰,直接結(jié)束攔截
// 如果方法多可以存到一個(gè)集合里,然后判斷當(dāng)前攔截的是否存在集合中窍箍,這里為了演示只攔截一個(gè)mapper方法
if (!"com.rudecrab.rbac.mapper.DataMapper.selectPage".equals(id)) {
return invocation.proceed();
}
// 獲取到原始sql語(yǔ)句
String sql = statementHandler.getBoundSql().getSql();
log.info("原始SQL語(yǔ)句: ==> {}", sql);
// 解析并返回新的SQL語(yǔ)句
sql = getSql(sql);
// 修改sql
metaObject.setValue("delegate.boundSql.sql", sql);
log.info("攔截后SQL語(yǔ)句:==>{}", sql);
return invocation.proceed();
}
/**
* 解析SQL語(yǔ)句串纺,并返回新的SQL語(yǔ)句
* 注意,該方法使用了JSqlParser來(lái)操作SQL椰棘,該依賴包Mybatis-plus已經(jīng)集成了纺棺。如果要單獨(dú)使用,請(qǐng)先自行導(dǎo)入依賴
*
* @param sql 原SQL
* @return 新SQL
*/
private String getSql(String sql) {
try {
// 解析語(yǔ)句
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);
}
// 創(chuàng)建連表join條件
Join join = new Join();
join.setInner(true);
join.setRightItem(new Table("user_company uc"));
// 第一個(gè):兩表通過(guò)company_id連接
EqualsTo joinExpression = new EqualsTo();
joinExpression.setLeftExpression(new Column(mainTable + ".company_id"));
joinExpression.setRightExpression(new Column("uc.company_id"));
// 第二個(gè)條件:和當(dāng)前登錄用戶id匹配
EqualsTo userIdExpression = new EqualsTo();
userIdExpression.setLeftExpression(new Column("uc.user_id"));
userIdExpression.setRightExpression(new LongValue(UserContext.getCurrentUserId()));
// 將兩個(gè)條件拼接起來(lái)
join.setOnExpression(new AndExpression(joinExpression, userIdExpression));
joins.add(join);
ps.setJoins(joins);
// 修改原語(yǔ)句
sql = ps.toString();
} catch (JSQLParserException e) {
e.printStackTrace();
}
return sql;
}
}
SQL
攔截器寫好后就會(huì)非常方便了晰搀,之前寫好的代碼不用修改五辽,直接用攔截器進(jìn)行統(tǒng)一處理即可办斑!如此外恕,我們就完成了一個(gè)簡(jiǎn)單的數(shù)據(jù)權(quán)限功能!是不是感覺(jué)太簡(jiǎn)單了點(diǎn)乡翅,這么一會(huì)就將數(shù)據(jù)權(quán)限介紹完啦鳞疲?
說(shuō)簡(jiǎn)單也確實(shí)簡(jiǎn)單,其核心一句話就可以表明:對(duì)SQL
進(jìn)行攔截然后達(dá)到數(shù)據(jù)過(guò)濾的效果蠕蚜。但是匾竿!我這里只是演示了一個(gè)特別簡(jiǎn)單的案例第美,考慮的層面特別少,如果需求一旦復(fù)雜起來(lái)那需要考慮的東西我這篇文章再加幾倍內(nèi)容只怕也難以說(shuō)完咖驮。
數(shù)據(jù)權(quán)限和業(yè)務(wù)關(guān)聯(lián)性極強(qiáng),有很多自己行業(yè)特點(diǎn)的權(quán)限劃分維度个唧,比如交易金額、交易時(shí)間、地區(qū)睛挚、年齡、用戶標(biāo)簽等等等等急黎,我們這只演示了一個(gè)部門維度的劃分而已扎狱。有些數(shù)據(jù)權(quán)限甚至要做到多個(gè)維度交叉,還要做到到能對(duì)某個(gè)字段進(jìn)行數(shù)據(jù)過(guò)濾(比如A管理員能看到手機(jī)號(hào)勃教、交易金額淤击,B管理員看不到),其難度和復(fù)雜度遠(yuǎn)超功能權(quán)限故源。
所以對(duì)于數(shù)據(jù)權(quán)限污抬,一定是需求在先,技術(shù)手段再跟上心软。至于你是要用Mybatis
還是其他什么框架壕吹,你是要用子查詢還是用連表,都沒(méi)有定式而言删铃,一定得根據(jù)具體的業(yè)務(wù)需求來(lái)制定針對(duì)性的數(shù)據(jù)過(guò)濾方案耳贬!
總結(jié)
到這里,關(guān)于權(quán)限的講解就接近尾聲了猎唁。其實(shí)本文說(shuō)了那么多也就只是在闡述以下幾點(diǎn):
- 權(quán)限的本質(zhì)就是保護(hù)資源
- 權(quán)限設(shè)計(jì)的核心就是 保護(hù)什么資源咒劲、如何保護(hù)資源
- 核心掌握后,根據(jù)具體的業(yè)務(wù)需求來(lái)制定方案即可诫隅,萬(wàn)變不離其宗
代碼從來(lái)就不是重點(diǎn)腐魂,重點(diǎn)的是思路!如果還有一些地方不太理解的也沒(méi)關(guān)系逐纬,可以參考項(xiàng)目效果來(lái)幫助理解思路蛔屹。本文所有代碼、SQL
語(yǔ)句都放在了Github上豁生,克隆下來(lái)即可運(yùn)行兔毒,不止有后端接口,前端頁(yè)面也是有的哦甸箱!我會(huì)持續(xù)更多【項(xiàng)目實(shí)踐】的育叁!
這兩篇文章講的是不使用安全框架,手?jǐn)]認(rèn)證和授權(quán)的功能芍殖。那么接下來(lái)的文章就講解如何使用安全框架Shiro
豪嗽、Spring Scurity
實(shí)現(xiàn)認(rèn)證和授權(quán),敬請(qǐng)期待!
博客龟梦、Github隐锭、微信公眾號(hào)請(qǐng)認(rèn)準(zhǔn):RudeCrab,歡迎關(guān)注计贰!如果對(duì)你有幫助可以收藏成榜、點(diǎn)贊、star蹦玫、在看赎婚、分享~~ 你的支持,就是我寫文的最大動(dòng)力
微信上轉(zhuǎn)載請(qǐng)聯(lián)系公眾號(hào)開(kāi)啟白名單樱溉,其他地方轉(zhuǎn)載請(qǐng)標(biāo)明原地址挣输、原作者!