目录

权限系统的设计与实现

目录

以角色为基础的动态权限配置,比如普通用户、管理员可以在系统运行时随意更改,此外还需要能够实现类似禁言的功能。

根据需求选择基于角色的访问控制(RBAC)。“其基本思想是,对系统操作的各种权限不是直接授予具体的用户,而是在用户集合与权限集合之间建立一个角色集合。每一种角色对应一组相应的权限。一旦用户被分配了适当的角色后,该用户就拥有此角色的所有操作权限。”[1]。

把后端暴露的每一个接口都写上相应的权限表达式,例如 get:books,然后设定一些角色与相应的接口(权限)相关联,例如 user 角色拥有 get:bookspost:login等权限,最后将某用户与某角色相关联,如此即可实现动态权限管理。角色与权限、用户与角色,均是多对多的关系。管理员可新建角色并指定角色拥有的权限,然后动态分配给用户。

根据基本设计思想,能够很好解决需求,但是性能不一定好,例如判断用户是否有权限时,将从用户所具有的所有权限中逐一比对,时间复杂度是O(n),当n特别大的时候即分配给用户的权限表达式数量特别多的时候,时间自然会更长。于是,如何做可以尽量减少分配给用户的权限表达式的数量?

系统如果比较简单,可以直接将权限表达式写为角色表达式,对应的接口判断角色而非权限即可,但是复杂的系统需要更细粒度的权限控制,这种方式并不一定合适。

注意到这样一种现象,在某一业务中,有些权限一旦具有,那么另一些权限也就自然而然具有了,那么可以使用父子权限的设计,一旦拥有了父权限就自然可使用子权限,父权限赋予给用户之后,就无需再赋予子权限了,这样就省掉了一些赘余的权限表达式。还有这样一种现象,某些业务中,有些权限都是成组的,要么都有要么都没有,那么可以使用权限组的设计,本质上同父子权限的设计类似,可以将一个组视作一个父权限,这样又省掉了一些赘余的权限表达式。由于这两种设计的含义不同但本质是一样的,本系统就直接采用父子权限的设计了。

回到需求中,类似禁言的功能如何实现?对于评论而言,用户可以增删改查,一旦违反社区规则,管理员将用户禁言之后,用户仅可以查看评论。一般而言所有用户起初的功能都是一样的,都是 user 角色,都有 user 角色多具有的权限,现在某个用户不能评论了,如何处理较为方便?首先需要明确的是 user 角色对应的权限轻易改不得,一旦更改将影响所有用户,那么只好在角色层面进行一些增改操作了。将评论的权限进行分组,增删改的功能成一组,同时增加一个角色名为评论,角色中有unlock_time字段,表示解锁时间,禁言七天就将解锁时间往后增加七天,角色只有在解锁时间小于当前时间时才能使用。

由上面的思考,又引出一个功能,会员功能,开通一个月的会员怎么实现?自然而然想到新建一个vip角色赋予用户,那么一个月的时间怎么处理?类比上面角色锁定,可以增加一个字段表示角色失效,即增加inactive_time字段,将其设定为一个月之后,角色必须是有效的即失效时间大于当前时间。

经过上面的讨论之后就可以进行数据库设计了,总共五张表:

https://cdn.jsdelivr.net/gh/dfface/img0@master/0/8MvLkR-UoUS91.png

https://cdn.jsdelivr.net/gh/dfface/img0@master/0/SDiB30-yZKxfg.png

为了一表多用,增加了前端路由地址控制的 type 字段,本来此表示专注于后端接口的,sort字段可用于排序,例如某父权限下有很多子权限,可规定子权限的排序。

https://cdn.jsdelivr.net/gh/dfface/img0@master/0/LsGRwY-9GXtJ6.png

https://cdn.jsdelivr.net/gh/dfface/img0@master/0/y4i30S-KttOpE.png

https://cdn.jsdelivr.net/gh/dfface/img0@master/0/c1Q9s5-OsMrPF.png

系统基于以下组件:

依赖 版本
spring boot 2.3.1
spring security starter 2.3.1(对应 security 5.3.3)
spring data jpa starter 2.3.1
/**
 * 继承配置类以完成拦截.
 * @author yuhanliu
 * @since 1.8
 *
 */
@EnableConfigurationProperties(WwpjwConfig.class)
@EnableGlobalMethodSecurity(prePostEnabled = true)  // 重点是开启注解配置
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  // Security 基本配置**省略**

Spring Security 需要实现 UserDetails、UserServiceImpl,主要是 UserServiceImpl 中的 loadUserByUsername,需要获取到权限及角色信息,调用 Service 层方法好了。

Controller 中使用 @PreAuthorize 注解(里面可以写 SpEL),例如:

@RestController
@RequestMapping("/admin/user") 
public class UserController {
    // ......
    @PreAuthorize("hasAuthority('ROLE_ADMIN') or #reqVo.sysUser.username == #userDetails.username")
    @PostMapping("/findUsers")
    public Result<List<SysUser>> findUsers(@Validated @RequestBody FindUsersReqVo reqVo, @AuthenticationPrincipal UserDetails userDetails) {
        PageInfo<SysUser> pageInfo = userService.findUsers(reqVo);
        return new Result<>(pageInfo.getList(), pageInfo.getTotal());
    }
}

