【項目實踐】一文帶你搞定頁面權限、按鈕權限以及數據權限


權限授權.png

以項目驅動學習,以實踐檢驗真知

前言

權限這一概念可以說是隨處可見:等級不夠進入不了某個論壇版塊、對別人發的文章我只能點贊評論但不能刪除或修改、朋友圈一些我看得了一些看不了,一些能看七天內的動態一些能看到所有動態等等等等。

每個系統的權限功能都不盡相同,各有其自身的業務特點,對權限管理的設計也都各有特色。不過不管是怎樣的權限設計,大致可歸為三種:頁面權限(菜單級)、操作權限(按鈕級)、數據權限,按維度划分的話就是:粗顆粒權限、細顆粒權限

本文的重點是權限,為了方便演示我會省略非權限相關的代碼,比如登錄認證、密碼加密等等。如果對於登錄認證(Authentication)相關知識不太清楚的話,可以先看我上一篇寫的【項目實踐】在用安全框架前,我想先讓你手擼一個登陸認證。和上篇一樣,本文的目的是帶大家了解權限授權(Authorization)的核心,所以直接帶你手擼權限授權,不會用上安全框架。核心搞清楚后,什么安全框架理解使用起來都會非常容易。

我會從最簡單、最基礎的講解起,由淺入深、一步一步帶大家實現各個功能。讀完文章你能收獲:

  • 權限授權的核心概念
  • 頁面權限、操作權限、數據權限的設計與實現
  • 權限模型的演進與使用
  • 接口掃描與SQL攔截

並且本文所有代碼、SQL語句都放在了Github上,克隆下來即可運行,不止有后端接口,前端頁面也是有的哦!

基礎知識

登錄認證(Authentication)是對用戶的身份進行確認,權限授權(Authorization)是對用戶能否問某個資源進行確認。比如你輸入賬號密碼登錄到某個論壇,這就是認證。你這個賬號是管理員所以想進哪個板塊就進哪個板塊,這就是授權。權限授權通常發生在登錄認證成功之后,即先得確認你是誰,然后再確認你能訪問什么。再舉個例子大家就清楚了:

系統:你誰啊?

用戶:我張三啊,這是我賬號密碼你看看

系統:哎喲,賬號密碼沒錯,看來是法外狂徒張三!你要干嘛呀(登錄認證)

張三:我想進金庫看看哇

系統:滾犢子,你只能進看守所,其他地方哪也去不了(權限授權)

可以看到權限的概念一點都不難,它就像是一個防火牆,保護資源不受侵害(沒錯,平常我們總說的網絡防火牆也是權限的一種體現,不得不說網絡防火牆這名字起得真貼切)。現在其實已經說清楚權限的本質是什么了,就是保護資源。無論是怎樣的功能要求,權限其核心都是圍繞在資源二字上。不能訪問論壇版塊,此時版塊是資源;不能進入某些區域,此時區域是資源……

進行權限系統的設計,第一步就是考慮要保護什么資源,再接着思考如何保護這個資源。這句話是本文的重點,接下來我會詳細地詮釋這句話!

保護什么資源,決定了你的權限粒度。怎樣保護資源,決定了你的.....

實現

我們使用SpringBoot搭建Web項目,MySQLMybatis-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>

在設計權限相關的表之前,肯定是先得有一個最基礎的用戶表,字段很簡單就三個,主鍵、用戶名、密碼:

user表.png

對應的實體類和SQL建表語句我就不寫了,大家一看表結構都知道該咋寫(github上我放了完整的SQL建表文件)。

接下來我們就先實現一種非常簡單的權限控制!

頁面權限

頁面權限非常容易理解,就是有這個權限的用戶才能訪問這個頁面,沒這個權限的用戶就無法訪問,它是以整個頁面為維度,對權限的控制並沒有那么細,所以是一種粗顆粒權限

最直觀的一個例子就是,有權限的用戶就會顯示所有菜單,無權限的用戶就只會顯示部分菜單:

菜單對比.png

這些菜單都對應着一個頁面,控制了導航菜單就相當於控制住了頁面入口,所以頁面權限通常也可稱為菜單權限

權限核心

就像之前所說,要設計一個權限系統第一步就是要考慮 保護什么資源,頁面權限這種要保護的資源那必然是頁面嘛。一個頁面(菜單)對應一個URI地址,當用戶登錄的時候判斷這個用戶擁有哪些頁面權限,自然而然就知道要渲染出什么導航菜單了!這些理清楚后表的設計自然浮現眼前:

