I recently had to add a pure HTML login form to an existing Spring Boot application, integrating with Spring Security. All the top posts in Google mentioned how to do it with Thymeleaf, but nothing with just pure HTML. It took me about three hours of reading documentation and trail and error to get it.

I followed the official Spring IO Guide to get started. I created a simple Spring Boot app at Spring Initializr configured with Spring Web, Thymeleaf and Spring Security. My pom.xml ended up looking like this:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.4.3</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.example</groupId>
	<artifactId>demo</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>demo</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>11</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</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-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity5</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

The first thing I did was edit the @SpringBootApplication class file to disable the provided security settings so I could customize my own.

package ca.hendriks.logindemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;

@SpringBootApplication(exclude = { SecurityAutoConfiguration.class })
public class LoginApplication {

	public static void main(String[] args) {
		SpringApplication.run(LoginApplication.class, args);
	}

}

Configure app for a static HTML login form

The next step was to create the Spring Security configuration. This is where the login form is specified.

package ca.hendriks.logindemo;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class LoginConfiguration extends WebSecurityConfigurerAdapter {

    @Override  
    public void configure(HttpSecurity http) throws Exception {  
    	configureLogin(http);
    }  

    private void configureLogin(HttpSecurity http) throws Exception {
        http  
        .csrf().disable()
        .authorizeRequests()  
        .anyRequest().authenticated()  
        .and()
        .formLogin()
        	.loginPage("/html_login.html")
        	.loginProcessingUrl("/doLogin")
        	.failureUrl("/login-error.html")
        	.defaultSuccessUrl("/welcome", true)
        	.permitAll();
	}

    @Override  
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {  
        auth.inMemoryAuthentication()  
            .withUser("user")  
            .password("{noop}pass")  
            .roles("USER");  
    }  
    
}

The overridden configure method just sets up a username and password to login with. Obviously in a real system, you would do something actually useful.

There’s a lot going on in there, so let’s break it down.

.csrf().disable()

From what I can tell, Spring’s Cross-Site Request Forgery solution is to add a nonce to the login page. Smart. But I can’t use it with a pure, static HTML solution, so I’ve disabled it.

.loginPage("/html_login.html")

When Spring decides it’s time to ask for a user’s name and password, it forwards to this URL. This is where our pure, static HTML file with the simple login form will go. Default is “/login”.

.loginProcessingUrl("/doLogin")

This is the URL Spring will expect the username + password parameters to be found (either GET or POST). Default is “/login”, but I found debugging easier using a unique URL.

.failureUrl("/login-error.html")

By default, Spring will redirect to the loginPage and append "?error" onto the URL. This means you’ll need to add Javascript to detect this and display an error message to the user. Instead, I opted to just go to a different page with a hard-coded error message. No Javascript required.

.defaultSuccessUrl("/welcome", true)

This is obviously where Spring forwards to after a successful login. What’s isn’t obvious is the boolean parameter. true tells Spring to forget where the user was before and force a forward to the specified page.

That’s it! Finally we can add our two HTML pages into the “src/main/resources/static” directory. And test our work.

/src/main/resources/static/html_login.html

<html>
<head></head>
<body>
    <div>
        <form name="f" action="/doLogin" method="post">
            <fieldset>
                <legend>Please Login</legend>
                <label for="username">Username</label>
                <input type="text" id="username" name="username"/>        
                <label for="password">Password</label>
                <input type="password" id="password" name="password"/>    
                <div class="form-actions">
                    <button type="submit" class="btn">Log in</button>
                </div>
            </fieldset>
        </form>
    </div>
  </body>
</html>

/src/main/resources/static/login-error.html

<html>
    <body>
        <p>
            DENIED<br/>
            <a href="/">Try again</a>
        </p>
    </body>
</html>

Running the Spring Boot application and pointing my web browser to http://localhost:8080/ :

Static HTML login form
Static HTML login error page
Successful login

Great success! But I don’t really like the hardcoded error message, nor disabling CSRF. I decided to try the Thymeleaf version and let management see the benefit for themselves.

Configure app for a dynamic HTML (Thymeleaf template) login form

First, we’ll need to tweak our custom LoginConfiguration class:

    private void configureLogin(HttpSecurity http) throws Exception {
        http  
        .authorizeRequests()  
        .anyRequest().authenticated()  
        .and()
        .formLogin()
        	.loginPage("/login")
        	.loginProcessingUrl("/doLogin")
        	.defaultSuccessUrl("/welcome", true)
        	.permitAll();
	}

The loginPage is set to the default “/login”. But we’re going to intercept this call using a Spring Web @Controller to inject error messages and whatnot. We’ll need to add another class:

package ca.hendriks.logindemo;

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class LoginController {

    @RequestMapping("/welcome")  
    @ResponseBody  
    public String index() {  
    	String time = LocalTime.now().format(DateTimeFormatter.ISO_LOCAL_TIME);
        return "Hello, world! Time is " + time;  
    }  

	@RequestMapping(value = "/login", method = RequestMethod.GET)
    public String thymeLeafLogin(Model model, String error, String logout) {
        if (error != null)
            model.addAttribute("errorMsg", "Your username and password are invalid.");

        if (logout != null)
            model.addAttribute("msg", "You have been logged out successfully.");

        return "thyme_login.html";
    }
	
}

The “/login” @RequestMapping we just wrote will inspect the Model for error messages and pass them on to the Thymeleaf template for display. The method ends by returning our destination – the Thymeleaf template file, which must be created in the templates directory of the class path.

/src/main/resources/templates/thyme_login.html

<html xmlns:th="https://www.thymeleaf.org">
<head></head>
<body>
    <div th:fragment="content">
        <form name="f" th:action="@{/doLogin}" method="post">               
            <fieldset>
                <legend>Please Login</legend>
                <div th:if="${param.error}" class="alert alert-error">    
                    Invalid username and password.
                </div>
                <div th:if="${param.logout}" class="alert alert-success"> 
                    You have been logged out.
                </div>
                <label for="username">Username</label>
                <input type="text" id="username" name="username"/>        
                <label for="password">Password</label>
                <input type="password" id="password" name="password"/>    
                <div class="form-actions">
                    <button type="submit" class="btn">Log in</button>
                </div>
            </fieldset>
        </form>
    </div>
  </body>
</html>

Running the Spring Boot application and pointing my web browser to http://localhost:8080/ :

Dynamic HTML (Thymeleaf template) login form
Dynamic HTML (Thymeleaf template) login error
Successful login

As the Spring article mentions, CSRF is also needs to logout, so you will have to use another Thymleaf template with a form to submit the logout request.

I feel that Thymeleaf is definitely the better way to go, but make sure you create the template file in the {CLASSPATH}/templates directory.