其中,hasRole("ADMIN")hasAuthority("ROLE_ADMIN")相等,该注解的用法一览:

https://cdn.jsdelivr.net/gh/dfface/img0@master/0/yPgeKZ-BK3kSX.png

public interface PermissionRepository extends JpaRepository<Permission, String> {

  /**
   * 获取指定角色id的所有权限.
   * 结果一定是没有重复元素的.
   * @param roleId 角色信息
   * @param type 1表示后端接口,0表示前端url
   * @return 权限列表
   */
  @Query(nativeQuery = true,
  value = "SELECT p.* FROM role r LEFT JOIN role_permission rp ON  r.id = rp.role_id AND r.id = ?1 "
    + "INNER JOIN permission p ON rp.permission_id = p.id AND p.type = ?2")
  List<Permission> findAllByRoleId(String roleId, boolean type);


  /**
   * 获取指定角色id列表的所有权限.
   * @param roleIdList 角色id列表
   * @param type 1表示后端接口,0表示前端url
   * @return 权限列表
   */
  @Query(nativeQuery = true,
  value = "SELECT DISTINCT p.id, p.name, p.code, p.url, p.type, p.method, p.sort, p.parent FROM role r LEFT JOIN role_permission rp ON  r.id = rp.role_id AND r.id in (:roleIdList) "
    + "INNER JOIN permission p ON rp.permission_id = p.id AND p.type = :type")
  List<Permission> findAllByRoleIdList(@Param("roleIdList") List<String> roleIdList, @Param("type") boolean type);
}
public interface RoleRepository extends JpaRepository<Role, String> {

  /**
   * 获取指定用户id的所有角色信息包含inactive_time、delete_time.
   * @param userId 用户id
   * @return 未经处理的角色信息列表
   */
  @Query(nativeQuery = true ,
  value = "SELECT r.id, r.name, r.code, r.description, r.delete_time, r.create_time, ur.inactive_time, ur.associate_time, ur.unlock_time " +
  "FROM user u LEFT JOIN user_role ur ON u.id = ur.user_id AND u.id = ?1 " +
  "INNER JOIN role r ON r.id = ur.role_id")
  List<RoleRelatedVo> findAllByUserId(String userId);
}
@Service
public class PermissionServiceImpl implements PermissionService {

  @Resource
  private PermissionRepository permissionRepository;

  @Override
  public List<Permission> getAllByRoleId(String roleId, boolean api) {
    return permissionRepository.findAllByRoleId(roleId, api);
  }

  @Override
  public List<Permission> getAllByRoleIdList(List<String> roleIdList, boolean api) {
    return permissionRepository.findAllByRoleIdList(roleIdList, api);
  }
}
@Service
public class RoleServiceImpl implements RoleService {

  @Resource
  private RoleRepository roleRepository;

  /**
   * 通过用户id获取用户所有角色(是否删除,是否有效).
   * @param userId 用户id
   * @param excludeInactive user_role 中 inactive_time 是否为 null
   * @param excludeDeleted role 中 delete_time 是否为 null
   * @param excludeLock user_role 中 unlock_time 是否小于当前时间
   * @return {@code List<RoleRelatedVo>} 用户角色相关信息列表
   */
  @Override
  public List<RoleRelatedVo> getAllByUserId(String userId, boolean excludeInactive, boolean excludeDeleted, boolean excludeLock) {
    List<RoleRelatedVo> roleRelatedVos = roleRepository.findAllByUserId(userId);
    // 没有一个角色就直接返回包含0个值的列表,这没有什么问题,同时避免之后 stream 的为空问题。
    if (roleRelatedVos.isEmpty()) {
      return roleRelatedVos;
    }
    if (excludeInactive) {
      // 留下的角色都是:1 失效时间为 null 表示永不失效,2 失效时间在当前时间之后【开通一个月会员功能】
      roleRelatedVos = roleRelatedVos.stream().filter(roleRelatedVo -> roleRelatedVo.getInactiveTime() == null
        || roleRelatedVo.getInactiveTime().isAfter(LocalDateTime.now())).collect(Collectors.toList());
    }
    if (excludeDeleted) {
      // 留下的角色都是:删除时间为 null 表示未删除
      roleRelatedVos = roleRelatedVos.stream().filter(roleRelatedVo -> roleRelatedVo.getDeleteTime() == null)
        .collect(Collectors.toList());
    }
    if (excludeLock) {
      // 留下的角色都是:解锁时间小于当前时间【禁言功能】
      roleRelatedVos = roleRelatedVos.stream().filter(roleRelatedVo -> LocalDateTime.now().isAfter(roleRelatedVo.getUnlockTime()))
        .collect(Collectors.toList());
    }
    return roleRelatedVos;
  }

  @Override
  public List<RoleRelatedVo> getAllByUserId(String userId) {
    return getAllByUserId(userId, true, true, true);
  }

[1] 基于角色的访问控制