【項(xiàng)目實(shí)踐】一文帶你搞定頁(yè)面權(quán)限、按鈕權(quán)限以及數(shù)據(jù)權(quán)限

權(quán)限授權(quán).png

以項(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)目,MySQLMybatis-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è),主鍵梁呈、用戶名婚度、密碼:

user表.png

對(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ì)比.png

這些菜單都對(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)眼前:

resource表2.png

這個(gè)資源表非常簡(jiǎn)單但目前足夠用了,假設(shè)我們頁(yè)面/菜單的URI映射如下:

菜單名映射.png

我們要設(shè)置用戶的權(quán)限話抚官,只要將用戶id和URI對(duì)應(yīng)起來(lái)即可:

頁(yè)面權(quán)限數(shù)據(jù).png

上面的數(shù)據(jù)就表明扬跋,id1的用戶擁有所有的權(quán)限,id2的用戶只擁有數(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)行,所以整體的邏輯鏈路是這樣的:

頁(yè)面權(quán)限-未分離.png

用戶登錄后訪問(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ā)生了變化:

頁(yè)面權(quán)限-分離.png

那么用戶登錄成功的同時(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è)面路由404.gif

到目前為止,頁(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è).png

這里分頁(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ù)編輯的功能:

頁(yè)面權(quán)限編輯.gif

可以看到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ù)量大就有其弊端所在:

  1. 數(shù)據(jù)重復(fù)極大
    • 消耗存儲(chǔ)資源原杂。比如/user/account,我有多少用戶有這權(quán)限我就得存儲(chǔ)多少個(gè)這樣的字符串您机。要知道這還是最簡(jiǎn)單的資源信息呢穿肄,只有一個(gè)路徑,有些資源的信息可有很多喲:資源名稱际看、類型咸产、等級(jí)、介紹等等等等
    • 更改資源成本過(guò)大仲闽。比如/data我要改成/info脑溢,那現(xiàn)有的那些權(quán)限數(shù)據(jù)都要跟著改
  2. 設(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_idpath這是之前的三個(gè)字段捌木,user_id并不是用來(lái)描述資源的油坝,所以我們將它刪除。然后我們?cè)兕~外加一個(gè)name字段用來(lái)描述資源名稱(非必須)刨裆,改造后此時(shí)資源表如下:

3-資源表.png

表里的內(nèi)容就專門用來(lái)放資源:

3-資源表數(shù)據(jù).png

資源表搞定了咱們建立一個(gè)中間表用來(lái)描述用戶和權(quán)限的關(guān)系澈圈,中間表很簡(jiǎn)單就只存用戶id和資源id:

用戶資源表.png

之前的權(quán)限關(guān)系在中間表里就是這樣存儲(chǔ)的了:

用戶-資源數(shù)據(jù).png

現(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)在我們的表如下:

三張表.png

由于表發(fā)生了變化贴膘,那么之前我們的代碼也要進(jìn)行相應(yīng)的調(diào)整卖子,調(diào)整也很簡(jiǎn)單,就是之前所有關(guān)于權(quán)限的操作都是操作resource表刑峡,我們改成操作user_resource表即可洋闽,左邊是老代碼,右邊是改進(jìn)后的代碼:

3-代碼差異.png

其中重點(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)限都得一一更改缝龄,極其繁瑣:

權(quán)限重復(fù).png

計(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)系不就搞定了:

權(quán)限封裝層.png

這樣有新的用戶時(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ù)以作演示:

role表和數(shù)據(jù).png

剛才說(shuō)的權(quán)限是和角色掛鉤的堵漱,那么之前的user_resource表就要改成role_resource综慎,然后用戶又和角色掛鉤,所以還得來(lái)一個(gè)user_role表:

兩張關(guān)系表和數(shù)據(jù).png

上面的數(shù)據(jù)表明勤庐,id為1的角色(超級(jí)管理員)擁有三個(gè)權(quán)限資源示惊,id為2的角色(數(shù)據(jù)管理員)只有一個(gè)權(quán)限資源。 然后用戶1擁有超級(jí)管理員角色愉镰,用戶2擁有數(shù)據(jù)管理員角色:

用戶-角色-權(quán)限示意圖.png

如果還有一個(gè)用戶想擁有超級(jí)管理員的所有權(quán)限米罚,只需要將該用戶和超級(jí)管理員角色綁定即可!這樣我們就完成了表的設(shè)計(jì)岛杀,現(xiàn)在我們數(shù)據(jù)庫(kù)表如下:

RBAC五張表.png

這就是非常著名且非常流行的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è)面:

5-賬戶管理頁(yè)面.png
5-角色管理頁(yè)面.png

老樣子 分頁(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è)面渲染欲诺。之前我們是將resourceuser_resource連表查詢出用戶的所有權(quán)限沉御,現(xiàn)在我們將user_rolerole_resource連表拿到權(quán)限id沧奴,左邊是我們以前代碼右邊是我們改后的代碼:

用戶id獲取權(quán)限代碼差異.png

關(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)限功能:

頁(yè)面操作角色.gif

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ì)顯示該按鈕太防,或者該按鈕被禁用:

刪除按鈕-3.png

前端實(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)限

資源表type.png

這里我們用0來(lái)表示頁(yè)面權(quán)限,用1來(lái)表示操作權(quán)限焊夸。

表擴(kuò)展完畢仁连,我們接下來(lái)就要添加操作權(quán)限類型的數(shù)據(jù)。剛才也說(shuō)了,于后端而言操作就是一個(gè)接口饭冬,那么我們就要將 接口路徑 作為我們的權(quán)限資源使鹅,大家一看就都明白了:

