自定义权限管理:RBAC

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

来回顾一下自定义权限管理:用户即角色这种设计的不足之处:

如果运营告诉你,由于图书信息录入错误率较高,希望让学生也参与校正(对学生开放updateBook接口),你只能改代码,把接口上面的:

@PermissionRequired(value={UserTypeEnum.TEACHER})

改为:

@PermissionRequired(value={UserTypeEnum.TEACHER, UserTypeEnum.STUDENT}, logical = Logical.OR)

如此一来,这个接口就对学生开放权限了。

但上面操作需要修改代码、重新打包部署,如果你们公司没有做持续集成啥的,可能这个需求还要等到夜深人静没什么用户时才能停机重启...

归根结底,@PermissionRequired一开始就设计错了,由于@PermissionRequired的属性是角色(TEACHER等),而我们又把这个注解直接放在具体的方法上(也就是资源),这相当于角色和权限以硬编码的方式绑定了,所以后期要更改权限,也要以硬编码的方式解绑。

解决办法是:@PermissionRequired只代表权限本身,把方法视为资源,这样资源即权限,然后通过t_role_permission中间表随意绑定、解绑某个角色拥有的权限即可。

示意图

由于用户的权限在一定时间内都是稳定不变的,所以没必要每次访问接口都去数据库查一遍,我们可以在用户登录时查一遍权限,然后缓存起来即可。

SQL

