Search code examples
spring-securityspring-bootcsrfthymeleaf

Spring Boot + Security + Thymeleaf and CSRF token not injected automatically


Disclaimer: I know how to inject the token in a form with thymeleaf manually with this:

<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />

The goal of this post is to improve the knowledge of the platform and get a better understanding of what's going on inside Spring Boot

I haven't tried Spring Boot, but recently i just decided to give it a try, and have to admit its awesome, but with Thymeleaf and Security on Spring MVC, i didn't need to inject CSRF token on forms (POST), because Thymeleaf took care of it automatically, but now in Spring Boot for some reason it doesn't.

From the Spring Boot Reference, i found a list of the common properties used on application.properties file, and the ones related to thymeleaf and security are:

Thymeleaf Properties

spring.thymeleaf.check-template-location=true
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.excluded-view-names= # comma-separated list of view names that should be excluded from resolution
spring.thymeleaf.view-names= # comma-separated list of view names that can be resolved
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML5
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.content-type=text/html # ;charset=<encoding> is added
spring.thymeleaf.cache=true # set to false for hot refresh

Security Properties

security.user.name=user # login username
security.user.password= # login password
security.user.role=USER # role assigned to the user
security.require-ssl=false # advanced settings ...
security.enable-csrf=false
security.basic.enabled=true
security.basic.realm=Spring
security.basic.path= # /**
security.basic.authorize-mode= # ROLE, AUTHENTICATED, NONE
security.filter-order=0
security.headers.xss=false
security.headers.cache=false
security.headers.frame=false
security.headers.content-type=false
security.headers.hsts=all # none / domain / all
security.sessions=stateless # always / never / if_required / stateless
security.ignored= # Comma-separated list of paths to exclude from the     default secured paths

But if the solution to make Thymeleaf inject the token again is there, i fail to see it.

Edit: adding my configuration

The project was created using the initializer that was shipped in the last STS version (which in my opinion is awesome), with Web, Thymeleaf, Security, JPA, MySQL, H2, Mail, Facebook, Twitter, LinkedIn and Actuator items checked, and added some extras afterwards

Using Java 7 and Tomcat 7 because i intend to deploy the project on Openshift in a near future, and next there are my config files:

pom.xml

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.2.3.RELEASE</version>
    <relativePath/>
</parent>
<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <start-class>com.adrisasws.springmvc.WebApplication</start-class>
    <java.version>1.7</java.version>
    <tomcat.version>7.0.59</tomcat.version>
</properties>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>io.spring.platform</groupId>
            <artifactId>platform-bom</artifactId>
            <version>1.1.2.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependencies>
    <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.thymeleaf.extras</groupId>
        <artifactId>thymeleaf-extras-springsecurity3</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-mail</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.social</groupId>
        <artifactId>spring-social-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-social-facebook</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-social-linkedin</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-social-twitter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.social</groupId>
        <artifactId>spring-social-google</artifactId>
        <version>1.0.0.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-tomcat</artifactId>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>
<profiles>
    <profile>
        <id>openshift</id>
        <build>
            <finalName>webapp</finalName>
            <plugins>
                <plugin>
                    <artifactId>maven-war-plugin</artifactId>
                    <version>2.1.1</version>
                    <configuration>
                        <outputDirectory>webapps</outputDirectory>
                        <warName>ROOT</warName>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

