Shiro权限管理框架(五):自定义Filter实现及其问题排查记录

明确需求

在使用 Shiro 的时候,鉴权失败一般都是返回一个错误页或者登录页给前端,特别是后台系统,这种模式用的特别多。但是现在的项目越来越多的趋向于使用前后端分离的方式开发,这时候就需要响应 Json 数据给前端了,前端再根据状态码做相应的操作。那么Shiro框架能不能在鉴权失败的时候直接返回 Json 数据呢?答案当然是可以。

其实 Shiro 的自定义过滤器功能特别强大,可以实现很多实用的功能,向前端返回 Json 数据自然不在话下。通常我们没有去关注它是因为 Shiro 内置的一下过滤器功能已经比较全了,后台系统的权限控制基本上只需要使用 Shiro 内置的一些过滤器就能实现了,此处再次贴上这个图。

相关文档地址:http://shiro.apache.org/web.html#default-filters

我最近的一个项目是需要为手机APP提供功能接口,需要做用户登录, Session 持久化以及 Session 共享,但不需要细粒度的权限控制。面对这个需求我第一个想到的就是集成 Shiro 了, Session 的持久化及共享在 Shiro 系列第二篇已经讲过了,那么这篇顺便用一下 Shiro 中的自定义过滤器。因为不需要提供细粒度权限控制,只需要做登录鉴权,而且鉴权失败后需要向前端响应 Json 数据,那么使用自定义 Filter 再好不过了。

自定义Filter

还是以第一篇的Demo为例,项目地址在文章尾部有放上,本篇在之前的代码上继续添加功能。

首发地址: https://www.guitu18.com/post/2020/01/06/64.html

在实现自定义Filter之前,我们先看看这个类: org.apache.shiro.web.filter.AccessControlFilter ,点开它的子类,发现子类全部都是 org.apache.shiro.web.filter.authcorg.apache.shiro.web.filter.authz 这两个包下的,大多都继承了 AccessControlFilter 这个类。这些子类的类名是不是很眼熟,看上面那张我贴了三遍的图,大部分都在这里面呢。

看来 AccessControlFilter 这个类是跟Shiro权限过滤密切相关的,那么先看看它的体系结构:

它的顶级父类是 javax.servlet.Filter ,前面我们也说过, Shiro中所有的权限过滤都是基于 Filter 来实现的 。自定义 Filter 同样需要实现 AccessControlFilter ,这里我们添加一个登录验证过滤器,代码如下:

public class AuthLoginFilter extends AccessControlFilter {
    // 未登录登陆返状态回码
    private int code;
    // 未登录登陆返提示信息
    private String message;
    public AuthLoginFilter(int code, String message) {
        this.code = code;
        this.message = message;
    }
    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse,
                                      Object mappedValue) throws Exception {
        Subject subject = SecurityUtils.getSubject();
        // 这里配合APP需求我只需要做登录检测即可
        if (subject != null && subject.isAuthenticated()) {
            // TODO 登录检测通过,这里可以添加一些自定义操作
            return Boolean.TRUE;
        }
        // 登录检测失败返货False后会进入下面的onAccessDenied()方法
        return Boolean.FALSE;
    }
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, 
                                     ServletResponse servletResponse) throws Exception {
        PrintWriter out = null;
        try {
            // 这里就很简单了,向Response中写入Json响应数据,需要声明ContentType及编码格式
            servletResponse.setCharacterEncoding("UTF-8");
            servletResponse.setContentType("application/json; charset=utf-8");
            out = servletResponse.getWriter();
            out.write(JSONObject.toJSONString(R.error(code, message)));
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (out != null) {
                out.close();
            }
        }
        return Boolean.FALSE;
    }
}

