权限模块的设计梳理

最近在做一个微服务商城的后台管理服务,在实现权限模块时重新梳理了一遍权限体系的设计逻辑。 之前虽然学习过完整的权限设计,但由于时间久远,一些细节已经模糊。趁这次重构机会,我决定系统地总结一下权限的整体逻辑流程。 毕竟,知识常读常新。这次整理,也让我对权限系统的设计有了更深的理解。

我会先从数据库端的设计出手,再到具体后端的程序具体实现。

1 数据库端

一个后台管理系统中的数据库中,常见的关于管理用户权限信息的表有5个,分别是

  1. t_user

  2. t_role

  3. t_permission

  4. t_user_role

  5. t_role_permission

在权限系统的设计中,核心思想是“用户 - 角色 - 权限”的多对多关联。

1.1 t_user 表

t_user 表就是用户表,是这个管理系统中的后台存放用户信息的表。常见字段有:id(bigint)、username(varchar)、password(varchar)、name(varchar)、status(int)、create_time(datetime)、create_by(bigint)、edit_time(datetime)、edit_by(bigint)、last_login_time(datetime)......等

注意

  1. 根据阿里 Java 开发规范,所有的表都需要有三个字段:id(数据类型为 bigint),gmt_create(数据类型为datetime,名称也可作 create_time),gmt_modified(数据类型为 datetime,名称也可作 edit_time)。对于我们的后台管理系统来说,再加上 create_by、edit_by 这两个创建人 id、修改人 id 字段是最好的。这三个字段分别代表的是:自增的唯一 id 值、创建该条记录的时间、最近修改该条记录的时间。另外的,这三个字段在是交给数据库自动维护Java 端只负责业务逻辑,不管时间戳。

  1. status 字段代表该 user 的状态,只有两种状态,一般 0 表示禁用 , 1 表示正常。这个字段还可以详细分成四个字段。分别是:account_no_expired、credentials_no_expired、account_no_locked、account_enabled,意思分别是账户是否没有过期、密码是否没有过期、账号是否没有锁定、账号是否启用,都是只有两种状态且通常 0 表示异常,1 表示正常,拆分成这四个字段是为了更好匹配 SpringSecurity 框架的权限管理设计。

1.2 t_role 表

t_role 表就是角色表,用于存放整个系统中所有角色信息的表。常见字段有:id(bigint)、role(varchar)、role_name(varchar)、create_time(datetime)、edit_time(datetime)......等

1.3 t_permission 表

t_permission 表就是权限表,用于存放整个系统中所有操作的表。常见字段有:id(bigint)、name(varchar)、code(varchar)、url(varchar)、type(varchar)、parent_id(bigint)、icon(varchar)、sort(int)。

注意

  1. code 字段,规范点说叫做权限标识符,就是这个权限的代码,英文,可为空,比如:useruser:listuser:adduser:deleteuser:import等等,code 字段可为空。

  1. url 字段,表示该权限访问对应的 url,url 字段可为空。

  1. type 字段,表示这个权限是什么类型的资源,比如:menu(菜单类型资源),button(按钮类型资源)

1.4 t_user_role 表

t_user_role 表,是 t_user 表和 t_role 表这两个表之间的关联表。决定着用户拥有着什么角色,一般来说一个用户只能有一个角色,而一个角色可以有多个用户。常见字段有:id(bigint)、user_id(bigint)、role_id(bigint)、create_time(datetime)、edit_time(datetime)。

1.5 t_role_permission 表

t_role_permission 表,是 t_role 表和 t_permission 表这两个表之间的关联表。决定着一个角色拥有什么具体的权限,一般来说一个角色有多个权限,一个权限也可以被多个角色拥有。常见的字段有:id(bigint)、role_id(bigint)、permission_id(bigint)、create_time(datetime)、edit_time(datetime)。

2. 后端

接下来是后端逻辑部分。我们以 Spring Security 框架为核心,通过登录时加载用户的角色与权限信息,并将这些信息注入到 SecurityContext 中,以实现访问控制。

java 端的整体逻辑是这样的:

  1. 用户登录之后,马上给当前用户增加角色(Role)信息

  2. 给角色配置能够访问的权限(Permission)资源

  3. 权限使用

2.1 预定角色、权限信息

修改对应用户实体类,

示例代码:

@Data
public class UserDo implements Serializable, UserDetails {
private Long id;
   // 省略其他本来有的字段的代码
   
   // 类中加入如下三个属性
   // 角色的角色名的 List (一般来说一个用户就一个角色,但还是写成 List 吧,万一系统未来扩展呢!)
   private List<String> roleList;
   // 权限之中的按钮权限的权限 Code 的 List
   private List<String> buttonPermissionList;
   // 权限之中的菜单权限的 List
   private List<PermissionDo> menuPermissionList;
}

另外,还要分别告诉 SpringSecurity ,先预定给他具体的 Role 、Permission 信息(这两个信息,就是我们刚加入的角色 List 和权限标识符 List)。

