Shiro authority management framework: custom Filter implementation and troubleshooting records

Keywords: Java Shiro Spring Tomcat JSON

Clear demand

When using Shiro, the authentication failure is usually to return an error page or login page to the front-end, especially the back-end system. This mode is particularly used. But now more and more projects tend to use the way of front-end and back-end separation for development. At this time, the front-end needs to respond to the Json data to the front-end, and then the front-end does the corresponding operations according to the status code. Can Shiro framework directly return Json data when authentication fails? The answer, of course, is yes.

In fact, Shiro's custom filter is very powerful, which can realize many practical functions. It's no surprise to return Json data to the front end. Usually we don't pay attention to it because the functions of the built-in filters in Shiro are quite complete. The permission control of the background system basically only needs to use some of the built-in filters in Shiro, and this figure is pasted here again.

Relevant document address: http://shiro.apache.org/web.html × default filters

My latest project is to provide a functional interface for mobile APP, which requires user login, session persistence and session sharing, but does not require fine-grained permission control. In the face of this requirement, the first thing I think about is to integrate Shiro. Session persistence and sharing have been mentioned in the second part of Shiro series. Then, I will use the custom Filter in Shiro by the way. Because you do not need to provide fine-grained permission control, you only need to do login authentication, and you need to respond to the Json data from the front end after authentication failure, so it is better to use a custom Filter.

Custom Filter

Take the Demo in the first article as an example. The project address is placed at the end of the article. This article continues to add functions in the previous code.

First address: https://www.guitu18.com/post/2020/01/06/64.html

Before implementing the user-defined Filter, let's first look at this class: org.apache.shiro.web.filter.AccessControlFilter. Click on its subclass, and find that all subclasses are org.apache.shiro.web.filter.authc and org.apache.shiro.web.filter.authz. Most of them inherit the AccessControlFilter class. Are the class names of these subclasses familiar? Look at the picture I posted three times above. Most of them are here.

It seems that the AccessControlFilter class is closely related to Shiro permission filtering. First look at its architecture:

Its top-level parent class is javax.servlet.Filter. As we said earlier, all permission filtering in Shiro is based on filter. The user-defined filter also needs to implement AccessControlFilter. Here we add a login verification filter, the code is as follows:

public class AuthLoginFilter extends AccessControlFilter {
    // Login return status return code not logged in
    private int code;
    // Login failed to return prompt message
    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();
        // I just need to do login detection to meet the needs of APP
        if (subject != null && subject.isAuthenticated()) {
            // TODO login detection passed, here you can add some custom operations
            return Boolean.TRUE;
        }
        // The following onAccessDenied() method will be entered after the failed login detection returns False
        return Boolean.FALSE;
    }
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, 
                                     ServletResponse servletResponse) throws Exception {
        PrintWriter out = null;
        try {
            // It's very simple here. To write Json Response data to Response, you need to declare ContentType and encoding format
            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;
    }
}

Now that the custom filter is written, you need to give it to Shiro for management:

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
    ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
    shiroFilterFactoryBean.setSecurityManager(securityManager);
    // Add login filter
    Map<String, Filter> filters = new LinkedHashMap<>();
    // The line commented here is a small pit I stepped in this time. I started to press the following configuration to generate an unexpected problem
    // filters.put("authLogin", authLoginFilter());
    // The correct configuration requires us to create a new Filter by ourselves. We can't give this Filter to Spring for management. It will be explained later
    filters.put("authLogin", new AuthLoginFilter(500, "No login or login timeout"));
    shiroFilterFactoryBean.setFilters(filters);
    // Set filter rules
    Map<String, String> filterMap = new LinkedHashMap<>();
    filterMap.put("/api/login", "anon");
    filterMap.put("/api/**", "authLogin");
    shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap);
    return shiroFilterFactoryBean;
}

This completes Shiro's addition of custom filters. You can add multiple customized filters to meet different requirements. You just need to put the Filter name in the filters and add the Filter alias and path mapping in the Filter chain map to use the Filter. One thing to note is that the filters are matched sequentially from front to back, so put a wide range of paths in the back.

Here, the user-defined Filter function has been implemented, followed by pit troubleshooting records, which can be skipped if not interested.