Security config (exactly the same security file i'm using in a non-boot project in which the CSRF token actually gets injected automatically)

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    //////////////////////////////////////////////////////////////////////////
    //                              DEPENDENCIES                            //
    //////////////////////////////////////////////////////////////////////////
    
    @Autowired private DataSource dataSource;
    @Autowired private UserRepository userRepository;


    //////////////////////////////////////////////////////////////////////////
    //                               PROPERTIES                             //
    //////////////////////////////////////////////////////////////////////////
    
    @Value("${custom.security.rememberme-secret}")  private String secret;
    @Value("${custom.security.rememberme-create-tables}") private String createTables;

    private final static String[] adminRequests = new String[] { ... some matchers here... };
    private final static String[] userRequests = new String[] { ... some matchers here... };
    private final static String[] publicRequests = new String[] { ...some matchers here... };

    
    //////////////////////////////////////////////////////////////////////////
    //                              AUTHORIZATION                           //
    //////////////////////////////////////////////////////////////////////////
    
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/css/**", "/images/**", "/js/**", "/error**");
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers(adminRequests).access("hasRole('"+Role.ADMIN.toString()+"')")
                .antMatchers(userRequests).access("hasRole('"+Role.USER.toString()+"')")
                .antMatchers(publicRequests).permitAll()
                .anyRequest().authenticated()
                .and()
            .requiresChannel()
                .anyRequest().requiresSecure()
                .and()
            .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/", false)
                .permitAll()
                .and()
            .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .permitAll()
                .and()
            .rememberMe()
                .rememberMeServices(rememberMeService())
                .and()
            .apply(new SpringSocialConfigurer());
    }
    
    
    //////////////////////////////////////////////////////////////////////////
    //                              AUTHENTICATION                          //
    //////////////////////////////////////////////////////////////////////////

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .userDetailsService(userDetailsService())
            .passwordEncoder(bCryptPasswordEncoder());
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder(11);
    }
    
    @Bean
    public UserDetailsService userDetailsService() {
        return new UserRepositoryUserDetailsService(userRepository);
    }
    
    @Bean
    public SocialUserDetailsService socialUserDetailsService() {
        return new UserRepositorySocialUserDetailsService(userDetailsService());
    }
    
    
    //////////////////////////////////////////////////////////////////////////
    //                               REMEMBER ME                            //
    //////////////////////////////////////////////////////////////////////////
    
    @Bean
    public JdbcTokenRepositoryImpl jdbcTokenRepository() {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        jdbcTokenRepository.setCreateTableOnStartup(Boolean.valueOf(createTables));
        return jdbcTokenRepository; 
    }

    @Bean
    public RememberMeAuthenticationProvider rememberMeAuthenticationProvider() {
        return new RememberMeAuthenticationProvider(secret);
    }

    @Bean 
    public PersistentTokenBasedRememberMeServices rememberMeService() {
        PersistentTokenBasedRememberMeServices service = 
                new PersistentTokenBasedRememberMeServices(secret, userDetailsService(), jdbcTokenRepository());
        service.setUseSecureCookie(true);
        service.setParameter("rememberme");
        service.setTokenValiditySeconds(AbstractRememberMeServices.TWO_WEEKS_S);
        return service;
    }

    @Bean
    public RememberMeAuthenticationFilter authenticationFilter() throws Exception {
        return new RememberMeAuthenticationFilter(authenticationManager(), rememberMeService());
    }
}

in my spring boot config at the moment related to thymeleaf, and for development purposes

spring.thymeleaf.cache=false

and thymeleaf templates look like this (my login page at the moment, will include only the relevant content for the sake of clarity)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
    xmlns:sec="http://www.thymeleaf.org/extras/spring-security/"
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    layout:decorator="thymeleaf/layouts/default">
<head>
    ... css and meta tags ...
</head>
<body>
        ... some html ...
        <th:block sec:authorize="isAnonymous()">
        <!-- Bad Credentials -->
        <div th:if="${param.error}" class="alert alert-danger text-center">
            Invalid username and/or password.
        </div>
        <!-- Logout -->
        <div th:if="${param.logout}" class="alert alert-success text-center">
            You have been logged out.
        </div>
        
        <!-- Login Form -->
        <form id="f" th:action="@{/login}" method="post" role="form" autocomplete="off">
            <!-- Username -->       
            <input type="text" class="form-control text-center" id="username" name="username" th:placeholder="#{form.login.username}" />
            <!-- Password -->
            <input type="password" class="form-control text-center" id="password" name="password" th:placeholder="#{form.login.password}" />
            <!-- Remember me -->
            <input type="checkbox" id="rememberme" name="rememberme" />
            <!-- Submit -->
            <button type="submit" class="btn btn-primary" th:utext="#{form.login.submit}">Login</button>
            <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
        </form>
        ... more html and javascript ...
</body>
</html>

Edit2 - after doing some debugging in the direction Faraj Farook pointed, i found out that, in a project with the configuration i posted, in the Spring Boot version, in this class org.thymeleaf.spring4.requestdata.RequestDataValueProcessor4Delegate, the following function returns a null processor

public Map<String, String> getExtraHiddenFields(
        final RequestContext requestContext, final HttpServletRequest request) {

    final RequestDataValueProcessor processor = requestContext.getRequestDataValueProcessor();
    if (processor == null) {
        return null;
    }

    return processor.getExtraHiddenFields(request);

}

whereas the non Spring boot version, it returns a processor which is an instance of org.springframework.security.web.servlet.support.csrf.CsrfRequestDataValueProcessor.


Solution

  • According to the Thymeleaf developers, RequestDataValueProcessor interface is used by Thymeleaf to find the extra hidden fields which is automatically added to the form post back.

    The below code in org/thymeleaf/spring3/processor/attr/SpringActionAttrProcessor.java shows this.

     final Map<String,String> extraHiddenFields =
                        RequestDataValueProcessorUtils.getExtraHiddenFields(arguments.getConfiguration(), arguments);
    

    To sort the issue, and automatically add the CSRF Token; In your application create a custom request data value processor and register it with spring. To do this, you may go through the tutorial below.

    Csrf Defense in Spring-MVC

    I also suggest you to check your previous spring MVC code without the spring boot, to confirm that project's configuration XML has a custom made RequestDataValueProcessor or not.