CREATE TABLE `t_permission` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `method` varchar(255) NOT NULL,
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted` tinyint(1) DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

通用组件

public abstract class WebConstant {
    /**
     * 当前登录的用户信息(Session)
     */
    public static final String CURRENT_USER_IN_SESSION = "current_user_in_session";
    /**
     * 当前登录的用户信息(ThreadLocal)
     */
    public static final String USER_INFO = "user_info";
    /**
     * 当前用户拥有的权限
     */
    public static final String USER_PERMISSIONS = "user_permissions";
}
/**
 * 角色权限注解
 * 注意:你首先要是一个用户才有权限,所以谈权限离不开登录,所以我在RequirePermission上加了LoginRequired
 */
@LoginRequired
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequiresPermission {

}

Controller

@RestController
@RequestMapping("/user")
public class UserController {

    // 由于要查询用户权限,五张表都来了
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private RoleMapper roleMapper;
    @Autowired
    private UserRoleMapper userRoleMapper;
    @Autowired
    private RolePermissionMapper rolePermissionMapper;
    @Autowired
    private PermissionMapper permissionMapper;
    @Autowired
    private HttpSession session;

    @PostMapping("/login")
    public Result<User> login(@RequestBody User loginInfo) {
        LambdaQueryWrapper<User> lambdaQuery = Wrappers.lambdaQuery();
        lambdaQuery.eq(User::getName, loginInfo.getName());
        lambdaQuery.eq(User::getPassword, loginInfo.getPassword());

        User user = userMapper.selectOne(lambdaQuery);
        if (user == null) {
            return Result.error("用户名或密码错误");
        }

        // 1.Session记录登录状态
        session.setAttribute(WebConstant.CURRENT_USER_IN_SESSION, user);
        // 2.Session缓存用户拥有的权限
        session.setAttribute(WebConstant.USER_PERMISSIONS, getUserPermissions(user.getId()));

        return Result.success(user);
    }

    private Set<String> getUserPermissions(Long uid) {
        // 用户拥有的角色
        LambdaQueryWrapper<UserRole> userRoleQuery = Wrappers.lambdaQuery();
        userRoleQuery.eq(UserRole::getUserId, uid);
        List<UserRole> userRoles = userRoleMapper.selectList(userRoleQuery);
        List<Long> roleIds = ConvertUtil.resultToList(userRoles, UserRole::getRoleId);
        if (CollectionUtils.isEmpty(roleIds)) {
            // 没有角色,所有没有权限
            return new HashSet<>();
        }

        // 角色拥有的权限
        List<Long> permissionIds = new ArrayList<>();
        rolePermissionMapper.selectBatchIds(roleIds);
        roleIds.forEach(roleId -> {
            LambdaQueryWrapper<RolePermission> rolePermissionQuery = Wrappers.lambdaQuery();
            rolePermissionQuery.eq(RolePermission::getRoleId, roleId);
            List<RolePermission> rolePermissions = rolePermissionMapper.selectList(rolePermissionQuery);
            permissionIds.addAll(ConvertUtil.resultToList(rolePermissions, RolePermission::getPermissionId));
        });
        if (CollectionUtils.isEmpty(permissionIds)) {
            // 角色都没有分配权限
            return new HashSet<>();
        }

        // 查询权限对应的method
        return Optional.ofNullable(permissionMapper.selectBatchIds(permissionIds))
                .map(permissionList -> permissionList.stream().map(Permission::getMethod).collect(Collectors.toSet()))
                .orElse(Collections.emptySet());

    }
}

拦截器

@Component
public class SecurityInterceptor extends HandlerInterceptorAdapter {

    @Autowired
    private HttpSession session;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 不拦截跨域请求相关
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            return true;
        }

        // 如果类或方法上没有加@LoginRequired或@RequiredPermission(上面叠加了@LoginRequired),直接放行
        if (isLoginFree(handler)) {
            return true;
        }

        // 登录校验,session里如果没有用户信息,就抛异常给globalExceptionHandler提示“需要登录”
        User user = handleLogin(request, response);
        ThreadLocalUtil.put(WebConstant.USER_INFO, user);

        // 权限校验,校验不通过就抛异常,交给全局异常处理
        checkPermission(user, handler);

        // 放行到Controller
        return super.preHandle(request, response, handler);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 及时移除,避免ThreadLocal内存泄漏
        ThreadLocalUtil.remove(WebConstant.USER_INFO);
        super.afterCompletion(request, response, handler, ex);
    }

    /**
     * 接口是否免登录(支持Controller上添加@LoginRequired)
     *
     * @param handler
     * @return
     */
    private boolean isLoginFree(Object handler) {

        // 判断是否支持免登录
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;

            // 类上是否有@LoginRequired
            Class<?> controllerClazz = handlerMethod.getBeanType();
            LoginRequired ControllerLogin = AnnotationUtils.findAnnotation(controllerClazz, LoginRequired.class);

            // 方法上是否有@LoginRequired
            Method method = handlerMethod.getMethod();
            LoginRequired methodLogin = AnnotationUtils.getAnnotation(method, LoginRequired.class);

            return ControllerLogin == null && methodLogin == null;
        }

        return true;
    }

    /**
     * 登录校验
     *
     * @param request
     * @param response
     * @return
     */
    private User handleLogin(HttpServletRequest request, HttpServletResponse response) {
        HttpSession session = request.getSession();
        User currentUser = (User) session.getAttribute(WebConstant.CURRENT_USER_IN_SESSION);
        if (currentUser == null) {
            // 抛异常,请先登录
            throw new BizException(ExceptionCodeEnum.NEED_LOGIN);
        }
        return currentUser;
    }

    /**
     * 权限校验
     *
     * @param user
     * @param handler
     */
    private void checkPermission(User user, Object handler) {
        // 如果类和当前方法上都没有加@RequiresPermission,说明不需要权限校验,直接放行
        if (isPermissionFree(handler)) {
            return;
        }

        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            Method method = handlerMethod.getMethod();
            Class<?> controllerClazz = handlerMethod.getBeanType();

            // 代码走到这,已经很明确,当前方法需要权限才能访问,那么当前用户有没有权限呢?
            @SuppressWarnings("unchecked")
            Set<String> userPermissionMethods = (Set<String>) session.getAttribute(WebConstant.USER_PERMISSIONS);
            String currentRequestMethod = controllerClazz.getName() + "#" + method.getName();
            if (userPermissionMethods.contains(currentRequestMethod)) {
                return;
            }

            // 当前访问的方法需要权限,但是当前用户不具备该权限
            throw new BizException(ExceptionCodeEnum.PERMISSION_DENY);
        }
    }

    /**
     * 是否需要权限校验
     *
     * @param handler
     * @return
     */
    private boolean isPermissionFree(Object handler) {
        // 判断是否需要权限认证
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            Class<?> controllerClazz = handlerMethod.getBeanType();
            Method method = handlerMethod.getMethod();
            RequiresPermission controllerPermission = AnnotationUtils.getAnnotation(controllerClazz, RequiresPermission.class);
            RequiresPermission methodPermission = AnnotationUtils.getAnnotation(method, RequiresPermission.class);
            // 没有加@RequiresPermission,不需要权限认证
            return controllerPermission == null && methodPermission == null;
        }

        return true;
    }

}

测试

之前说过了,@RequiresPermission不再代表角色,只是说明这个接口需要权限。如何让学生也能访问这个接口?只要让学生这个角色也拥有这个权限即可。此时我们不需要去更改代码,只需要往t_role_permission这个中间表插入一条记录,让目标角色和目标方法产生关联即可。

优化

不知道大家有没有发现一个问题。

现在的设计思路是,拼接ControllerName+MethodName得到当前要访问的方法全路径,比如com.bravo.controller.UserController#needPermission,然后查询用户所有的权限,比如:

  • com.bravo.controller.UserController#needPermission(包含这个接口,所以当前用户允许访问)
  • com.bravo.controller.UserController#listUser
  • com.bravo.controller.UserController#getById
  • ...

用户的权限哪来的呢?通过t_user->t_user_role->t_role->t_role_permission->t_permission这样关联查询出来的。如果说t_permission保存了所有需要权限的方法,那么t_role_permission则记录了各个用户所拥有的权限。

t_permission里的记录哪来的?肯定我们插入的,要么通过页面手动录入,要么通过程序自动插入,总之每个@RequiresPermission标记的方法都是需要权限的,所以都要插入到t_permission。

手动插入就不提了,提供对应的add接口即可,自动插入怎么做呢?

一个较为可行的思路是:

项目启动时,得到所有Controller,然后扫描所有handler方法,过滤出带有@RequiresPermission注解的method,然后拼接methodName存入数据库。

更详细的操作是:

/**
 * 系统启动时收集全部的权限方法,同步到t_permission
 */
@Component
public class PermissionMethodCollectionListener implements ApplicationListener<ContextRefreshedEvent>, ApplicationContextAware {

    /**
     * 这里演示通过ApplicationContextAware注入,你也可以直接使用@AutoWired
     */
    private ApplicationContext applicationContext;

    @Autowired
    private PermissionMapper permissionMapper;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    @Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        // 得到t_permission已有的所有权限方法
        Set<String> permissionsFromDB = new HashSet<>();
        List<Permission> permissions = permissionMapper.selectList(new QueryWrapper<>());
        if (CollectionUtils.isNotEmpty(permissions)) {
            permissions.forEach(permission -> permissionsFromDB.add(permission.getMethod()));
        }

        // 遍历所有Controller
        Map<String, Object> beanMap = applicationContext.getBeansWithAnnotation(Controller.class);
        Collection<Object> beans = beanMap.values();
        for (Object bean : beans) {
            Class<?> controllerClazz = bean.getClass();

            // 如果Controller上有@RequiresPermission,那么所有接口都要收集(isApiMethod),否则只收集打了@Permission的接口(hasPermissionAnnotation)
            Predicate<Method> filter = AnnotationUtils.findAnnotation(controllerClazz, RequiresPermission.class) != null
                    ? this::isApiMethod
                    : this::hasPermissionAnnotation;

            // 过滤出Controller中需要权限验证的method
            Set<String> permissionMethodsWithinController = getPermissionMethodsWithinController(
                    controllerClazz.getName(),
                    controllerClazz.getMethods(),
                    filter
            );

            for (String permissionMethodInMemory : permissionMethodsWithinController) {
                // 如果是新增的权限方法
                if (!permissionsFromDB.contains(permissionMethodInMemory)) {
                    Permission permission = new Permission();
                    permission.setModule("");
                    permission.setName("");
                    permission.setMethod(permissionMethodInMemory);
                    permissionMapper.insert(permission);
                }
            }
        }

    }

    private Set<String> getPermissionMethodsWithinController(String controllerName, Method[] methods, Predicate<Method> filter) {

        return Arrays.stream(methods)
                .filter(filter)
                .map(method -> {
                    StringBuilder sb = new StringBuilder();
                    String methodName = method.getName();
                    return sb.append(controllerName).append("#").append(methodName).toString();
                })
                .collect(Collectors.toSet());
    }

    private boolean hasPermissionAnnotation(Method method) {
        return AnnotationUtils.findAnnotation(method, RequestMapping.class) != null
                && AnnotationUtils.findAnnotation(method, RequiresPermission.class) != null;
    }

    private boolean isApiMethod(Method method) {
        return AnnotationUtils.findAnnotation(method, RequestMapping.class) != null;
    }

}

反思

部分同学可能觉得权限系统设计到这个地步,已经很强了,但实际上还存在很多可以改进的地方,特别是设计思路上。大家有没有发现,这几篇文章下来,我都把接口方法(资源)和权限等同看待,认为资源就是权限,权限就是资源。所以,当我把@requiresPermission这个注解(代表权限控制)加到方法上时,大家感受不到这也是一种“耦合”。

“耦合”体现在哪呢?

举个例子,假设对于某个接口,比如listBooks(),希望由原先的“只允许校内用户查询”更改为“未登录也能查询”,你应该怎么做?

注意,是未登录,而不是游客。

“未登录”不是一种角色(角色依赖于用户),你没法把权限赋给一个不存在的角色,所以你只能改代码。我们一开始就把@RequiresPermission与listBooks()绑定了,所以这个接口一定要权限才能访问(且先不管什么角色拥有该权限),如果要“消除”这个接口的权限,只能手动去除注解,也就是改动代码。

RBAC要实现真正的动态分配权限,不仅要考虑权限的动态转移,还要能做到权限的动态消除,也就是资源和权限也要解偶。

鉴于篇幅有限,这里提供一个思路:

我们现在t_permission的数据哪来的?是不是Listener扫描所有Handler后插入的?它的选取标准是什么呢?带有@RequiresPermission注解的Controller或Handler。

但现在@RequiresPermission显然不能用了,因为直接加在方法上会耦合,后期无法为资源“去权限”。

很显然,这个时候t_permission的数据收集工作只能有我们自己做。判断标准是:

你认为哪个接口需要加权限,就自己拼好methodName,然后插入,最后手动为角色分配权限,手动为用户分配角色。

这无疑会加大我们的工作量,但“自动化”与“精细控制”本身确实存在一定的取舍。

有更好的做法欢迎留言~

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

进群,大家一起学习,一起进步,一起对抗互联网寒冬

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值