Troubleshooting

In the first half of the section, I introduced how to use Shiro's custom Filter function to implement filtering. In Shiro's configuration code, I mentioned a small pit stepped by this configuration. If we give the custom Filter to Spring management, there will be some unexpected problems. It's true that when we do configuration in Spring projects, by default, we leave beans to Spring for management. Generally, there won't be any problems, but this time it's different. First, look at the code as follows:

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, "No login or login timeout");
}

The phenomenon caused by this configuration is that no matter whether the previous filter is released or not, it will eventually go to the user-defined AuthLoginFilter.

For example, in the above configuration, when we visit / api/login, it will be matched by anon to AnonymousFilter. There is no direct release for anything here, but after the release, we will continue to go to AuthLoginFilter. How can we do this? How can we match in order? How can we not issue cards in a routine way.

Break the point and go all the way up. Here we find ApplicationFilterChain, which is the specification of a Java Servlet API implemented by Tomcat. All requests must be filtered through the filters layer by layer before calling the service() method in the Servlet. All kinds of filters in Spring are registered here.

The first four filters are Spring's, and the fifth is ShiroFilterFactoryBean of Shiro. It also maintains a Filter inside, which is used to save some of Shiro's built-in filters and our customized filters. The filters maintained by Tomcat and Shiro are parent-child relationships. ShiroFilterFactoryBean in Shiro is just the filters in Tomcat One member. Click ShiroFilterFactoryBean to check. Sure enough, some of Shiro's built-in filters are in order. Our customized AuthLoginFilter is in the last one.

However, looking at the sixth Filter in tomcat, it is also our custom AuthLoginFilter. It appears in both Tomcat and Shiro's filters, which causes the problems mentioned above. Shiro will indeed release the request after matching to anon, but it is still matched in the outer Tomcat's Filter, which causes the phenomenon that Shiro's Filter configuration The rule is invalid. In fact, this problem has nothing to do with Shiro.

The root cause of the problem is found. To solve this problem, you must find out when the custom Filter is added to Tomcat's Filter execution chain and why.

Find by hard and thorough search

On this issue, I found the class ServletContextInitializerBeans, which will be initialized when Spring starts, and did a lot of initialization related operations in its constructor. As for this series of initialization processes, we have to mention the knowledge points related to ServletContextInitializer. We can open another blog to elaborate on its contents. Let's first look at the construction method of ServletContextInitializerBeans:

@SafeVarargs
public ServletContextInitializerBeans(ListableBeanFactory beanFactory,
        Class<? extends ServletContextInitializer>... initializerTypes) {
    this.initializers = new LinkedMultiValueMap<>();
    this.initializerTypes = (initializerTypes.length != 0) ? Arrays.asList(initializerTypes)
            : Collections.singletonList(ServletContextInitializer.class);
    // The Filter mentioned above was added to the ApplicationFilterChain step by step at the beginning of this method
    addServletContextInitializerBeans(beanFactory);
    addAdaptableBeans(beanFactory);
    List<ServletContextInitializer> sortedInitializers = this.initializers.values().stream()
            .flatMap((value) -> value.stream().sorted(AnnotationAwareOrderComparator.INSTANCE))
            .collect(Collectors.toList());
    this.sortedList = Collections.unmodifiableList(sortedInitializers);
    logMappings(this.initializers);
}

The above mentioned Filter in ApplicationFilterChain is added to the Filters step by step at the beginning of addServletContextInitializerBeans(beanFactory). For the limited space, here are the key steps.

private void addServletContextInitializerBeans(ListableBeanFactory beanFactory) {
    for (Class<? extends ServletContextInitializer> initializerType : this.initializerTypes) {
        for (Entry<String, ? extends ServletContextInitializer> initializerBean : 
                // Here, get the Bean list according to the type and traverse
                getOrderedBeansOfType(beanFactory, initializerType)) {
            // Start to add the corresponding ServletContextInitializer here
            addServletContextInitializerBean(initializerBean.getKey(), initializerBean.getValue(), beanFactory);
        }
    }
}