@JsonIgnore
@Override
public Collection extends GrantedAuthority> getAuthorities() {
   List<GrantedAuthority> list = new ArrayList<GrantedAuthority>();

   // 角色
   if (!ObjectUtils.isEmpty(this.getRoleList())){
       this.getRoleList().forEach(role -> {
           list.add(new SimpleGrantedAuthority(role));
      });
  }

   // 权限标识符
   if (!ObjectUtils.isEmpty(this.getButtonPermissionList())){
       this.getButtonPermissionList().forEach(permission -> {
           list.add(new SimpleGrantedAuthority(permission));
      });
  }
   return list;
}

2.2 增加角色、权限信息

我们已经预定了,要给 SpringSecurity 的 Role 、Permission 信息是从我们的 UserDo 的属性中获取的,但是目前我们这两个属性的值一直是空的,没赋上,现在开始赋值,我们是从登录成功之后就赋值,所以要找到我们的登陆成功代码:

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
   // 查询用户
   UserDo userDo = userMapper.selectByLoginAct(username);
   // 判断用户是否存在
   if (userDo == null) {
       throw new UsernameNotFoundException("登录账号不存在!");
  }
   // 代码运行到这里,说明登录成功
   // 更新本次登录时间
   userDo.setLastLoginTime(new Date());
   userMapper.updateByPrimaryKey(userDo);
   // 返回 userDo 实体
   return userDo;
}

现在,要在这个代码里面,添加查询到的角色、权限信息,代码中带 * 号的就是具体的添加,代码如下:

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
   UserDo userDo = userMapper.selectByLoginAct(username);
   if (userDo == null) {
       throw new UsernameNotFoundException("登录账号不存在!");
  }
   userDo.setLastLoginTime(new Date());
   userMapper.updateByPrimaryKey(userDo);
   
   // 新添加的代码开始----------------------------------------------------------------------
   // 查询一下当前用户的角色信息
   List<RoleDo> RoleList = roleMapper.selectRoleListByUserId(userDo.getId());
   // 字符串的角色(即角色名)列表
   List<String> stringRoleList = new ArrayList<>();
   RoleList.forEach(roleDo -> {
       stringRoleList.add(roleDo.getRole());
  });
   // 设置用户的角色名 List(*)
   userDo.setRoleList(stringRoleList);
       
   // 查询一下该用户有哪些菜单权限
   List<PermissionDo> menuPermissionList = permissionMapper.selectMenuPermissionByUserId(userDo.getId());
   // 设置菜单权限(*)
   userDo.setMenuPermissionList(menuPermissionList);
   
   // 查询一下该用户有哪些功能权限
   List<PermissionDo> buttonPermissionList = permissionMapper.selectButtonPermissionByUserId(userDo.getId());
   List<String> stringPermissionList = new ArrayList<>();
   buttonPermissionList.forEach(permission -> {
       // 权限标识符
       stringPermissionList.add(permission.getCode());
  });
   // 设置用户的权限标识符(*)
   userDo.setButtonPermissionList(stringPermissionList);
   // 新添加的代码结束----------------------------------------------------------------------
   
   return userDo;
}

注意点:这里的调用的 permissionMapper 的查询方法里,里面查询到的 PermissionDo 列表,都需要查询的一级 Permisson 列表,而对应的二级 Permission 列表,要通过 PermissionDo 类中加入新的属性值:private List subPermissionList; 来实现一级的 Permisson 列表包含二级 Permission 列表。具体的代码这里就不说了,涉及到单表的两次连接的一对多关联映射,在 MyBatis 中使用 标签实现,就不扯开去了。

2.3 权限使用

  1. 开启权限的使用

    即在 SecurityConfig.java 中添加 @EnableMethodSecurity 注解。添加注解就行了。

    // 开启注解方式的权限验证
    @EnableMethodSecurity
    @Configuration
    public class SecurityConfig {
    // ....略
    }
  1. 在 controller 中加入权限名称

    只加入 @PreAuthorize 注解就好了,注解的值设置为一个数据库中设定的值,如:

    @RestController
    public class ClueController {
       @Resource
       ClueService clueService;

       @PreAuthorize("hasAuthority('clue:list')")
       @GetMapping("/api/clues")
       public R getClues(@RequestParam(value = "current", required = false) Integer current) {
           // ...略
      }

       @PreAuthorize("hasAuthority('clue:view')")
       @GetMapping("/api/clue/{id}")
       public R getUserDetail(@PathVariable("id") Integer id) {
           // ...略
      }

       @PreAuthorize("hasAuthority('clue:delete')")
       @DeleteMapping("/api/clue/{id}")
       public R delClue(@PathVariable("id") Integer id) {
           // ...略
      }

       @PreAuthorize("hasAuthority('clue:delete')")
       @DeleteMapping("/api/clues")
       public R batchDelClues(@RequestParam(value = "ids", required = false) String ids) {
           // ...略
      }

       @PreAuthorize("hasAuthority('clue:import')")
       @PostMapping("/api/import-excel")
       public R importExcel(MultipartFile file, Authentication authentication) throws IOException {
           // ...略
      }

       @PreAuthorize("hasAuthority('clue:add')")
       @PostMapping("/api/clue")
       public R addClue(ClueQuery clueQuery, Authentication authentication) {
           // ...略
      }


       @PreAuthorize("hasAuthority('clue:view')")
       @GetMapping("/api/clue/full/{id}")
       public R getClueFullDetail(@PathVariable("id")Integer id){
           // ...略
      }
       
       @PreAuthorize("hasAuthority('clue:edit')")
       @PutMapping("/api/clue")
       public R editClue(ClueQuery clueQuery,Authentication authentication) {
           // ...略
      }
    }
  2. 加入没有权限时的处理器

    在 SecurityConfig.java 的 securityFilterChain 方法的 build 之前加入没有权限时的处理器,如:

    .exceptionHandling((t)->{
       t.accessDeniedHandler(myAccessDeniedHandler);
    })

    具体的权限处理器自己写,写法大致是:在没有权限时的处理器,没有权限访问,执行处理器里的方法,在方法中返回没有权限的 json 给前端,就行了。

