驽马十驾 驽马十驾

驽马十驾,功在不舍

目录
关于新项目权限认证的实践总结和梳理
/    

关于新项目权限认证的实践总结和梳理

开篇

一个企业级的项目,权限系统是必不可少的,Java中Spring SecurityShiro都是非常不错的选择,但是经过技术调研和需求调研,发现这2者都不能够100%的满足我们的需求,同时也没有想象中的轻量。

没有轮子或者轮子不好,那我们就来造一个轮子吧。

权限定义

资源分类

  • 从业务需求来看,我们项目的权限基本符合:RBAC(Role-Based Access Control)基于角色的访问控制
  • 从资源分类来看:用户、角色、权限(功能)。
    • 用户和角色可以是1:1的关系,也可以是1:N的关系,有一个用户-角色中间表
    • 角色和权限的关系1:N,有一个角色-权限中间表

设计区分

这里我们需要思考一个点:权限到底如何表示(定位)?

  • 通过Controller层的方法上的URL来定义一个权限。因为是基于URL该方式通常可采用Filter实现鉴权
  • 通过Controller层方法上的注解来定义一个权限。因为该方法需要获取到方法上的注解,所以通常由基于反射的Interceptor实现鉴权

Filter 通常的实现依赖的是FilterChain,基于函数回调实现

Interceptor通常是通过反射来实现的

上述2者都体现了AOP的思想噢...

在上一个项目中,我们采用的是第一种URL来定义一个权限,但是发现了如下几个问题:

  1. URL不够语义化,运营人员不太明白。比如基于REST规范的Get /studentPOST /student,他们不明白这个表示的到底是什么功能权限!
  2. URL的写法可能是Request也可能是PathVariable,这些搅和在一起,让人困惑。
  3. URL一旦同角色绑定过后,不能轻易修改,否则之前的授权就需要重新绑定

当然上面的这些都有办法去解决,但是处理完成后,我们认为这些处理方式不够统一,为了解决这些问题,我们结合Swagger定制了一套权限管理系统。

定义权限

基于Interceptor的实现,很多第三方框架都是定义了一些注解,比如这样的:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthRequired {
 	//一些必要参数...
}

但是在我看来,能够复用已经存在于框架中的东西的时候,还是要利用起来的。

同时我们公司要求对外的方法需要通过Swagger来注解,方便前端开发人员明确方法含义和方便生成文档,比如这样的示例:

    @ApiOperation(value = "【查询】用户统计时候可用的省的",nickname = "L029-02")
    @GetMapping("/pv-data/province")
    public ApiRet<IPage<String>> findProvince(PVProvincePage provincePage) {
        IPage<String> result = userUseViewService.findProvince(provincePage);
        return new ApiRet<>(result);
    }

仔细一看,swagger的注解ApiOperation描述的不就是我们想要的吗?

  • 权限含义:通过value注解,中文汉字语义明确,而且可以在后期随时变更
  • 权限标志:通过nickname来定义id,业务上保证全局唯一,通过该值同角色绑定。

这不就是我们想要的,最为核心的,能够准确描述一个能够让运维人员看懂的权限吗!?

权限扫描

假如按照上述的规定定义权限功能,当应用启动的时候,通过某种方式扫描全局的Controller中的所有public且带有ApiOperation的方法,这就可以获取当前系统的所有需要处理的权限了。

获取权限后你是缓存到内存中还是将扫描到的集合入库,都是可以的,这里我将我们的扫描ApiOperation核心API提一下

  • 扫描API的代码大略如下所示
    /**
     * 扫描基础包下的方法,并将其映射为
     *
     * @param basePackage 基础扫描包
     * @return 权限集合
     */
    public static Set<OperationInfo> scan(String basePackage) {
      	//ClassScanner 采用的是hutool的,性能没问题
        Set<Class<?>> classes = ClassScanner.scanPackageByAnnotation(basePackage, RestController.class);
        Set<OperationInfo> result = Optional.ofNullable(classes)
                .orElse(new HashSet<>(0))
                .stream()
                .map(ApiOperationScanner::mapToOperationInfo) //将Swagger注解映射为权限实体
                .filter(CollectionUtil::isNotEmpty)
                .flatMap(Set::stream)
                .collect(Collectors.toSet());
        return result;
    }