addServletContextInitializerBeans(beanFactory) goes all the way to getOrderedBeansOfType() method, then calls beanFactory's getBeanNamesForType(), and the default implementation is in DefaultListableBeanFactory.

private String[] doGetBeanNamesForType(ResolvableType type, boolean includeNonSingletons, boolean allowEagerInit) {
    List<String> result = new ArrayList<>();
    // Check all beans
    for (String beanName : this.beanDefinitionNames) {
        // Match only when the bean name is not defined as another bean's alias
        if (!isAlias(beanName)) {
            RootBeanDefinition mbd = getMergedLocalBeanDefinition(beanName);
            // Check the integrity of the Bean, check whether it is an abstract class, whether it is lazy to load, etc
            if (!mbd.isAbstract() && (allowEagerInit || (mbd.hasBeanClass() || !mbd.isLazyInit() || 
                    isAllowEagerClassLoading()) && !requiresEagerInitForType(mbd.getFactoryBeanName()))) {
                // Whether the matching Bean is a FactoryBean? For a FactoryBean, you need to match the object it creates
                boolean isFactoryBean = isFactoryBean(beanName, mbd);
                BeanDefinitionHolder dbd = mbd.getDecoratedDefinition();
                // This is also a integrity check
                boolean matchFound = (allowEagerInit || !isFactoryBean || (dbd != null && !mbd.isLazyInit())
                    || containsSingleton(beanName)) && (includeNonSingletons || 
                    (dbd != null ? mbd.isSingleton() : isSingleton(beanName))) && isTypeMatch(beanName, type);
                if (!matchFound && isFactoryBean) {
                    // For FactoryBean, next try to match the FactoryBean instance itself
                    beanName = FACTORY_BEAN_PREFIX + beanName;
                    matchFound = (includeNonSingletons || mbd.isSingleton()) && isTypeMatch(beanName, type);
                }
                if (matchFound) {
                    result.add(beanName);
                }
            }
        }
    }
    return StringUtils.toStringArray(result);
}

This is the key point. It will call isTypeMatch(beanName, type) to match every Bean taken over by Spring according to the target type. The isTypeMatch method is very long, so it will not be pasted here. If you are interested, you can go and have a look. It is located in AbstractBeanFactory. The matching type here is the initializerTypes list in the ServletContextInitializerBeans traversal self construction method.

After you get out of doGetBeanNamesForType, look at this method:

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);
    }
}

You should be familiar with the first two configurations of Filter and Servlet. This is often the case when adding a custom Filter in Spring. Adding a Servlet is the same:

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

Spring will then add it to the Filter execution chain, which is just one of the many ways to add filters.

Solution

Then the root of the problem is found. All the filters in the Bean taken over by Spring will be added to the ApplicationFilterChain. Then I won't let Spring take over my AuthLoginFilter. How to do it? When configuring, use new directly. Remember the previous two lines of code:

// The line commented here is a small pit I stepped in this time. I started with the following configuration and had an unexpected problem
// filters.put("authLogin", authLoginFilter());
// The correct configuration requires our own new. We can't give this Filter to Spring for management
filters.put("authLogin", new AuthLoginFilter(500, "No login or login timeout"));

OK, problem solving, that's it. But it's such a small problem. When you don't know the cause of the problem, you can't imagine that Spring took over the Filter. Only when you understand the bottom layer can you better troubleshoot the problem.

tail

  • The user-defined Filter in Shiro only needs to inherit the AccessControlFilter class to implement the two methods participating in the filtering, and then configure it to ShiroFilterFactoryBean.
  • It should be noted that because of Spring's initialization mechanism, if our custom Filter is taken over by Spring, it will be added to the ApplicationFilterChain by Spring, resulting in the repeated execution of the custom Filter. That is to say, regardless of the Filter filtering results in Shiro, it will still go to the custom Filter added to the ApplicationFilterChain.
  • The solution to this problem is very simple. Don't let Spring take over our Filter. Just go to new and configure it in Shiro.
  • There is no limit to the size of the sea. If you don't advance, you will fall back. Every day you accumulate a small step, even a thousand miles.

Shiro series blog project source code address:

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

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

Posted by HairyArse on Tue, 07 Jan 2020 07:15:13 -0800