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.authc
和 org.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的,第五个是 Shiro
的 ShiroFilterFactoryBean
,它的内部也维护了一个 filters
,用来保存 Shiro
内置的一些过滤器和我们自定义的过滤器, Tomcat
所维护的 filters
和 Shiro
维护的 filters
是一个父子层级的关系 , Shiro
中的 ShiroFilterFactoryBean
仅仅只是 Tomcat
里 filters
中的一员。点开看 ShiroFilterFactoryBean
查看,果然 Shiro
内置的一些过滤器全都按顺序排着呢,我们自定义的 AuthLoginFilter
在最后一个。
但是,再看看 Tomcat
中的第六个过滤器,居然也是我们自定义的 AuthLoginFilter
,它同时出现在 Tomcat
和 Shiro
的 filters
中,这样也就造成了前面提到的问题, Shiro
在匹配到 anon
之后确实会将请求放行,但是在外层 Tomcat
的 Filter
中依旧被匹配上了,造成的现象好像是 Shiro
的 Filter
配置规则失效了,其实这个问题跟 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()
方法中,然后调用了 beanFactory
的 getBeanNamesForType()
,默认的实现在 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接管的 Bean
, isTypeMatch
方法很长,这里就不贴了,有兴趣的可以自行去看看,它位于 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); } }
前面两个配置过 Filter
和 Servlet
的应该很熟悉, 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系列博客项目源代码地址: