乐趣区

关于后端:项目实践一文带你搞定页面权限按钮权限以及数据权限

前言

权限这一概念能够说是随处可见:等级不够进入不了某个论坛版块、对他人发的文章我只能点赞评论但不能删除或批改、朋友圈一些我看得了一些看不了,一些能看七天内的动静一些能看到所有动静等等等等。

每个零碎的权限性能都不尽相同,各有其本身的业务特点,对权限治理的设计也都各有特色。不过不论是怎么的权限设计,大抵可归为三种:页面权限 (菜单级)、操作权限(按钮级)、数据权限,按维度划分的话就是: 粗颗粒权限、细颗粒权限

《2020 最新 Java 根底精讲视频教程和学习路线!》

我会从最简略、最根底的解说起,由浅入深、一步一步带大家实现各个性能。读完文章你能播种:

  • 权限受权的外围概念
  • 页面权限、操作权限、数据权限的设计与实现
  • 权限模型的演进与应用
  • 接口扫描与 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> 

在设计权限相干的表之前,必定是先得有一个最根底的用户表,字段很简略就三个,主键、用户名、明码:

对应的实体类和 SQL 建表语句我就不写了,大家一看表构造都晓得该咋写(github 上我放了残缺的 SQL 建表文件)。

接下来咱们就先实现一种非常简单的权限管制!

页面权限

页面权限非常容易了解,就是有这个权限的用户能力拜访这个页面,没这个权限的用户就无法访问,它是以整个页面为维度,对权限的管制并没有那么细,所以是一种 粗颗粒权限

最直观的一个例子就是,有权限的用户就会显示所有菜单,无权限的用户就只会显示局部菜单:

这些菜单都对应着一个页面,管制了导航菜单就相当于管制住了页面入口,所以页面权限通常也可称为 菜单权限

权限外围

就像之前所说,要设计一个权限零碎第一步就是要思考 爱护什么资源,页面权限这种要爱护的资源那必然是页面嘛。一个页面(菜单)对应一个 URI 地址,当用户登录的时候判断这个用户领有哪些页面权限,自然而然就晓得要渲染出什么导航菜单了!这些理分明后表的设计天然浮现眼前:

这个资源表非常简单但目前足够用了,假如咱们页面 / 菜单的 URI 映射如下:

咱们要设置用户的权限话,只有将用户 id 和 URI 对应起来即可:

下面的数据就表明,id1 的用户领有所有的权限,id2 的用户只领有数据管理权限(首页咱们就让所有用户都能进,毕竟一个用户你至多还是得让他能看到一些最根本的货色嘛)。至此,咱们就实现了页面权限的数据库表设计!

数据水灵灵放在那毫无作用,所以接下来咱们就要进行代码的编写来应用这些数据。代码实现分为后端和前端,在前后端没有拆散的时候,逻辑的解决和页面的渲染都是在后端进行,所以整体的逻辑链路是这样的:

用户登录后拜访页面,咱们来编写一下页面接口:

@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", "权限治理");
        c.put("/data", "数据管理");
        request.setAttribute("menuMap", menuMap);
        
        // 获取以后用户的所有页面权限,并将数据放到 request 对象中好让视图渲染
        Set<String> menus = resourceService.getCurrentUserMenus();
        request.setAttribute("menus", menus);
        return "index";
    }
} 

index.html:

<!-- 这个语法为 thymeleaf 语法,和 JSP 一样是一种后端模板引擎技术 -->
<ul>
    <!-- 首页让所有人都能看到,就间接渲染 -->
    <li> 首页 </li>
    
    <!-- 依据权限数据渲染对应的菜单 -->
    <li th:each="i : ${menus}">
        [[${menuMap.get(i)}]]
    </li>
    
</ul> 

这里只是大略演示一下是如何渲染的,就不写代码的全貌了,重点是思路,不必过多纠结代码的细节

前后端未分离的模式下,至此页面权限的基本功能曾经实现了。

那当初前后端拆散模式下,后端只负责提供 JSON 数据,页面渲染是前端的事,此时整体的逻辑链路就产生了变动:

那么用户登录胜利的同时,后端要将用户的权限数据返回给前端,这是咱们登录接口:

@RestController // 留神,这里是 @RestController,代表该类所有接口返回的都是 JSON 数据
public class LoginController {
    @Autowired
    private UserService userService;

    @PostMapping("/login")
    public Set<String> login(@RequestBody UserParam user) {
        // 这里简略点就只返回一个权限门路汇合
        return userService.login(user);
    }
} 

具体的业务办法:

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private ResourceMapper resourceMapper;
    @Autowired
    private UserMapper userMapper;

    @Override
    public Set<String> login(UserParam userParam) {
        // 依据前端传递过去的账号密码从数据库中查问用户数据
        // 该办法 SQL 语句:select * from user where user_name = #{userName} and password = #{password}
        User user = userMapper.selectByLogin(userParam.getUsername(), userParam.getPassword());
        if (user == null) {throw new ApiException("账号或明码谬误");
        }
        
        // 返回该用户的权限门路汇合
        // 该办法的 SQL 语句:select path from resource where user_id = #{userId}
        return resourceMapper.getPathsByUserId(user.getId());
    }
} 

后端的接口咱们就编写结束了,前端在登录胜利后会收到后端传递过去的 JSON 数据:

[
    "/user/account",
    "/user/role",
    "/data"
] 

这时候后端不须要像之前那样将菜单名映射也传递给前端,前端本人会存储一个映射字典。前端将这个权限存储在本地(比方LocalStorage),而后依据权限数据渲染菜单,前后端拆散模式下的权限性能就这样实现了。咱们来看一下成果:

到目前为止,页面权限的根本逻辑链路就介绍结束了,是不是非常简单?根本的逻辑弄清楚之后,剩下的不过就是十分一般的增删改查:当我想要让一个用户的权限变大时就对这个用户的权限数据进行减少,想让一个用户的权限变小时就对这个用户的权限数据进行删除……接下来咱们就实现这一步,让零碎的用户可能对权限进行治理,否则干什么都要间接操作数据库那必定是不行的。

首先,必定是得先让用户可能看到一个数据列表而后能力进行操作,我新增了一些数据来不便展现成果:

这里分页、新增账户、删除账户的代码怎么写我就不解说了,就讲一下对权限进行编辑的接口:

@RestController
public class LoginController {
    @Autowired
    private ResourceService resourceService;
    
    @PutMapping("/menus")
    private String updateMenus(@RequestBody UserMenusParam param) {resourceService.updateMenus(param);
        return "操作胜利";
    }
} 

承受前端传递过去的参数非常简单,就一个用户 id 和将要设置的菜单门路汇合:

// 省去 getter、setter
public class UserMenusParam {
    private Long id;
    private Set<String> menus;
} 

业务类的代码如下:

@Override
public void updateMenus(UserMenusParam param) {
    // 先依据用户 id 删除原有的该用户权限数据
    resourceMapper.removeByUserId(param.getId());
    // 如果权限汇合为空就代表删除所有权限,不必走前面新增流程了
    if (Collections.isEmpty(param.getMenus())) {return;}
    // 依据用户 id 新增权限数据
    resourceMapper.insertMenusByUserId(param.getId(), param.getMenus());
} 

删除权限数据和新增权限数据的 SQL 语句如下:

<mapper namespace="com.rudecrab.rbac.mapper.ResourceMapper">
    <!-- 依据用户 id 删除该用户所有权限 -->
    <delete id="deleteByUserId">
        delete from resource where user_id = #{userId}
    </delete>
    
    <!-- 依据用户 id 减少菜单权限 -->
    <insert id="insertMenusByUserId">
        insert into resource(user_id, path) values
        <foreach collection="menus" separator="," item="menu">
            (#{userId}, #{menu})
        </foreach>
    </insert>
</mapper> 

如此就实现了权限数据编辑的性能:

能够看到 root 用户之前是只能拜访 数据管理 ,对其进行权限编辑后,他就也能拜访 账户治理 了,当初咱们的页面权限治理性能才算实现。

是不是感觉非常简单,咱们仅仅用了两张表就实现了一个权限治理性能。

ACL 模型
两张表非常不便且容易了解,零碎小数据量小这样玩没啥,如果数据量大就有其弊病所在:

  1. 数据反复极大
  • 耗费存储资源。比方/user/account,我有多少用户有这权限我就得存储多少个这样的字符串。要晓得这还是最简略的资源信息呢,只有一个门路,有些资源的信息可有很多哟:资源名称、类型、等级、介绍等等等等
  • 更改资源老本过大。比方 /data 我要改成/info,那现有的那些权限数据都要跟着改
  1. 设计不合理
  • 无奈直观形容资源。方才咱们只弄了三个资源,如果我零碎中想增加第四、五 … 种资源是没有方法的,因为当初的资源都是依赖于用户而存在,基本不能独立存储起来
  • 表的释义不清。当初咱们的 resource 表与其说是在形容资源,倒不如说是在形容用户和资源的关系。

为了解决上述问题,咱们该当对以后表设计进行改进,要将 资源 用户和资源的关系 拎清。用户和资源的关系是多对多的,一个用户能够有多个权限,一个权限下能够有多个用户,咱们个别都用两头表来形容这种多对多关系。而后资源表就不用来形容关系了,只用来形容资源。这样咱们新的表设计就进去了:建设两头表,改良资源表!

咱们先来对资源表进行革新,iduser_idpath这是之前的三个字段,user_id并不是用来形容资源的,所以咱们将它删除。而后咱们再额定加一个 name 字段用来形容资源名称(非必须),革新后此时资源表如下:

表里的内容就专门用来放资源:

资源表搞定了咱们建设一个两头表用来形容用户和权限的关系,两头表很简略就只存用户 id 和资源 id:

之前的权限关系在两头表里就是这样存储的了:

当初的数据表明,id 为 1 的用户领有 id 为 1、2、3 的权限,即用户 1 领有 账户治理、角色治理、数据管理 权限。id 为 2 的用户只领有 id 为 3 的资源权限,即用户 2 领有 数据管理 权限!

整个表设计就如此降级结束了,当初咱们的表如下:

因为表产生了变动,那么之前咱们的代码也要进行相应的调整,调整也很简略,就是之前所有对于权限的操作都是操作 resource 表,咱们改成操作 user_resource 表即可,右边是老代码,左边是改良后的代码:

其中重点就是之前咱们都是操作资源表的 path 字符串,前后端之间传递权限信息也是传递的 path 字符串,当初都改为操作资源表的id(Java 代码中记得也改过来,这里我就只演示 SQL)。

这里要独自解释一下,前后端只传递资源 id 的话,前端是咋依据这个 id 渲染页面呢?又是怎么依据这个 id 显示资源名称的呢?这是因为前端本地有存储一个映射字典,字典里有资源的信息,比方 id 对应哪个门路、名称等等,前端拿到了用户的 id 后依据字典进行判断就能够做到相应的性能了。
这个映射字典在理论开发中有两种管理模式,一种是前后端采取约定的模式,前端本人就在代码里造好了字典,如果后续资源有什么变动,前后端人员沟通一下就好了,这种形式只适宜权限资源特地简略的状况。还一种就是后端提供一个接口,接口返回所有的资源数据,每当用户登录或进入零碎首页的时候前端调用接口同步一下资源字典就好了!咱们当初就用这种形式,所以还得写一个接口进去才行:

/**
* 返回所有资源数据
*/
@GetMapping("/resource/list")
public List<Resource> getList() {
    // SQL 语句非常简单:select * from resource
    return resourceService.list();} 

当初,咱们的权限设计才像点样子。这种用户和权限资源绑定关系的模式就是ACL 模型,即 Access Control List 访问控制列表,其特点是不便、易于了解,适宜权限性能简略的零碎。

咱们乘热打铁,持续将整个设计再降级一下!

RBAC 模型

我这里为了不便演示所以没有设置过多的权限资源(就是导航菜单),所以整个权限零碎用起来如同也挺不便的,不过一旦权限资源多了起来目前的设计有点顾此失彼了。假如咱们有 100 个权限资源,A 用户要设置 50 个权限,BCD 三个用户也要设置这同样的 50 个权限,那么我必须为每个用户都反复操作 50 下才行!这种需要还特地特地常见,比方销售部门的员工都领有同样的权限,每新来一个员工我就得给其一步一步反复地去设置权限,并且我要是更改这个销售部门的权限,那么旗下所有员工的权限都得一一更改,极其繁琐:

计算机科学畛域的任何问题都能够通过减少一个间接的中间层来解决

当初咱们的权限关系是和用户绑定的,所以每有一个新用户咱们就得为其设置一套专属的权限。既然很多用户的权限都是雷同的,那么我再封装一层进去,屏蔽用户和权限之间的关系不就搞定了:

这样有新的用户时只须要将其和这个封装层绑定关系,即可领有一整套权限,未来就算权限更改也很不便。这个封装层咱们将它称为 角色!角色非常容易了解,销售人员是一种角色、后勤是一种角色,角色和权限绑定,用户和角色绑定,就像上图显示的一样。

既然加了一层角色,咱们的表设计也要跟着扭转。毋庸置疑,必定得有一个角色表来专门形容角色信息,简略点就两个字段 主键 id角色名称,这里增加两个角色数据以作演示:

方才说的权限是和角色挂钩的,那么之前的 user_resource 表就要改成 role_resource,而后用户又和角色挂钩,所以还得来一个user_role 表:

下面的数据表明,id 为 1 的角色(超级管理员)领有三个权限资源,id 为 2 的角色(数据管理员)只有一个权限资源。而后用户 1 领有超级管理员角色,用户 2 领有数据管理员角色:

如果还有一个用户想领有超级管理员的所有权限,只须要将该用户和超级管理员角色绑定即可!这样咱们就实现了表的设计,当初咱们数据库表如下:

这就是十分驰名且十分风行的 RBAC 模型,即 Role-Based Access Controller 基于角色访问控制模型!它能满足绝大多数的权限要求,是业界最罕用的权限模型之一。光说不练假把式,当初表也设计好了,咱们接下来改良咱们的代码并且和前端联调起来,实现一个基于角色的权限管理系统!

当初咱们零碎中有三个实体:用户、角色、资源(权限)。之前咱们是有一个用户页面,在那一个页面上就能够进行权限治理,当初咱们多了角色这个概念,就还得增加一个角色页面:

老样子 分页、新增、删除的代码我就不解说了,重点还是讲一下对于权限操作的代码。

之前咱们的用户页面是间接操作权限的,当初咱们要改成操作角色,所以 SQL 语句要按如下编写:

<mapper namespace="com.rudecrab.rbac.mapper.RoleMapper">
    <!-- 依据用户 id 批量新增角色 -->
    <insert id="insertRolesByUserId">
        insert into user_role(user_id, role_id) values
        <foreach collection="roleIds" separator="," item="roleId">
            (#{userId}, #{roleId})
        </foreach>
    </insert>

    <!-- 依据用户 id 删除该用户所有角色 -->
    <delete id="deleteByUserId">
        delete from user_role where user_id = #{userId}
    </delete>

    <!-- 依据用户 id 查问角色 id 汇合 -->
    <select id="selectIdsByUserId" resultType="java.lang.Long">
        select role_id from user_role where user_id = #{userId}
    </select>
</mapper> 

除了用户对角色的操作,咱们还得有一个接口是拿用户 id 间接获取该用户的所有权限,这样前端才好依据以后用户的权限进行页面渲染。之前咱们是将 resourceuser_resource连表查问出用户的所有权限,当初咱们将 user_rolerole_resource连表拿到权限 id,右边是咱们以前代码左边是咱们改后的代码:

对于用户这一块的操作到此就实现了,咱们接着来解决角色相干的操作。角色这里的思路和之前是一样的,之前用户是怎么间接操作权限的,这里角色就怎么操作权限:

<mapper namespace="com.rudecrab.rbac.mapper.ResourceMapper">
    <!-- 依据角色 id 批量减少权限 -->
    <insert id="insertResourcesByRoleId">
        insert into role_resource(role_id, resource_id) values
        <foreach collection="resourceIds" separator="," item="resourceId">
            (#{roleId}, #{resourceId})
        </foreach>
    </insert>

    <!-- 依据角色 id 删除该角色下所有权限 -->
    <delete id="deleteByRoleId">
        delete from role_resource where role_id = #{roleId}
    </delete>

    <!-- 依据角色 id 获取权限 id-->
    <select id="selectIdsByRoleId" resultType="java.lang.Long">
        select resource_id from role_resource where role_id = #{roleId}
    </select>
</mapper> 

留神哦,这里前后端传递的也都是 id,既然是id 那么前端就得有映射字典才好渲染,所以咱们这两个接口是必不可少的:

/**
* 返回所有资源数据
*/
@GetMapping("/resource/list")
public List<Resource> getList() {
    // SQL 语句非常简单:select * from resource
    return resourceService.list();}

/**
* 返回所有角色数据
*/
@GetMapping("/role/list")
public List<Role> getList() {
    // SQL 语句非常简单:select * from role
    return roleService.list();} 

字典有了,操作角色的办法有了,操作权限的办法也有了,至此咱们就实现了基于 RBAC 模型的页面权限性能:

root用户领有 数据管理员 的权限,一开始 数据管理员 只能看到 数据管理 页面,前面咱们为 数据管理 员又增加了 账户治理 的页面权限,root用户不做任何更改就能够看到 账户治理 页面了!

无论几张表,权限的外围还是我之前展现的那流程图,思路把握了怎么的模型都是 OK 的

不晓得大家发现没有,在前后端拆散的模式下,后端在登录的时候将权限数据甩给前端后就再也不论了,如果此时用户的权限发生变化是无奈告诉前端的,并且数据存储在前端也容易被用户间接篡改,所以很不平安。前后端拆散不像未分离一样,页面申请都得走后端,后端能够很轻松的就对每个页面申请其进行平安判断:

@Controller
public class ViewController {
    @Autowired
    private ResourceService resourceService;
    
       // 这些逻辑都能够放在过滤器对立做,这里只是为了不便演示
    @GetMapping("/user/account")
    public String userAccount() {
        // 先从缓存或数据库中取出以后登录用户的权限数据
        List<String> menus = resourceService.getCurrentUserMenus();
        
        // 判断有没有权限
        if (list.contains("/user/account")) {
             // 有权限就返回失常页面
            return "user-account";
        }
        // 没有权限就返回 404 页面
        return "404";
    }
    
} 

首先权限数据存储在后端,被用户间接篡改的可能就被屏蔽了。并且每当用户拜访页面的时候后端都要实时查问数据,当用户权限数据产生变更时也能即时同步。

这么一说难道前后端拆散模式下就得认栽了?当然不是,其实有一个骚操作就是前端发动每一次后端申请时,后端都将最新的权限数据返回给前端,这样就能防止上述问题了。不过这个办法会给网络传输带来极大的压力,既不优雅也不明智,所以个别都不这么干。折中的方法就是当用户进入某个页面时从新获取一次权限数据,比方首页。不过这也不太平安,毕竟只有用户不进入首页那还是没用。

那么又优雅又理智又平安的形式是什么呢,就是咱们接下来要讲的操作权限了!

操作权限

操作权限就是将 操作 视为资源,比方删除操作,有些人能够有些人不行。于后端来说,操作就是一个接口。于前端来说,操作往往是一个按钮,所以操作权限也被称为 按钮权限 ,是一种 细颗粒权限

在页面上比拟直观的体现就是没有这个删除权限的人就不会显示该按钮,或者该按钮被禁用:

前端实现按钮权限还是和之前导航菜单渲染一样的,拿以后用户的权限资源 id 和权限资源字典比照,有权限就渲染进去,无权限就不渲染

前端对于权限的逻辑和之前一样,那操作权限怎么就比页面权限平安了呢?这个平安次要体现在后端上,页面渲染不走后端,但接口可必须得走后端,那只有走后端那就好办了,咱们只须要对每个接口进行一个权限判断就 OK 了嘛!

根本实现

咱们之前都是针对页面权限进行的设计,当初扩大操作权限的话咱们要对现有的 resource 资源表进行一个小小的扩大,加一个 type 字段来辨别页面权限和操作权限

这里咱们用 0 来示意页面权限,用 1 来示意操作权限。

表扩大结束,咱们接下来就要增加操作权限类型的数据。方才也说了,于后端而言操作就是一个接口,那么咱们就要 将 接口门路 作为咱们的权限资源,大家一看就都明确了:

DELETE:/API/user分为两个局部组成,DELETE:示意该接口的申请形式,比方 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 "操作胜利";
    }
    
    ... 省略其余接口申明
} 

和前端联调后,前端就依据权限暗藏了相应的操作按钮:

按钮是暗藏了,可如果用户篡改本地权限数据,导致不该显示的按钮显示了进去,或者用户晓得了接口绕过页面自行调用怎么办?反正不管怎样,他最终都是要调用咱们接口的,那咱们就调用接口来试下成果:

能够看到,绕过前端的平安判断也是没有用的!

而后还有一个咱们之前说的问题,如果以后用户权限被人批改了,如何实时和前端同步呢?比方,一开始 A 用户的角色是有 删除权限 的,而后被一个管理员将他的该权限给去除了,可此时 A 用户不从新登录的话还是能看到删除按钮。

其实有了操作权限后,用户就算能看到不属于本人的按钮也不侵害安全性,他点击后还是会提醒无权限,只是说用户体验略微差点罢了!页面也是一样,页面只是一个容器,用来承载数据的,而数据是要通过接口来调用的 ,比方图中演示的分页数据,咱们就能够将分页查问接口也做一个权限治理嘛,这样用户就算绕过了页面权限,来到了 账户治理 板块,照样看不到丝毫数据!

至此,咱们就实现了按钮级的操作权限,是不是很简略?再次啰嗦:只有把握了外围思路,实现起来真的很简略,不要想简单了。

晓得我格调的读者就晓得,我接下来又要降级了!没错,当初咱们这种实现形式太简陋、太麻烦了。咱们当初都是手动增加的资源数据,写一个接口我就要手动加一个数据,要晓得一个零碎中成千盈百个接口太失常了,那我手动增加不得腾飞咯?那有什么方法,我写接口的同时就主动将资源数据给生成呢,那就是我接下来要讲的接口扫描!

接口扫描

SpringMVC提供了一个十分不便的类 RequestMappingInfoHandlerMapping,这个类能够拿到所有你申明的 web 接口信息,这个拿到后剩下的事不就非常简单了,就是通过代码将接口信息批量增加到数据库呗!不过咱们也不是要真的将 所有 接口都增加到权限资源中去,咱们要的是那些须要权限解决的接口生成权限资源,有些接口不须要权限解决那天然就不生成了。所以咱们得想一个方法来标记一下该接口是否须要被权限治理!

咱们的接口都是通过办法来申明的,标记办法最不便的形式天然就是注解嘛!那咱们先来自定义一个注解:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE}) // 表明该注解能够加在类或办法上
public @interface Auth {
    /**
     * 权限 id,须要惟一
     */
    long id();
    /**
     * 权限名称
     */
    String name();} 

这个注解为啥这样设计我等下再说,当初只须要知道,只有接口办法加上了这个注解,咱们就被视其为是须要权限治理的:

@RestController
@RequestMapping("/API/user")
@Auth(id = 1000, name = "用户治理")
public class UserController {
     ... 省略主动注入的 service 代码

    @PostMapping
    @Auth(id = 1, name = "新增用户")
    public String createUser(@RequestBody UserParam param) {
           ... 省略业务代码
        return "操作胜利";
    }

    @DeleteMapping
    @Auth(id = 2, name = "删除用户")
    public String deleteUser(Long[] ids) {
        ... 省略业务代码
        return "操作胜利";
    }

    @PutMapping
    @Auth(id = 3, name = "编辑用户")
    public String updateRoles(@RequestBody UserParam param) {
        ... 省略业务代码
        return "操作胜利";
    }
    
    @GetMapping("/test/{id}")
    @Auth(id = 4,name = "用于演示门路参数")
    public String testInterface(@PathVariable("id") String id) {
        ... 省略业务代码
        return "操作胜利";
    }

    ... 省略其余接口申明
} 

在讲接口扫描和介绍注解设计前,咱们先看一下最终的成果,看完成果后再去了解就事倍功半:

能够看到,下面代码中我在类和办法上都加上了咱们自定义的 Auth 注解,并在注解中设置了 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 和一个公司表 companydata 数据表中其余字段不是重点,次要是要有一个 company_id 字段用来关联 company 公司表,这样能力将数据分类,能力后续进行权限的划分:

咱们权限划分也很简略,就和之前一样的,建一个两头表即可。这里为了演示,就间接将用户和公司间接挂钩了,建一个 user_company 表来示意用户领有哪些公司数据权限:

下面数据表明 id 为 1 的用户领有 id 为 1、2、3、4、5 的公司数据权限,id 为 2 的用户领有 id 为 4、5 的公司数据权限。

我置信大家通过了性能权限的学习后,这点表设计曾经信手拈来了。表设计和数据筹备好后,接下来就是咱们要害的权限性能实现。

首先,咱们得梳理一下一般的分页查问是怎么的。咱们要对 data 进行分页查问,SQL语句会依照如下编写:

-- 依照创立工夫降序排序
SELECT * FROM `data` ORDER BY create_time DESC LIMIT ?,? 

这个没什么好说的,失常查问数据而后进行 limit 限度以达到分页的成果。那么咱们要加上数据过滤性能,只须要在 SQL 上进行过滤不就搞定了

-- 只查问指定公司的数据
SELECT * FROM `data` where company_id in (?, ?, ?...) ORDER BY create_time DESC LIMIT ?,? 

咱们只须要先将用户所属的公司 id 全副查出来,而后放到分页语句中的 in 中即可达到成果。

咱们不必 in 条件判断,应用连表也是能够达到成果的:

-- 连贯 用户 - 公司 关系表,查问指定用户关联的公司数据
SELECT
    *
FROM
    `data`
    INNER JOIN user_company uc ON data.company_id = uc.company_id AND uc.user_id = ? 
ORDER BY
    create_time DESC 
LIMIT ?,? 

当然,不必连表用子查问也能够实现,这里就不过多开展了。总之,可能达到过滤成果的 SQL 语句有很多,依据业务特点优化就好。

到这里我其实就曾经介绍完一种非常简单粗犷的数据权限实现形式了:硬编码!即,间接批改咱们原有的 SQL 语句,自然而然就达到成果了嘛~

不过这种形式对原有代码入侵太大了,每个要权限过滤的接口我都得批改,重大影响了开闭准则。有啥方法能够不对原有接口进行批改吗?当然是有的,这就是我接下来要介绍的 Mybatis 拦挡插件。

Mybatis 拦挡插件

Mybatis提供了一个 Interceptor 接口,通过实现该接口能够定义咱们本人的拦截器,这个拦截器能够对 SQL 语句进行拦挡,而后扩大 / 批改。许多分页、分库分表、加密解密等插件都是通过该接口实现的!

咱们只须要拦挡到原有的 SQL 语句后,增加上咱们额定的语句,不就和方才硬编码一样实现了成果?这里我先给大家看一下我曾经写好了的拦截器成果:

能够看到,红框框起来的局部就是在原 SQL 上增加的语句!这个拦挡并不仅限于分页查问,只有咱们写好语句扩大规定,其余语句都是能够拦挡扩大的!

接下来我就贴上拦截器的代码,留神 这个代码大家不必过多地去纠结,大略瞟一眼晓得有这么个玩意就行了,因为当初咱们的重点是整体思路,先跟着我的思路来,代码有的是工夫再看:

@Component
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class DataInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 拿到 mybatis 的一些对象, 等下要操作
        StatementHandler statementHandler = PluginUtils.realTarget(invocation.getTarget());
        MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
        MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");

        // id 为执行的 mapper 办法的全路径名,如 com.rudecrab.mapper.UserMapper.insertUser
        String id = mappedStatement.getId();
        log.info("mapper: ==> {}", id);
        // 如果不是指定的办法,间接完结拦挡
        // 如果办法多能够存到一个汇合里,而后判断以后拦挡的是否存在汇合中,这里为了演示只拦挡一个 mapper 办法
        if (!"com.rudecrab.rbac.mapper.DataMapper.selectPage".equals(id)) {return invocation.proceed();
        }

        // 获取到原始 sql 语句
        String sql = statementHandler.getBoundSql().getSql();
        log.info("原始 SQL 语句:==> {}", sql);
        // 解析并返回新的 SQL 语句
        sql = getSql(sql);
        // 批改 sql
        metaObject.setValue("delegate.boundSql.sql", sql);
        log.info("拦挡后 SQL 语句:==>{}", sql);

        return invocation.proceed();}

    /**
     * 解析 SQL 语句,并返回新的 SQL 语句
     * 留神,该办法应用了 JSqlParser 来操作 SQL,该依赖包 Mybatis-plus 曾经集成了。如果要独自应用,请先自行导入依赖
     *
     * @param sql 原 SQL
     * @return 新 SQL
     */
    private String getSql(String sql) {
        try {
            // 解析语句
            Statement stmt = CCJSqlParserUtil.parse(sql);
            Select selectStatement = (Select) stmt;
            PlainSelect ps = (PlainSelect) selectStatement.getSelectBody();
            // 拿到表信息
            FromItem fromItem = ps.getFromItem();
            Table table = (Table) fromItem;
            String mainTable = table.getAlias() == null ? table.getName() : table.getAlias().getName();
            List<Join> joins = ps.getJoins();
            if (joins == null) {joins = new ArrayList<>(1);
            }

            // 创立连表 join 条件
            Join join = new Join();
            join.setInner(true);
            join.setRightItem(new Table("user_company uc"));
            // 第一个:两表通过 company_id 连贯
            EqualsTo joinExpression = new EqualsTo();
            joinExpression.setLeftExpression(new Column(mainTable + ".company_id"));
            joinExpression.setRightExpression(new Column("uc.company_id"));
            // 第二个条件:和以后登录用户 id 匹配
            EqualsTo userIdExpression = new EqualsTo();
            userIdExpression.setLeftExpression(new Column("uc.user_id"));
            userIdExpression.setRightExpression(new LongValue(UserContext.getCurrentUserId()));
            // 将两个条件拼接起来
            join.setOnExpression(new AndExpression(joinExpression, userIdExpression));
            joins.add(join);
            ps.setJoins(joins);

            // 批改原语句
            sql = ps.toString();} catch (JSQLParserException e) {e.printStackTrace();
        }
        return sql;
    }
} 

SQL拦截器写好后就会十分不便了,之前写好的代码不必批改,间接用拦截器进行对立解决即可!如此,咱们就实现了一个简略的数据权限性能!是不是感觉太简略了点,这么一会就将数据权限介绍完啦?

说简略也的确简略,其外围一句话就能够表明:SQL 进行拦挡而后达到数据过滤的成果。然而!我这里只是演示了一个特地简略的案例,思考的层面特地少,如果需要一旦简单起来那须要思考的货色我这篇文章再加几倍内容只怕也难以说完。

数据权限和业务关联性极强,有很多本人行业特点的权限划分维度,比方交易金额、交易工夫、地区、年龄、用户标签等等等等,咱们这只演示了一个部门维度的划分而已。有些数据权限甚至要做到多个维度穿插,还要做到到能对某个字段进行数据过滤(比方 A 管理员能看到手机号、交易金额,B 管理员看不到),其难度和复杂度远超性能权限。

所以对于数据权限,肯定是需要在先,技术手段再跟上。至于你是要用 Mybatis 还是其余什么框架,你是要用子查问还是用连表,都没有定式而言,肯定得依据具体的业务需要来制订针对性的数据过滤计划!

总结

到这里,对于权限的解说就靠近序幕了。其实本文说了那么多也就只是在论述以下几点:

  1. 权限的实质就是爱护资源
  2. 权限设计的外围就是 爱护什么资源、如何爱护资源
  3. 外围把握后,依据具体的业务需要来制定方案即可,万变不离其宗
退出移动版