自定义过滤器写好了,现在需要把它交给Shiro管理:

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
    ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
    shiroFilterFactoryBean.setSecurityManager(securityManager);
    // 添加登录过滤器
    Map filters = new LinkedHashMap();
    // 这里注释的一行是我这次踩的一个小坑,我一开始按下面这么配置产生一个我意料之外的问题
    // filters.put("authLogin", authLoginFilter());
    // 正确的配置是需要我们自己new出来,不能将这个Filter交给Spring管理,后面会说明
    filters.put("authLogin", new AuthLoginFilter(500, "未登录或登录超时"));
    shiroFilterFactoryBean.setFilters(filters);
    // 设置过滤规则
    Map filterMap = new LinkedHashMap();
    filterMap.put("/api/login", "anon");
    filterMap.put("/api/**", "authLogin");
    shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap);
    return shiroFilterFactoryBean;
}

如此Shiro添加自定义过滤器就完成了。自定义的 Filter 可以添加多个以实现不同的需求,你仅仅需要在 filters 中将过滤器起好名字 put 进去,并在 filterChainMap 中添加过滤器别名和路径的映射就可以使用这个过滤器了。需要注意的一点就是 过滤器是从前往后顺序匹配 的,所以要把范围大的路径放在后面 put 进去。

到这里自定义Filter功能已经实现了,后面是采坑排查记录,不感兴趣可以跳过。

问题排查

前半段介绍了如何使用 Shiro 的自定义 Filter 功能实现过滤,在 Shiro 配置代码中我提了一句这次配置踩的一个小坑,如果我们将自定义的Filter交给Spring管理,会产生一些意料之外的问题。确实,通常在Spring项目中做配置时,我们都默认将Bean交由Spring管理,一般不会有什么问题,但是这次不一样,先看代码如下:

public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
    ...
    filters.put("authLogin", authLoginFilter());
    ...
    filterMap.put("/api/login", "anon");
    filterMap.put("/api/**", "authLogin");
    ...
}
@Bean
public AuthLoginFilter authLoginFilter() {
    return new AuthLoginFilter(500, "未登录或登录超时");
}

这样配置后造成的现象是: 无论前面的过滤器是否放行,最终都会走到自定义的 AuthLoginFilter 过滤器

比如上面的配置,我们访问 /api/login 正常来讲会被 anon 匹配到 AnonymousFilter 中,这里是什么都没做直接放行的,但是放行后还会继续走到 AuthLoginFilter 中,怎么会这样,说好的按顺序匹配呢,怎么不按套路出牌。

打断点一路往上追溯,我们找到了 ApplicationFilterChain 这里,它是 Tomcat 所实现的一个 Java Servlet API 的规范。所有的请求都必须通过 filters 里的过滤器层层过滤后才会调用 Servlet 中的方法 service() 方法。这里包括Spring中的各种过滤器,全部都是注册到这里来的。

前面的四个Filter都是Spring的,第五个是 ShiroShiroFilterFactoryBean ,它的内部也维护了一个 filters ,用来保存 Shiro 内置的一些过滤器和我们自定义的过滤器, Tomcat 所维护的 filtersShiro 维护的 filters 是一个父子层级的关系 Shiro 中的 ShiroFilterFactoryBean 仅仅只是 Tomcatfilters 中的一员。点开看 ShiroFilterFactoryBean 查看,果然 Shiro 内置的一些过滤器全都按顺序排着呢,我们自定义的 AuthLoginFilter 在最后一个。

但是,再看看 Tomcat 中的第六个过滤器,居然也是我们自定义的 AuthLoginFilter ,它同时出现在 TomcatShirofilters 中,这样也就造成了前面提到的问题, Shiro 在匹配到 anon 之后确实会将请求放行,但是在外层 TomcatFilter 中依旧被匹配上了,造成的现象好像是 ShiroFilter 配置规则失效了,其实这个问题跟 Shiro 并没有关系。

问题的根源找到了,想要解决这个问题必须找到这个自定义的 Filter 何时被添加到 Tomcat 的过滤器执行链中以及其原因。

追根溯源

关于这个问题我找到了 ServletContextInitializerBeans 这个类中,它在Spring启动时就会初始化,在它的构造方法中做了很多初始化相关的操作。至于这一系列初始化流程就不得不提 ServletContextInitializer 相关知识点了,关于它的内容完全可以另开一片博客细说了。先看看 ServletContextInitializerBeans 的构造方法:

@SafeVarargs
public ServletContextInitializerBeans(ListableBeanFactory beanFactory,
        Class... initializerTypes) {
    this.initializers = new LinkedMultiValueMap();
    this.initializerTypes = (initializerTypes.length != 0) ? Arrays.asList(initializerTypes)
            : Collections.singletonList(ServletContextInitializer.class);
    // 上面提到的Filter正是在这个方法开始一步步被添加到ApplicationFilterChain中的
    addServletContextInitializerBeans(beanFactory);
    addAdaptableBeans(beanFactory);
    List sortedInitializers = this.initializers.values().stream()
            .flatMap((value) -> value.stream().sorted(AnnotationAwareOrderComparator.INSTANCE))
            .collect(Collectors.toList());
    this.sortedList = Collections.unmodifiableList(sortedInitializers);
    logMappings(this.initializers);
}

上面提到的 ApplicationFilterChain 中的 Filter 正是在 addServletContextInitializerBeans(beanFactory) 这个方法开始一步步被添加到 Filters 中的,限于篇幅这里就看一下关键步骤。

private void addServletContextInitializerBeans(ListableBeanFactory beanFactory) {
    for (Class initializerType : this.initializerTypes) {
        for (Entry initializerBean : 
                // 这里根据type获取Bean列表并遍历
                getOrderedBeansOfType(beanFactory, initializerType)) {
            // 此处开始添加对应的ServletContextInitializer
            addServletContextInitializerBean(initializerBean.getKey(), initializerBean.getValue(), beanFactory);
        }
    }
}

addServletContextInitializerBeans(beanFactory) 一路走下去会到达 getOrderedBeansOfType() 方法中,然后调用了 beanFactorygetBeanNamesForType() ,默认的实现在 DefaultListableBeanFactory 中,这里所贴前后删减掉了无关代码:

private String[] doGetBeanNamesForType(ResolvableType type, boolean includeNonSingletons, boolean allowEagerInit) {
    List result = new ArrayList();
    // 检查所有的Bean
    for (String beanName : this.beanDefinitionNames) {
        // 当这个Bean名称没有定义为其他bean的别名时,才进行匹配
        if (!isAlias(beanName)) {
            RootBeanDefinition mbd = getMergedLocalBeanDefinition(beanName);
            // 检查Bean的完整性,检测是否是抽象类,是否懒加载等等属性
            if (!mbd.isAbstract() && (allowEagerInit || (mbd.hasBeanClass() || !mbd.isLazyInit() || 
                    isAllowEagerClassLoading()) && !requiresEagerInitForType(mbd.getFactoryBeanName()))) {
                // 匹配的Bean是否是FactoryBean,对于FactoryBean,需要匹配它创建的对象
                boolean isFactoryBean = isFactoryBean(beanName, mbd);
                BeanDefinitionHolder dbd = mbd.getDecoratedDefinition();
                // 这里也是做完整性检查
                boolean matchFound = (allowEagerInit || !isFactoryBean || (dbd != null && !mbd.isLazyInit())
                    || containsSingleton(beanName)) && (includeNonSingletons || 
                    (dbd != null ? mbd.isSingleton() : isSingleton(beanName))) && isTypeMatch(beanName, type);
                if (!matchFound && isFactoryBean) {
                    // 对于FactoryBean,接下来尝试匹配FactoryBean实例本身
                    beanName = FACTORY_BEAN_PREFIX + beanName;
                    matchFound = (includeNonSingletons || mbd.isSingleton()) && isTypeMatch(beanName, type);
                }
                if (matchFound) {
                    result.add(beanName);
                }
            }
        }
    }
    return StringUtils.toStringArray(result);
}

到这里就是关键所在了,它会根据目标类型调用 isTypeMatch(beanName, type) 匹配每一个被Spring接管的 BeanisTypeMatch 方法很长,这里就不贴了,有兴趣的可以自行去看看,它位于 AbstractBeanFactory 中。这里匹配的 type 就是 ServletContextInitializerBeans 遍历自构造方法中的 initializerTypes 列表。

doGetBeanNamesForType 出来后,再看这个方法:

private void addServletContextInitializerBean(String beanName,
        ServletContextInitializer initializer, ListableBeanFactory beanFactory) {
    if (initializer instanceof ServletRegistrationBean) {
        Servlet source = ((ServletRegistrationBean) initializer).getServlet();
        addServletContextInitializerBean(Servlet.class, beanName, initializer,
                beanFactory, source);
    }
    else if (initializer instanceof FilterRegistrationBean) {
        Filter source = ((FilterRegistrationBean) initializer).getFilter();
        addServletContextInitializerBean(Filter.class, beanName, initializer,
                beanFactory, source);
    }
    else if (initializer instanceof DelegatingFilterProxyRegistrationBean) {
        String source = ((DelegatingFilterProxyRegistrationBean) initializer)
                .getTargetBeanName();
        addServletContextInitializerBean(Filter.class, beanName, initializer,
                beanFactory, source);
    }
    else if (initializer instanceof ServletListenerRegistrationBean) {
        EventListener source = ((ServletListenerRegistrationBean) initializer)
                .getListener();
        addServletContextInitializerBean(EventListener.class, beanName, initializer,
                beanFactory, source);
    }
    else {
        addServletContextInitializerBean(ServletContextInitializer.class, beanName,
                initializer, beanFactory, initializer);
    }
}

前面两个配置过 FilterServlet 的应该很熟悉, Spring 中添加自定义 Filter 经常这么用,添加 Servlet 同理:

@Bean
public FilterRegistrationBean xssFilterRegistration() {
    FilterRegistrationBean registration = new FilterRegistrationBean();
    registration.setDispatcherTypes(DispatcherType.REQUEST);
    registration.setFilter(new XxxFilter());
    registration.addUrlPatterns("/*");
    registration.setName("xxxFilter");
    return registration;
}

这样 Spring 就会将其添加到过滤器执行链中,当然这只是添加 Filter 的众多方式之一。

解决方案

那么问题的根源找到了,被 Spring 接管的 Bean 中所有的 Filter 都会被添加到 ApplicationFilterChain ,那我不让 Spring 接管我的 AuthLoginFilter 不就行了。如何做?配置的时候直接 new 出来,还记得前面的那两行代码吗:

// 这里注释的一行是我这次踩的一个小坑,我一开始按下面这么配置产生了一个我意料之外的问题
// filters.put("authLogin", authLoginFilter());
// 正确的配置是需要我们自己new出来,不能将这个Filter交给Spring管理
filters.put("authLogin", new AuthLoginFilter(500, "未登录或登录超时"));

OK,问题解决,就是这么简单。但就是这么小小的一个问题,在不清楚问题产生的原因的情况下,根本想不到是 Spring 接管 Filter 造成的,了解了底层,才能更好的排查问题。

尾巴

  • Shiro 中自定义 Filter 仅需要继承 AccessControlFilter 类后实现参与过滤的两个方法,再将其配置到 ShiroFilterFactoryBean 中即可。
  • 需要注意的点是,因为 Spring 的初始化机制,我们自定义的 Filter 如果被 Spring 接管,那么会被 Spring 添加到 ApplicationFilterChain 中, 导致这个自定义过滤器会被重复执行 ,也就是无论 Shiro 中的过滤器过滤结果如何,最后依旧会走到被添加到 ApplicationFilterChain 中的自定义过滤器。
  • 解决这个问题的方法非常简单, 不让 Spring 接管我们的 Filter ,直接 new 出来配置到 Shiro 即可。
  • 码海无涯,不进则退,日积跬步,以至千里。

Shiro系列博客项目源代码地址:

Gitee: https://gitee.com/guitu18/ShiroDemo

GitHub: https://github.com/guitu18/ShiroDemo