Search code examples
javaspring-bootshirospring-java-config

Apache Shiro JdbcRealm with JavaConfig and Spring Boot


I'm trying to configure my Spring Boot application to use Apache Shiro as its security framework. I have everything working with a PropertiesRealm, now I'm trying to get it working with a JdbcRealm and Spring Boot's built-in H2 database. Here's my dependencies in my pom.xml:

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>1.2.3</version>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.2.3</version>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-web</artifactId>
    <version>1.2.3</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
</dependency>

My schema.sql:

create table if not exists users (
  username varchar(256),
  password varchar(256),
  enabled boolean
);

create table if not exists user_roles (
  username varchar(256),
  role_name varchar(256)
);

My data.sql:

insert into users (username, password, enabled) values ('user', '04f8996da763b7a969b1028ee3007569eaf3a635486ddab211d512c85b9df8fb', true);

insert into user_roles (username, role_name) values ('user', 'guest');

And my WebSecurityConfig.java class that configures everything:

package security;

import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.crypto.hash.Sha256Hash;
import org.apache.shiro.realm.jdbc.JdbcRealm;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.filter.authc.AnonymousFilter;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.apache.shiro.web.filter.authc.LogoutFilter;
import org.apache.shiro.web.filter.authc.UserFilter;
import org.apache.shiro.web.filter.authz.RolesAuthorizationFilter;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.h2.server.web.WebServlet;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.embedded.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;

import javax.servlet.Filter;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class WebSecurityConfig {

    @Bean(name = "shiroFilter")
    public ShiroFilterFactoryBean shiroFilter() {
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        Map<String, String> filterChainDefinitionMapping = new HashMap<>();
        filterChainDefinitionMapping.put("/api/health", "authc,roles[guest],ssl[8443]");
        filterChainDefinitionMapping.put("/login", "authc");
        filterChainDefinitionMapping.put("/logout", "logout");
        shiroFilter.setFilterChainDefinitionMap(filterChainDefinitionMapping);
        shiroFilter.setSecurityManager(securityManager());
        shiroFilter.setLoginUrl("/login");
        Map<String, Filter> filters = new HashMap<>();
        filters.put("anon", new AnonymousFilter());
        filters.put("authc", new FormAuthenticationFilter());
        LogoutFilter logoutFilter = new LogoutFilter();
        logoutFilter.setRedirectUrl("/login?logout");
        filters.put("logout", logoutFilter);
        filters.put("roles", new RolesAuthorizationFilter());
        filters.put("user", new UserFilter());
        shiroFilter.setFilters(filters);
        return shiroFilter;
    }

    @Bean(name = "securityManager")
    public DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(jdbcRealm());
        return securityManager;
    }

    @Autowired
    private DataSource dataSource;

    @Bean(name = "realm")
    @DependsOn("lifecycleBeanPostProcessor")
    public JdbcRealm jdbcRealm() {
        JdbcRealm realm = new JdbcRealm();
        HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
        credentialsMatcher.setHashAlgorithmName(Sha256Hash.ALGORITHM_NAME);
        realm.setCredentialsMatcher(credentialsMatcher);
        realm.setDataSource(dataSource);
        realm.init();
        return realm;
    }

    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    @Bean
    public ServletRegistrationBean h2servletRegistration() {
        ServletRegistrationBean registration = new ServletRegistrationBean(new WebServlet());
        registration.addUrlMappings("/console/*");
        return registration;
    }
}

I'm not seeing any errors in my logs. I tried cranking up the logging my adding the following to my application.properties, but it doesn't help much.

logging.level.org.apache.shiro=debug

Thanks,

Matt


Solution

  • There are a couple problems that are happening.

    LifecycleBeanPostProcessor

    The problem is due to the fact that LifecycleBeanPostProcessor is defined in your config class. Since it is a BeanPostProcessor it must be initialized eagerly to process all other beans. Furthermore, the rest of WebSecurityConfig needs to be initialized eagerly since it may impact LifecycleBeanPostProcessor.

    The problem is that the autowired feature is not yet available because it is a BeanPostProcessor (i.e. AutowiredAnnotationBeanPostProcessor) too. This means the DataSource is null.

    Since it is null the JdbcRealm is going to throw a NullPointerException. This is in turn caught by AbstractAuthenticator and rethrown as an AuthenticationException. The DefaultWebSecurityManager (actually its parent DefaultSecurityManager) then catches it invokes onFailedLogin which removes the "remember me" cookie.

    Solving LifecycleBeanPostProcessor

    The easiest solution is to ensure any infrastructure related beans are defined with a static method. This informs Spring that it does not need to initialize the entire configuration class (i.e. WebSecurityConfig). Again

    @Bean
    public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }
    

    Alternatively, you can also isolate infrastructure related beans in their own configuration.

    UPDATE

    ShiroFilterFactoryBean

    I didn't realize that ShiroFilterFactoryBean implements BeanPostProcessor also. It is pretty interesting case for an ObjectFactory to also implement BeanPostProcessor.

    The problem is that this is preventing the loading of data.sql which means the application does not have any users in the table so authentication will fail.

    The issue is that data.sql is loaded via a DataSourceInitializedEvent. However, due to the eager initialization of the DataSource (it was a dependency of a BeanPostProcessor) the DataSourceInitializedEvent cannot be fired. This is why you see the following in the logs:

    Could not send event to complete DataSource initialization (ApplicationEventMulticaster not initialized)

    Ensuring data.sql Loads

    There are a few options that I see to get the insert statements to load.

    data.sql->schema.sql

    The easiest option is to move the contents of data.sql to schema.sql. The schema.sql is still loaded since it does not require an event to be fired to process it. The data.sql requires an event so that the same mechanism can be used to load data when JPA initializes the schema.

    Fixing the Ordering

    Unfortunately, you cannot simply make the definition for ShiroFilterFactoryBean static since it relies on other bean definitions. Fortunately, there really is no need for the BeanPostProcessor in this instance. This means you can change your code to return the result of the factory bean which removes the BeanPostProcessor from the equation:

    @Bean(name = "shiroFilter")
    public AbstractShiroFilter shiroFilter() throws Exception {
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        Map<String, String> filterChainDefinitionMapping = new HashMap<>();
        filterChainDefinitionMapping.put("/api/health", "authc,roles[guest],ssl[8443]");
        filterChainDefinitionMapping.put("/login", "authc");
        filterChainDefinitionMapping.put("/logout", "logout");
        shiroFilter.setFilterChainDefinitionMap(filterChainDefinitionMapping);
        shiroFilter.setSecurityManager(securityManager());
        shiroFilter.setLoginUrl("/login");
        Map<String, Filter> filters = new HashMap<>();
        filters.put("anon", new AnonymousFilter());
        filters.put("authc", new FormAuthenticationFilter());
        LogoutFilter logoutFilter = new LogoutFilter();
        logoutFilter.setRedirectUrl("/login?logout");
        filters.put("logout", logoutFilter);
        filters.put("roles", new RolesAuthorizationFilter());
        filters.put("user", new UserFilter());
        shiroFilter.setFilters(filters);
        return (AbstractShiroFilter) shiroFilter.getObject();
    }
    

    insert into user

    The insert statement found in data.sql is incorrect. It needs to include the enabled column. For example:

    insert into users values ('admin', '22f256eca1f336a97eef2b260773cb0d81d900c208ff26e94410d292d605fed8',true);