# 权限管理

RBAC (opens new window) 是基于角色的访问控制(Role-Based Access Control ),权限与角色相关联,通过给用户分配适当角色的成员而得到这些角色的相应的权限,极大地简化了权限的管理。

每个角色至少具备一个权限,每个用户至少扮演一个角色。可以对两个完全不同的角色分配完全相同的访问权限;会话由用户控制,一个用户可以创建会话并激活多个用户角色,从而获取相应的访问权限,用户可以在会话中更改激活角色,并且用户可以主动结束一个会话。用户和角色是多对多的关系,表示一个用户在不同的场景下可以拥有不同的角色。

本项目采用这种权限管理,因为管理都是层级相互依赖的,权限赋予给角色,而又把用户赋予对应的角色,这样的权限设计很清楚,管理起来很方便。

# 用户管理

系统默认自带超级管理员角色的用户 admin ,默认包含所有的角色权限。可以通过超级管理员账户登录系统,创建和维护新的用户,如下图:

image-20210209101431962

如果想要新创建的用户拥有一定的系统访问权限,记得在角色的下拉框中,选择需要赋予用户的相关角色,注意这里可以支持多选,也就是一个用户可以通过拥有多个角色来访问系统。

# 角色管理

如果系统不存在当前需要的角色,可以通过 “角色管理” 模块进行添加和编辑,系统会自动加载所有的菜单权限,系统管理员在创建或编辑角色的同时,设置当前角色的菜单权限和数据权限。

image-20210209105143143

# 菜单管理

本项目支持动态菜单管理,参考前端项目的菜单路由配置src\router\index.js,提供管理员通过界面修改对应的路由属性,从而动态配置每个菜单项。

// 当设置 true 的时候该路由不会在侧边栏出现 如401,login等页面,或者如一些编辑页面/edit/1
hidden: true // (默认 false)

//当设置 noRedirect 的时候该路由在面包屑导航中不可被点击
redirect: 'noRedirect'

// 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式--如组件页面
// 只有一个时,会将那个子路由当做根路由显示在侧边栏--如引导页面
// 若你想不管路由下面的 children 声明的个数都显示你的根路由
// 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由
alwaysShow: true

name: 'router-name' // 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
meta: {
  permissions: ['user:list'] // 设置该路由进入的权限,支持多个权限叠加
  title: 'title' // 设置该路由在侧边栏和面包屑中展示的名字
  icon: 'svg-name' // 设置该路由的图标,支持 svg-class,也支持 el-icon-x element-ui 的 icon
  noCache: true // 如果设置为true,则不会被 <keep-alive> 缓存(默认 false)
  breadcrumb: false //  如果设置为false,则不会在breadcrumb面包屑中显示(默认 true)
  affix: true // 如果设置为true,它则会固定在tags-view中(默认 false)

  // 当路由设置了该属性,则会高亮相对应的侧边栏。
  // 这在某些场景非常有用,比如:一个文章的列表页路由为:/article/list
  // 点击文章进入文章详情页,这时候路由为/article/1,但你想在侧边栏高亮文章列表的路由,
  // 就可以进行如下设置
  activeMenu: '/article/list'

这里我们为了方便配置,定义了三种类型的菜单项:

  • 普通菜单

    左侧导航菜单栏的的菜单,如果是外链,则点击后跳转到外部链接,如果不是外链,则为系统菜单,需要定义对应前端路由、组件名称、组件路径,前端项目根据此组件的vue文件来渲染界面。

    image-20210209130959708

  • 路由菜单

    一般为对用户不可见的菜单,但是又必须有路由的菜单,比如一些新建、修改、详情页的应用场景,像article/edit/:id这个路由,非传统意义上的菜单,但是前端UI必须定义这样的路由,所以这里作为一个隐藏菜单来定义。

    image-20210218111230615

  • 控件菜单

此类菜单是指那些在前端界面所用的控件,通过此处的权限标识的配置来控制该控件显示还是不显示,如下的删除按钮,标记为article:del权限的用户才有访问的权限,前端代码需要通过指令v-permission来包括所有可以访问的权限标记。

<el-table-column v-permission="['admin', 'article:edit', 'article:del']" label="操作">
...
</el-table-column>

image-20210218141539785

# 代码逻辑

# 安全验证

本项目基于Spring Security + JWT 进行相关的权限验证的,所有相关逻辑在rapid-api-web项目下tech.lancelot.security包中。

  • TokenFilter

    继承GenericFilterBean,实现doFilter接口,主要的逻辑是前端访问后端时,携带token信息,这里会进行判断,如果当前携带的 token 信息跟 Redis 中保存的 token 信息一致,则认证通过,并进行 token 续期,否则拒绝该访问。

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, 
                         FilterChain filterChain)
        throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String token = resolveToken(httpServletRequest);
        // 对于 Token 为空的不需要去查 Redis
        if (StrUtil.isNotBlank(token)) {
            OnlineUserDto onlineUserDto = null;
            boolean cleanUserCache = false;
            try {
                onlineUserDto = onlineUserService.getOne(properties.getOnlineKey() + token);
            } catch (ExpiredJwtException e) {
                log.error(e.getMessage());
                cleanUserCache = true;
            } finally {
                if (cleanUserCache || Objects.isNull(onlineUserDto)) {
                    userCacheClean.cleanUserCache(
                        String.valueOf(tokenProvider.getClaims(token)
                 .get(TokenProvider.AUTHORITIES_KEY)));
                }
            }
            if (onlineUserDto != null && StringUtils.hasText(token)) {
                Authentication authentication = tokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                // Token 续期
                tokenProvider.checkRenewal(token);
            }
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }
    
  • JwtAuthenticationEntryPoint

    继承AuthenticationEntryPoint并实现commence接口,当前端项目调用后端的API资源而没有有效凭据时,返回401错误。

    @Component
    public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
        @Override
        public void commence(HttpServletRequest request,
                             HttpServletResponse response,
                             AuthenticationException authException) throws IOException {
            // 当用户尝试访问安全的REST资源而不提供任何凭据时,将调用此方法发送401 响应
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, 
                               authException==null?"Unauthorized":authException.getMessage());
        }
    }
    
  • JwtAccessDeniedHandler

    继承AccessDeniedHandler并实现handle接口,用户在没有授权的情况下访问受保护的REST资源时,将调用此方法返回403错误。

    @Component
    public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    
        @Override
        public void handle(HttpServletRequest request, 
                           HttpServletResponse response,
                           AccessDeniedException accessDeniedException) throws IOException {
            //当用户在没有授权的情况下访问受保护的REST资源时,将调用此方法发送403 Forbidden响应
            response.sendError(HttpServletResponse.SC_FORBIDDEN, 
                               accessDeniedException.getMessage());
        }
    }
    
  • SecurityConfig

    全局 Spring Security 配置,设置认证策略和 url 放行策略,并指定如果认证失败,处理返回方法。

    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        ...
    
        @Override
        protected void configure(HttpSecurity httpSecurity) throws Exception {
            // 搜寻匿名标记 url: @AnonymousAccess
            Map<RequestMappingInfo, HandlerMethod> handlerMethodMap = applicationContext
                .getBean(RequestMappingHandlerMapping.class).getHandlerMethods();
            // 获取匿名标记
            Map<String, Set<String>> anonymousUrls = getAnonymousUrl(handlerMethodMap);
            httpSecurity
                    // 禁用 CSRF
                    .csrf().disable()
                    .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                    // 授权异常
                    .exceptionHandling()
                    .authenticationEntryPoint(authenticationErrorHandler)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
                    // 防止iframe 造成跨域
                    .and()
                    .headers()
                    .frameOptions()
                    .disable()
                    // 不创建会话
                    .and()
                    .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                    .authorizeRequests()
                    // 静态资源等等
                    .antMatchers(
                            HttpMethod.GET,
                            "/*.html",
                            "/*.ico",
                            "/**/*.html",
                            "/**/*.css",
                            "/**/*.js",
                            "/webSocket/**"
                    ).permitAll()
                    // 工作流
                    .antMatchers("/workflow/**").permitAll()
                    // swagger 文档
                    .antMatchers("/swagger-ui.html").permitAll()
                    .antMatchers("/swagger-resources/**").permitAll()
                    .antMatchers("/webjars/**").permitAll()
                    .antMatchers("/*/api-docs").permitAll()
                    // 文件
                    .antMatchers("/avatar/**").permitAll()
                    .antMatchers("/file/**").permitAll()
                    // 阿里巴巴 druid
                    .antMatchers("/druid/**").permitAll()
                    // 放行OPTIONS请求
                    .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                    // 自定义匿名访问所有url放行:允许匿名和带Token访问,
                	// 细腻化到每个 Request 类型
                    // GET
                    .antMatchers(HttpMethod.GET, anonymousUrls
                                 .get(RequestMethodEnum.GET.getType())
                                 .toArray(new String[0])).permitAll()
                    // POST
                    .antMatchers(HttpMethod.POST, anonymousUrls
                                 .get(RequestMethodEnum.POST.getType())
                                 .toArray(new String[0])).permitAll()
                    // PUT
                    .antMatchers(HttpMethod.PUT, anonymousUrls
                                 .get(RequestMethodEnum.PUT.getType())
                                 .toArray(new String[0])).permitAll()
                    // PATCH
                    .antMatchers(HttpMethod.PATCH, anonymousUrls
                                 .get(RequestMethodEnum.PATCH.getType())
                                 .toArray(new String[0])).permitAll()
                    // DELETE
                    .antMatchers(HttpMethod.DELETE, anonymousUrls
                                 .get(RequestMethodEnum.DELETE.getType())
                                 .toArray(new String[0])).permitAll()
                    // 所有类型的接口都放行
                    .antMatchers(anonymousUrls
                                 .get(RequestMethodEnum.ALL.getType())
                                 .toArray(new String[0])).permitAll()
                    // 所有请求都需要认证
                    .anyRequest().authenticated()
                    .and().apply(securityConfigurerAdapter());
        }
    }