这样,SpringSecurity 就会自动根据一开始登陆时传给它的权限信息来规定后来的功能使用与否。此时,还需配合前端,前端直接在对应没有权限的按钮上隐去,

2.4 权限使用(前端)

前端权限的使用,我用 Vue 来示例。

记住,前端的权限使用是防君子的,后端的活是防小人的。即是说,前端权限的逻辑大概是:后端返回给前端用户的菜单权限信息,不展示没有的菜单权限,仅展示有的菜单权限。若不设后端权限而只设前端权限,用户仍可以通过修改地址 url 来访问未有权限的菜单。因此,前端权限控制仅用于界面展示优化,真正的安全防线仍然依赖后端权限校验。

  1. 菜单权限的筛选

    需求:不同用户所展示的菜单,是不一样的,菜单选项不能写死,根据不同的用户角色,设计不同的菜单。

    原理还是很简单,就是筛选后端传过来的此用户的菜单权限 List。

    <el-menu
       :default-active="currentRouterPath"
       class="el-menu-vertical-demo"
       :unique-opened="true"
       :collapse="isCollapse"
       :collapse-transition="false"
       :router="true">
     <el-sub-menu :index="menuPermission.id" v-for="menuPermission in user.menuPermissionList"
                  :key="menuPermission.id">
       <template #title>
         
         <el-icon><component :is="menuPermission.icon"/>el-icon>

         <span>{{ menuPermission.name }}span>
       template>
       <el-menu-item :index="subMenuPermission.url"
                     v-for="subMenuPermission in menuPermission.subPermissionList"
                     :key="subMenuPermission.id">
         <el-icon><component :is="subMenuPermission.icon"/>el-icon>
        {{ subMenuPermission.name }}
       el-menu-item>
     el-sub-menu>
    el-menu>
  1. 功能权限的筛选

    需求:前端根据不同的用户角色的权限,隐去没有权限的按钮,逻辑和上面的菜单权限类似。

    • Step1 创建自定义指令

      在 vue 的 main.js 的 mount app 之前,我们给 app 添加一个全局自定义指令。

      // el:指令所绑定到的页面 dom 元素。这可以用于直接操作 DOM。
      // binding:是一个对象,里面包含很多属性,重点看 value 属性:传递给指令的值。我们传的是 clue:delete 这个值
      app.directive("hasPermission", (el, binding) => {
         // 这会在 `mounted` 和 `updated` 时都调用
         doGet("/api/login/info", {}).then(resp => {
             let user = resp.data.data;
             let permissionList = user.permissionList;
             let flag = false;

             for (let key in permissionList) {
                 if (permissionList[key] === binding.value) {
                     flag = true;
                     break;
                }
            }
             if (!flag) {
                 // 没有权限,把页面元素隐藏掉
                 // el.style.display = 'none';
                 // 把没有权限的按钮dom元素删除
                 el.parentNode && el.parentNode.removeChild(el)
            }
        })
      })
    • Step2 使用自定义指令

      在合适的地方使用之前我们创建的自定义指令,我这里在删除按钮这里使用,别的地方也一模一样,引号里的内容就是数据库中存的权限信息。

      <el-button type="danger" @click="del(scope.row.id,scope.row.fullName)" v-has-permission="'clue:delete'">删除el-button>

至此,就做完了前端的页面的菜单、功能权限的没有权限时的隐去。

3. 总结

其实整个项目的权限的流程还是很清晰的,从数据库端定义“用户 - 角色 - 权限”三层关系,到后端通过 Spring Security 进行鉴权,再到前端根据权限动态展示菜单和按钮。但具体的权限实现,还是依靠 SpringSecurity 框架。权限系统的关键在于两点:

  • 规范化的数据库设计,确保权限数据结构清晰

  • 与框架的契合,在 Spring Security 的生态下,不必重复造轮子。

我们仅需从项目的设计上,去契合框架给我们的设计,所以说框架还是很强大的。权限不是孤立的功能,而是整个系统安全体系的基础。只有当后端鉴权与前端展示形成统一闭环,系统的安全与用户体验才能兼顾。


  • 微信
  • 赶快加我聊天吧
  • QQ
  • 赶快加我聊天吧
  • weinxin
三桂

发表评论 取消回复 您未登录,登录后才能评论,前往登录