权限设计
- 登录认证
登录认证是对用户身份进行确认。
- 权限认证
权限认证是对用户能否访问某个资源进行确认,一般在用户登录成功之后进行。
RBAC 模型
用户,角色,权限。在这种设计模式下,用户不直接拥有权限,权限是赋值给角色的,用户只能具有某种角色后,才能获取对应角色的权限。
用户和角色是多对多的关系,角色与权限也是多对多的关系,用户通过角色来关联到权限。
- 数据库的设计如下:
页面权限
权限表内存储的是前端对应页面的 url,在用户登录时,把用户信息返回给前端,有前端控制是否展示对应的界面。
操作权限
权限表内存储是后端对应接口的地址,在用户登录时,把用户信息返回给前端,有前端控制是否展示对应的按钮。
数据权限
数据权限说直白点就是哪些人能看到什么数据。
例如:有一张数据表和公司表,数据表里有
company_id
字段,当前用户能看到哪些公司的数据,只需要增加一个用户和公司的关联表,然后在 SQL 查询的时候,SELECT * FROM data WHERE company_id in (?, ?, ?...) ORDER BY create_time DESC LIMIT ?,?
即可实现。
注意事项
- 无论是角色表还是权限表,都需要存储对应角色或权限的标识码。
- 权限一般分为:目录,菜单,按钮,只有对应按钮才会触发后端的方法,因此权限表内存储的按钮信息,需要存储对应的标识码(sys:user:add:用户新增),其它类型的可以不存。
- 在给用户分配菜单时,要么默认菜单上的按钮全部分配,要么默认都不分配。不能只分配菜单,不处理菜单上的按钮。
- 权限要做好分层的设计。
- 目录,菜单,按钮的权限有前端控制是否展示,但按钮触发的接口后端也需要校验。
设计思路
用户登录时把用户的权限码给用户
- 设置用户的权限码,其实就是用户能够访问的接口在权限表内的标识码,这一步主要为了后续的校验。
// 保存用户登录日志 生成 Token 信息
LoginUser loginUser = new LoginUser();
loginUser.setId(tSysUser.getId());
loginUser.setName(tSysUser.getName());
loginUser.setEmail(tSysUser.getEmail());
// 设置用户的权限码,其实就是用户能够访问的接口在权限表内的标识码,这一步主要为了后续的校验
loginUser.setPermissions(sysMenuService.getMenuByUserId(loginUser.getId()));
sysUserLoginLogsService.setSysUserLoginLogs(tSysUser, null, "success", "login");
return R.ok(sysUserService.createToken(loginUser));
返回用户-角色-权限 树形信息
- 用户登录后,前端请求该接口,拿到用户能够看到的数据,其中包括:用户信息,用户角色信息,对应角色下的权限信息,重叠的数据取并集。
// 用户登录后,前端请求该接口,拿到用户能够看到的数据,其中包括:用户基本信息,用户角色基本信息,对应角色下的权限信息,重叠的数据取并集
public SysUserRoleMenuTreeVO getUserRoleMenuTree() {
LoginUser loginUser = LoginUserUtils.get();
TSysUser tSysUser = sysUserService.getById(loginUser.getId());
SysUserRoleMenuTreeVO vo = new SysUserRoleMenuTreeVO();
BeanUtils.copyProperties(tSysUser, vo);
Set<SysRoleVO> rolesList = sysRoleService.getRolesByUserId(loginUser.getId());
rolesList.forEach(m -> {
m.setMenus(buildTree(sysMenuService.getMenuTreeByRoleId(m.getId())));
});
vo.setRoles(rolesList);
return vo;
}
// 封装数据树形方法
public Set<SysMenuVO> buildTree(Set<SysMenuVO> set) {
Map<Long, SysMenuVO> parentTree = new HashMap<>();
Set<SysMenuVO> children = new HashSet<>();
for (SysMenuVO data : set){
parentTree.put(data.getId(), data);
}
for (SysMenuVO data : set){
if (data.getParent() == 0){
children.add(data);
} else {
SysMenuVO vo = parentTree.get(data.getParent());
if (ObjectUtils.isNotEmpty(vo)) vo.getChildren().add(data);
}
}
return children;
}
定义后端接口校验方法
- 权限注解的校验模式
/**
* 权限注解的验证模式
*
*/
public enum Logical {
/**
* 必须具有所有的元素
*/
AND,
/**
* 只需具有其中一个元素
*/
OR
}
- 权限认证接口
/**
* 权限认证:必须具有指定权限才能进入该方法
*
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RequiresPermissions {
/**
* 需要校验的权限码
*/
String[] value() default {};
/**
* 验证模式:AND | OR,默认AND
*/
Logical logical() default Logical.AND;
}
- 角色认证接口
/**
* 角色认证:必须具有指定角色标识才能进入该方法
*
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RequiresRoles {
/**
* 需要校验的角色标识
*/
String[] value() default {};
/**
* 验证逻辑:AND | OR,默认AND
*/
Logical logical() default Logical.AND;
}
- 定义权限/角色认证出错的异常类
/**
* 未能通过的权限认证异常
*
* @author ruoyi
*/
public class NotPermissionException extends RuntimeException {
private static final long serialVersionUID = 1L;
public NotPermissionException(String permission) {
super(permission);
}
public NotPermissionException(String[] permissions) {
super(StringUtils.join(permissions, ","));
}
}
/**
* 未能通过的角色认证异常
*
* @author ruoyi
*/
public class NotRoleException extends RuntimeException {
private static final long serialVersionUID = 1L;
public NotRoleException(String role) {
super(role);
}
public NotRoleException(String[] roles) {
super(StringUtils.join(roles, ","));
}
}
// 封装的接口类里需要增加这两个类
/**
* 权限的校验
* @param notPermissionException
* @return
*/
@ExceptionHandler(NotPermissionException.class)
public R<?> notPermissionException(NotPermissionException notPermissionException) {
log.error(notPermissionException.getMessage());
return R.fail(notPermissionException.getMessage());
}
/**
* 角色的校验
* @param notRoleException
* @return
*/
@ExceptionHandler(NotRoleException.class)
public R<?> notRoleException(NotRoleException notRoleException) {
log.error(notRoleException.getMessage());
return R.fail(notRoleException.getMessage());
}
- 基于 AOP 注解校验类
/**
* 基于 Spring Aop 的注解鉴权
*
*/
@Aspect
@Component
public class PreAuthorizeAspect {
/**
* 构建
*/
public PreAuthorizeAspect() {}
/**
* 定义AOP签名 (切入所有使用鉴权注解的方法)
*/
public static final String POINTCUT_SIGN = "@annotation(com.rhx.manage.config.auth.RequiresPermissions) || " + "@annotation(com.rhx.manage.config.auth.RequiresRoles)";
/**
* 声明AOP签名
*/
@Pointcut(POINTCUT_SIGN)
public void pointcut() {}
/**
* 环绕切入
*
* @param joinPoint 切面对象
* @return 底层方法执行后的返回值
* @throws Throwable 底层方法抛出的异常
*/
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 注解鉴权
checkMethodAnnotation(((MethodSignature) joinPoint.getSignature()).getMethod());
// 执行原有逻辑
return joinPoint.proceed();
}
/**
* 对一个Method对象进行注解检查
*/
public void checkMethodAnnotation(Method method) {
// 校验 @RequiresRoles 注解
RequiresRoles requiresRoles = method.getAnnotation(RequiresRoles.class);
if (ObjectUtils.isNotEmpty(requiresRoles)) AuthUtils.checkRole(requiresRoles);
// 校验 @RequiresPermissions 注解
RequiresPermissions requiresPermissions = method.getAnnotation(RequiresPermissions.class);
if (ObjectUtils.isNotEmpty(requiresPermissions)) AuthUtils.checkPermi(requiresPermissions);
}
}
- 公共接口类
/**
* 权限校验公共方法
*/
public class AuthUtils {
/**
* 根据角色鉴权
* @param requiresRoles
*/
public static void checkRole(RequiresRoles requiresRoles) {
if (requiresRoles.logical() == Logical.AND) {
checkRoleAnd(requiresRoles.value());
} else {
checkRoleOr(requiresRoles.value());
}
}
/**
* 验证用户是否含有指定角色,必须全部拥有
*
* @param roles 角色标识数组
*/
public static void checkRoleAnd(String... roles) {
Set<String> roleList = getRoleList();
for (String role : roles) {
if (!hasRole(roleList, role)) {
throw new NotRoleException(role);
}
}
}
/**
* 验证用户是否含有指定角色,只需包含其中一个
*
* @param roles 角色标识数组
*/
public static void checkRoleOr(String... roles) {
Set<String> roleList = getRoleList();
for (String role : roles) {
if (hasRole(roleList, role)) {
return;
}
}
if (roles.length > 0) {
throw new NotRoleException(roles);
}
}
/**
* 获取当前账号的角色列表
*
* @return 角色列表
*/
public static Set<String> getRoleList() {
try {
LoginUser loginUser = LoginUserUtils.get();
return loginUser.getRoles();
} catch (Exception e) {
return new HashSet<>();
}
}
/**
* 判断是否包含角色
*
* @param roles 角色列表
* @param role 角色
* @return 用户是否具备某角色权限
*/
public static boolean hasRole(Collection<String> roles, String role) {
return roles.stream().filter(StringUtils::hasText)
.anyMatch(x -> PatternMatchUtils.simpleMatch(x, role));
}
/**
* 校验用户权限
* @param requiresPermissions
* @throws Exception
*/
public static void checkPermi(RequiresPermissions requiresPermissions) {
if (requiresPermissions.logical() == Logical.AND) {
checkPermiAnd(requiresPermissions.value());
} else {
checkPermiOr(requiresPermissions.value());
}
}
/**
* 验证用户是否含有指定权限,必须全部拥有
*
* @param permissions 权限列表
*/
public static void checkPermiAnd(String... permissions) {
Set<String> permissionList = getPermiList();
for (String permission : permissions) {
if (!hasPermi(permissionList, permission)) {
throw new NotPermissionException("无对应权限: " + permission);
}
}
}
/**
* 验证用户是否含有指定权限,只需包含其中一个
*
* @param permissions 权限码数组
*/
public static void checkPermiOr(String... permissions) {
Set<String> permissionList = getPermiList();
for (String permission : permissions) {
if (hasPermi(permissionList, permission)) {
return;
}
}
if (permissions.length > 0) {
throw new NotPermissionException(permissions);
}
}
/**
* 判断是否包含权限
*
* @param authorities 权限列表
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
public static boolean hasPermi(Collection<String> authorities, String permission){
if (CollectionUtils.isEmpty(authorities)) {
throw new NotPermissionException("无对应权限: " + permission);
}
return authorities.stream().filter(StringUtils::hasText)
.anyMatch(x -> PatternMatchUtils.simpleMatch(x, permission));
}
/**
* 获取当前账号的权限列表
*
* @return 权限列表
*/
public static Set<String> getPermiList() {
try {
LoginUser loginUser = LoginUserUtils.get();
return loginUser.getPermissions();
} catch (Exception e) {
return new HashSet<>();
}
}
}
- 接口使用
/**
* @RequiresPermissions(value = "sys:user:page")
* @RequiresPermissions(value = "sys:user:page", logical = Logical.OR)
* 使用的时候,注意上述两种注解的区别
*/
@RequiresPermissions(value = "sys:user:page", logical = Logical.OR)
@ApiOperation(value = "注册用户分页查询")
@PostMapping(value = "/page")
public R<?> getSysUserPage(@RequestBody SysUserPageDTO pageDTO) {
return R.ok(sysUserHandler.getSysUserPage(pageDTO));
}
补充
当对用户的角色信息操作时,已登录的用户如何感知?
- 方案一
增加拦截器,每次的接口访问都对用户的角色信息进行校验,然后把信息存储到 Redis 内,当操作用户的角色信息时,及时更新或者刷新缓存。
- 方案二
操作用户角色信息后,强制用户下线,然后重新登录。