resource表2.png

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

菜單名映射.png

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

頁面權限數據.png

上面的數據就表明,id1的用戶擁有所有的權限,id2的用戶只擁有數據管理權限(首頁我們就讓所有用戶都能進,畢竟一個用戶你至少還是得讓他能看到一些最基本的東西嘛)。至此,我們就完成了頁面權限的數據庫表設計!

數據干巴巴放在那毫無作用,所以接下來我們就要進行代碼的編寫來使用這些數據。代碼實現分為后端和前端,在前后端沒有分離的時候,邏輯的處理和頁面的渲染都是在后端進行,所以整體的邏輯鏈路是這樣的:

頁面權限-未分離.png用戶登錄后訪問頁面,我們來編寫一下頁面接口:

@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數據,頁面渲染是前端的事,此時整體的邏輯鏈路就發生了變化:

頁面權限-分離.png

那么用戶登錄成功的同時,后端要將用戶的權限數據返回給前端,這是我們登錄接口:

@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),然后根據權限數據渲染菜單,前后端分離模式下的權限功能就這樣完成了。我們來看一下效果:

頁面路由404.gif

到目前為止,頁面權限的基本邏輯鏈路就介紹完畢了,是不是非常簡單?基本的邏輯弄清楚之后,剩下的不過就是非常普通的增刪改查:當我想要讓一個用戶的權限變大時就對這個用戶的權限數據進行增加,想讓一個用戶的權限變小時就對這個用戶的權限數據進行刪除……接下來我們就完成這一步,讓系統的用戶能夠對權限進行管理,否則干什么都要直接操作數據庫那肯定是不行的。

首先,肯定是得先讓用戶能夠看到一個數據列表然后才能進行操作,我新增了一些數據來方便展示效果:

賬戶管理分頁.png

這里分頁、新增賬戶、刪除賬戶的代碼怎么寫我就不講解了,就講一下對權限進行編輯的接口:

@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>

如此就完成了權限數據編輯的功能:

頁面權限編輯.gif

可以看到root用戶之前是只能訪問數據管理,對其進行權限編輯后,他就也能訪問賬戶管理了,現在我們的頁面權限管理功能才算完成。

是不是感覺非常簡單,我們僅僅用了兩張表就完成了一個權限管理功能。

ACL模型

兩張表十分方便且容易理解,系統小數據量小這樣玩沒啥,如果數據量大就有其弊端所在:

  1. 數據重復極大
    • 消耗存儲資源。比如/user/account,我有多少用戶有這權限我就得存儲多少個這樣的字符串。要知道這還是最簡單的資源信息呢,只有一個路徑,有些資源的信息可有很多喲:資源名稱、類型、等級、介紹等等等等
    • 更改資源成本過大。比如/data我要改成/info,那現有的那些權限數據都要跟着改
  2. 設計不合理
    • 無法直觀描述資源。剛才我們只弄了三個資源,如果我系統中想添加第四、五...種資源是沒有辦法的,因為現在的資源都是依賴於用戶而存在,根本不能獨立存儲起來
    • 表的釋義不清。現在我們的resource表與其說是在描述資源,倒不如說是在描述用戶和資源的關系。

為了解決上述問題,我們應當對當前表設計進行改良,要將資源用戶和資源的關系拎清。用戶和資源的關系是多對多的,一個用戶可以有多個權限,一個權限下可以有多個用戶,我們一般都用中間表來描述這種多對多關系。然后資源表就不用來描述關系了,只用來描述資源。 這樣我們新的表設計就出來了:建立中間表,改進資源表!

我們先來對資源表進行改造,iduser_idpath這是之前的三個字段,user_id並不是用來描述資源的,所以我們將它刪除。然后我們再額外加一個name字段用來描述資源名稱(非必須),改造后此時資源表如下:

3-資源表.png

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

3-資源表數據.png

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

用戶資源表.png

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

用戶-資源數據.png

現在的數據表明,id為1的用戶擁有id為1、2、3的權限,即用戶1擁有賬戶管理、角色管理、數據管理權限。id為2的用戶只擁有id為3的資源權限,即用戶2擁有數據管理權限!

整個表設計就如此升級完畢了,現在我們的表如下:

三張表.png

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

3-代碼差異.png

其中重點就是之前我們都是操作資源表的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下才行!這種需求還特別特別常見,比如銷售部門的員工都擁有同樣的權限,每新來一個員工我就得給其一步一步重復地去設置權限,並且我要是更改這個銷售部門的權限,那么旗下所有員工的權限都得一一更改,極其繁瑣:

