以角色为基础的动态权限配置,比如普通用户、管理员可以在系统运行时随意更改,此外还需要能够实现类似禁言的功能。
根据需求选择基于角色的访问控制(RBAC)。“其基本思想是,对系统操作的各种权限不是直接授予具体的用户,而是在用户集合与权限集合之间建立一个角色集合。每一种角色对应一组相应的权限。一旦用户被分配了适当的角色后,该用户就拥有此角色的所有操作权限。”[1]。
把后端暴露的每一个接口都写上相应的权限表达式,例如 get:books
,然后设定一些角色与相应的接口(权限)相关联,例如 user
角色拥有 get:books
、 post:login
等权限,最后将某用户与某角色相关联,如此即可实现动态权限管理。角色与权限、用户与角色,均是多对多的关系。管理员可新建角色并指定角色拥有的权限,然后动态分配给用户。
根据基本设计思想,能够很好解决需求,但是性能不一定好,例如判断用户是否有权限时,将从用户所具有的所有权限中逐一比对,时间复杂度是 O(n)
,当 n
特别大的时候即分配给用户的权限表达式数量特别多的时候,时间自然会更长。于是,如何做可以尽量减少分配给用户的权限表达式的数量?
系统如果比较简单,可以直接将权限表达式写为角色表达式,对应的接口判断角色而非权限即可,但是复杂的系统需要更细粒度的权限控制,这种方式并不一定合适。
注意到这样一种现象,在某一业务中,有些权限一旦具有,那么另一些权限也就自然而然具有了,那么可以使用 父子权限 的设计,一旦拥有了父权限就自然可使用子权限,父权限赋予给用户之后,就无需再赋予子权限了,这样就省掉了一些赘余的权限表达式。还有这样一种现象,某些业务中,有些权限都是成组的,要么都有要么都没有,那么可以使用 权限组 的设计,本质上同父子权限的设计类似,可以将一个组视作一个父权限,这样又省掉了一些赘余的权限表达式。由于这两种设计的含义不同但本质是一样的,本系统就直接采用 父子权限 的设计了。
回到需求中,类似禁言的功能如何实现?对于评论而言,用户可以增删改查,一旦违反社区规则,管理员将用户禁言之后,用户仅可以查看评论。一般而言所有用户期初的功能都是一样的,都是 user
角色,都有 user
角色多具有的权限,现在某个用户不能评论了,如何处理较为方便?首先需要明确的是 user
角色对应的权限轻易改不得,一旦更改将影响所有用户,那么只好在角色层面进行一些增改操作了。将评论的权限进行分组,增删改的功能成一组,同时增加一个角色名为评论,角色中有 unlock_time
字段,表示解锁时间,禁言七天就将解锁时间往后增加七天,角色只有在解锁时间小于当前时间时才能使用。
由上面的思考,又引出一个功能,会员功能,开通一个月的会员怎么实现?自然而然想到新建一个 vip
角色赋予用户,那么一个月的时间怎么处理?类比上面角色锁定,可以增加一个字段表示角色失效,即增加 inactive_time
字段,将其设定为一个月之后,角色必须是有效的即失效时间大于当前时间。
经过上面的讨论之后就可以进行数据库设计了,总共五张表:
为了一表多用,增加了前端路由地址控制的 type
字段,本来此表示专注于后端接口的, sort
字段可用于排序,例如某父权限下有很多子权限,可规定子权限的排序。
系统基于以下组件:
依赖 | 版本 |
---|---|
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 * */ @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")
相等,该注解的用法一览:
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] 基于角色的访问控制