获取到对应的后,如果要入库,需要对比下之前的权限和现在的权限的差异

  • 那些API的说明是否变更了
  • 那些API被删除了
  • 那些API是新增的
  • ....

上述的这些细节都需要考虑到,这里就不详诉了,请结合你们的业务需求自行分析

权限验证

基于RBAC的思路,权限是绑定在角色上的,所以你只需要将某个角色具有的权限绑定在一起,入库即可。当需要鉴权时候,查找该用户的对应的角色是否预备这个方法即可,我们来看看下述代码:

创建一个拦截器UEInterceptor

@Component
public class UEInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        //...省略部分代码...

        //获取到当前拦截的方法的注解
        ApiOperation operation = method.getAnnotation(ApiOperation.class);

        //获取token
        String token = request.getHeader(USER_TOKEN);
        //判定这个token是否存在
        boolean exists = checkTokenExists(userId, token);
        if (!exists) {
            throw new AccountLoseEffectException(token);
        }
        //通过token获取到userId的获取
        Long userId = tokenManager.parseUserId(token);

        //判定当前用户的角色是否包含功能
        boolen flag = userHasAuth(userId,operation)
        if(!flag){
          throw new PermissionMissException();
        }
        //通过ThreadLocal缓存数据用户的数据信息
        UserContext.storeUser(new UserInfo(userId, roleId, userCore.getLoginName()));
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
                                @Nullable Exception ex) throws Exception {
        //防止内存泄露,所以请求完该方法后,清空数据
        UserContext.clear();
    }
}

关键实现其中的2个方法:

  • preHandle 就是在真正执行方法前,进行某些操作。上述方法主要是:
    • 查看当前用户的token是否存在,是否合法
    • token对应的用户的角色是否具备这个功能
    • 在线程上下文ThreadLocal中缓存用户信息
  • afterCompletion就是方法执行完成后,做的操作
    • 清空ThreadLocal的值,避免内存泄露

上述的拦截器如果要生效,还需要实现接口WebMvcConfigure来进行如下设置:

@Configuration
public class UEInterceptorConfig implements WebMvcConfigurer {

    @Autowired
    private UEInterceptor ueInterceptor;

    @Value("${ue.interceptor.include:/**}")
    private String[] includePaths;

    @Value("${ue.interceptor.exclude}")
    private String[] excludePaths;

  	//添加拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(ueInterceptor).order(Integer.MIN_VALUE)
          .addPathPatterns(includePaths).excludePathPatterns(excludePaths);
    }

}
  • includePaths可以默认为/**表示所有的都拦截
  • excluedePaths为需要排除的拦截URI,比如登录、获取验证码、静态资源请求等
  • 假如系统存在多个拦截器,通过order(Integer.MIN_VALUE)来定义顺序

总结

本文阐述了下述几个要点:

  • 为什么权限认证由最开始的Filter改为了Interceptr
  • 如何结合Swagger来定义一个权限(功能)
  • 项目启动后通过ClassScaner扫描出所有的对应的方法
  • 拦截器主要的功能:token判定、权限判定、用户信息保存在ThreadLocal,以及方法执行完成后清理ThreadLocal
  • 拦截器的设置:includeexclude

上文中又不少细节同项目有关,根据公司规定,我不能贴太多代码,所以仅仅提了下思路,请结合自己的理解去完善和实践,不要怕做错,自己尝试的绝对比别人教你的印象深刻。

积土成山,风雨兴焉。积水成渊,蛟龙生焉。