操作權(quán)限-資源數(shù)據(jù)1.png

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)的操作按鈕:

沒(méi)有按鈕基本演示.gif

按鈕是隱藏了,可如果用戶篡改本地權(quán)限數(shù)據(jù)科展,導(dǎo)致不該顯示的按鈕顯示了出來(lái)均牢,或者用戶知道了接口繞過(guò)頁(yè)面自行調(diào)用怎么辦?反正不管怎樣才睹,他最終都是要調(diào)用我們接口的徘跪,那我們就調(diào)用接口來(lái)試下效果:

自行調(diào)用接口.png

可以看到,繞過(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ì)前,我們先看一下最終的效果,看完效果后再去理解就事半功倍:

操作權(quán)限資源數(shù)據(jù)1.png

可以看到汇四,上面代碼中我在類和方法上都加上了我們自定義的Auth注解接奈,并在注解中設(shè)置了idname的值,這個(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)限id4,一大堆角色綁定在這個(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筑累、23來(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)限的訂單运嗜,就像這樣:

全部data數(shù)據(jù).png
部分data數(shù)據(jù).png

都是同樣的分頁(yè)列表頁(yè)面壶辜,不同的人查出來(lái)了不同的結(jié)果。

這個(gè)分頁(yè)查詢功能沒(méi)什么好說(shuō)的担租,數(shù)據(jù)庫(kù)表的設(shè)計(jì)也非常簡(jiǎn)單砸民,我們建一個(gè)數(shù)據(jù)表data和一個(gè)公司表companydata數(shù)據(jù)表中其他字段不是重點(diǎn)奋救,主要是要有一個(gè)company_id字段用來(lái)關(guān)聯(lián)company公司表岭参,這樣才能將數(shù)據(jù)分類,才能后續(xù)進(jìn)行權(quán)限的劃分:

公司-data數(shù)據(jù).png

我們權(quán)限劃分也很簡(jiǎn)單尝艘,就和之前一樣的演侯,建一個(gè)中間表即可。這里為了演示背亥,就直接將用戶和公司直接掛鉤了秒际,建一個(gè)user_company表來(lái)表示用戶擁有哪些公司數(shù)據(jù)權(quán)限:

用戶-公司權(quán)限數(shù)據(jù).png

上面數(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)寫好了的攔截器效果:

攔截日志.png

可以看到,紅框框起來(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):

  1. 權(quán)限的本質(zhì)就是保護(hù)資源
  2. 權(quán)限設(shè)計(jì)的核心就是 保護(hù)什么資源咒劲、如何保護(hù)資源
  3. 核心掌握后,根據(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)明原地址挣输、原作者!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末福贞,一起剝皮案震驚了整個(gè)濱河市撩嚼,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌挖帘,老刑警劉巖完丽,帶你破解...
    沈念sama閱讀 216,591評(píng)論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異拇舀,居然都是意外死亡逻族,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,448評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門骄崩,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)聘鳞,“玉大人,你說(shuō)我怎么就攤上這事要拂】倭В” “怎么了?”我有些...
    開(kāi)封第一講書人閱讀 162,823評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵脱惰,是天一觀的道長(zhǎng)搏嗡。 經(jīng)常有香客問(wèn)我,道長(zhǎng)拉一,這世上最難降的妖魔是什么采盒? 我笑而不...
    開(kāi)封第一講書人閱讀 58,204評(píng)論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮舅踪,結(jié)果婚禮上纽甘,老公的妹妹穿的比我還像新娘良蛮。我一直安慰自己抽碌,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,228評(píng)論 6 388
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著货徙,像睡著了一般左权。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上痴颊,一...
    開(kāi)封第一講書人閱讀 51,190評(píng)論 1 299
  • 那天赏迟,我揣著相機(jī)與錄音,去河邊找鬼蠢棱。 笑死锌杀,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的泻仙。 我是一名探鬼主播糕再,決...
    沈念sama閱讀 40,078評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼玉转!你這毒婦竟也來(lái)了突想?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書人閱讀 38,923評(píng)論 0 274
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤究抓,失蹤者是張志新(化名)和其女友劉穎猾担,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體刺下,經(jīng)...
    沈念sama閱讀 45,334評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡绑嘹,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,550評(píng)論 2 333
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了橘茉。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片圾叼。...
    茶點(diǎn)故事閱讀 39,727評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖捺癞,靈堂內(nèi)的尸體忽然破棺而出夷蚊,到底是詐尸還是另有隱情,我是刑警寧澤髓介,帶...
    沈念sama閱讀 35,428評(píng)論 5 343
  • 正文 年R本政府宣布惕鼓,位于F島的核電站,受9級(jí)特大地震影響唐础,放射性物質(zhì)發(fā)生泄漏箱歧。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,022評(píng)論 3 326
  • 文/蒙蒙 一一膨、第九天 我趴在偏房一處隱蔽的房頂上張望呀邢。 院中可真熱鬧,春花似錦豹绪、人聲如沸价淌。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 31,672評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)蝉衣。三九已至括尸,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間病毡,已是汗流浹背濒翻。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 32,826評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留啦膜,地道東北人有送。 一個(gè)月前我還...
    沈念sama閱讀 47,734評(píng)論 2 368
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像僧家,于是被迫代替她去往敵國(guó)和親娶眷。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,619評(píng)論 2 354