权限系统的设计与实现
以角色为基础的动态权限配置,比如普通用户、管理员可以在系统运行时随意更改,此外还需要能够实现类似禁言的功能。
根据需求选择基于角色的访问控制(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
*
*/
@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")
相等,该注解的用法一览:
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] 基于角色的访问控制