權限重復.png

計算機科學領域的任何問題都可以通過增加一個間接的中間層來解決

現在我們的權限關系是和用戶綁定的,所以每有一個新用戶我們就得為其設置一套專屬的權限。既然很多用戶的權限都是相同的,那么我再封裝一層出來,屏蔽用戶和權限之間的關系不就搞定了:

權限封裝層.png

這樣有新的用戶時只需要將其和這個封裝層綁定關系,即可擁有一整套權限,將來就算權限更改也很方便。這個封裝層我們將它稱為角色!角色非常容易理解,銷售人員是一種角色、后勤是一種角色,角色和權限綁定,用戶和角色綁定,就像上圖顯示的一樣。

既然加了一層角色,我們的表設計也要跟着改變。毋庸置疑,肯定得有一個角色表來專門描述角色信息,簡單點就兩個字段主鍵id角色名稱,這里添加兩個角色數據以作演示:

role表和數據.png

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

兩張關系表和數據.png

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

用戶-角色-權限示意圖.png

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

RBAC五張表.png

這就是非常著名且非常流行的RBAC模型,即Role-Based Access Controller基於角色訪問控制模型!它能滿足絕大多數的權限要求,是業界最常用的權限模型之一。光說不練假把式,現在表也設計好了,咱們接下來改進我們的代碼並且和前端聯調起來,完成一個基於角色的權限管理系統!

現在我們系統中有三個實體:用戶、角色、資源(權限)。之前我們是有一個用戶頁面,在那一個頁面上就可以進行權限管理,現在我們多了角色這個概念,就還得添加一個角色頁面:

5-賬戶管理頁面.png

5-角色管理頁面.png

老樣子 分頁、新增、刪除的代碼我就不講解了,重點還是講一下關於權限操作的代碼。

之前咱們的用戶頁面是直接操作權限的,現在我們要改成操作角色,所以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直接獲取該用戶的所有權限,這樣前端才好根據當前用戶的權限進行頁面渲染。之前我們是將resourceuser_resource連表查詢出用戶的所有權限,現在我們將user_rolerole_resource連表拿到權限id,左邊是我們以前代碼右邊是我們改后的代碼:

用戶id獲取權限代碼差異.png

關於用戶這一塊的操作到此就完成了,我們接着來處理角色相關的操作。角色這里的思路和之前是一樣的,之前用戶是怎樣直接操作權限的,這里角色就怎樣操作權限:

<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模型的頁面權限功能:

頁面操作角色.gif

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";
    }
    
}

首先權限數據存儲在后端,被用戶直接篡改的可能就被屏蔽了。並且每當用戶訪問頁面的時候后端都要實時查詢數據,當用戶權限數據發生變更時也能即時同步。

這么一說難道前后端分離模式下就得認栽了?當然不是,其實有一個騷操作就是前端發起每一次后端請求時,后端都將最新的權限數據返回給前端,這樣就能避免上述問題了。不過這個方法會給網絡傳輸帶來極大的壓力,既不優雅也不明智,所以一般都不這么干。折中的辦法就是當用戶進入某個頁面時重新獲取一次權限數據,比如首頁。不過這也不太安全,畢竟只要用戶不進入首頁那還是沒用。

那么又優雅又明智又安全的方式是什么呢,就是我們接下來要講的操作權限了!

操作權限

操作權限就是將操作視為資源,比如刪除操作,有些人可以有些人不行。於后端來說,操作就是一個接口。於前端來說,操作往往是一個按鈕,所以操作權限也被稱為按鈕權限,是一種細顆粒權限

在頁面上比較直觀的體現就是沒有這個刪除權限的人就不會顯示該按鈕,或者該按鈕被禁用:

刪除按鈕-3.png

前端實現按鈕權限還是和之前導航菜單渲染一樣的,拿當前用戶的權限資源id和權限資源字典對比,有權限就渲染出來,無權限就不渲染

前端關於權限的邏輯和之前一樣,那操作權限怎么就比頁面權限安全了呢?這個安全主要體現在后端上,頁面渲染不走后端,但接口可必須得走后端,那只要走后端那就好辦了,我們只需要對每個接口進行一個權限判斷就OK了嘛!

基本實現

咱們之前都是針對頁面權限進行的設計,現在擴展操作權限的話我們要對現有的resource資源表進行一個小小的擴展,加一個type字段來區分頁面權限和操作權限

資源表type.png

這里我們用0來表示頁面權限,用1來表示操作權限。

