【專案實踐】一文帶你搞定頁面許可權、按鈕許可權以及資料許可權

RudeCrab發表於2021-01-11

許可權授權.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實現認證和授權,敬請期待!

相關文章