表擴展完畢,我們接下來就要添加操作權限類型的數據。剛才也說了,於后端而言操作就是一個接口,那么我們就要將 接口路徑 作為我們的權限資源,大家一看就都明白了:

操作權限-資源數據1.png

DELETE:/API/user分為兩個部分組成,DELETE:表示該接口的請求方式,比如GETPOST等,/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 "操作成功";
    }
    
    ...省略其他接口聲明
}

和前端聯調后,前端就根據權限隱藏了相應的操作按鈕:

沒有按鈕基本演示.gif

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

自行調用接口.png

可以看到,繞過前端的安全判斷也是沒有用的!

然后還有一個我們之前說的問題,如果當前用戶權限被人修改了,如何實時和前端同步呢?比如,一開始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 "操作成功";
    }

    ...省略其他接口聲明
}

在講接口掃描和介紹注解設計前,我們先看一下最終的效果,看完效果后再去理解就事半功倍:

操作權限資源數據1.png

可以看到,上面代碼中我在類和方法上都加上了我們自定義的Auth注解,並在注解中設置了idname的值,這個name好理解,就是資源數據中的資源名稱嘛。可注解里為啥要設計id呢,數據庫主鍵id不是一般都是用自增嘛。這是因為我們人為控制資源的主鍵id有很多好處。

首先是id和接口路徑的映射特別穩定,如果要用自增的話,我一個接口一開始的權限id4,一大堆角色綁定在這個資源4上面了,然后我業務需求有一段時間不需要該接口做權限管理,於是我將這個資源4刪除一段時間,后續再加回來,可數據再加回來的時候id就變成5,之前與其綁定的角色又得重新設置資源,非常麻煩!如果這個id是固定的話,我將這個接口權限一加回來,之前所有設置好的權限都可以無感知地生效,非常非常方便。所以,id和接口路徑的映射從一開始就要穩定下來,不要輕易變更!

至於類上加上Auth注解是方便模塊化管理接口權限,一個Controller類咱們就視為一套接口模塊,最終接口權限的id就是模塊id + 方法id。大家想一想如果不這么做的話,我要保證每一個接口權限id唯一,我就得記得各個類中所有方法的id,一個一個累加地去設置新id。比如上一個方法我設置到了101,接着我就要設置102103...,只要一沒注意就設置重了。可如果按照Controller類分好組后就特別方便管理了,這個類是1000、下一個類是2000,然后類中所有方法就可以獨立地按照123來設置,極大避免了心智負擔!

介紹了這么久注解的設計,我們再講解接口掃描的具體實現方式!這個掃描肯定是發生在我新接口寫完了,重新編譯打包重啟程序的時候!並且就只在程序啟動的時候做一次掃描,后續運行期間是不可能再重復掃描的,重復掃描沒有任何意義嘛!既然是在程序啟動時進行的邏輯操作,那么我們就可以使用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數據.png

部分data數據.png

都是同樣的分頁列表頁面,不同的人查出來了不同的結果。

這個分頁查詢功能沒什么好說的,數據庫表的設計也非常簡單,我們建一個數據表data和一個公司表companydata數據表中其他字段不是重點,主要是要有一個company_id字段用來關聯company公司表,這樣才能將數據分類,才能后續進行權限的划分:

公司-data數據.png

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

用戶-公司權限數據.png

上面數據表明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語句后,添加上我們額外的語句,不就和剛才硬編碼一樣實現了效果?這里我先給大家看一下我已經寫好了的攔截器效果:

攔截日志.png

可以看到,紅框框起來的部分就是在原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還是其他什么框架,你是要用子查詢還是用連表,都沒有定式而言,一定得根據具體的業務需求來制定針對性的數據過濾方案!

總結

到這里,關於權限的講解就接近尾聲了。其實本文說了那么多也就只是在闡述以下幾點:

  1. 權限的本質就是保護資源
  2. 權限設計的核心就是 保護什么資源、如何保護資源
  3. 核心掌握后,根據具體的業務需求來制定方案即可,萬變不離其宗

代碼從來就不是重點,重點的是思路!如果還有一些地方不太理解的也沒關系,可以參考項目效果來幫助理解思路。本文所有代碼、SQL語句都放在了Github上,克隆下來即可運行,不止有后端接口,前端頁面也是有的哦!我會持續更多【項目實踐】的!

這兩篇文章講的是不使用安全框架,手擼認證和授權的功能。那么接下來的文章就講解如何使用安全框架Spring Scurity實現認證和授權,敬請期待!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM