Code Review Videos > Java > Form-Based Login in Spring Boot with Spring Security

Form-Based Login in Spring Boot with Spring Security

In this blog post, we’re going to explore what seems like a fairly straightforward process at first glance: adding basic form login to a Spring Boot app.

However, as with most things in the Spring Boot and Spring Security ecosystem, it’s rarely as simple as it sounds.

There are several hurdles we will encounter on your way to getting things working smoothly, and plenty of opportunities to learn some fun and interesting things along the way.

What We Will Cover In This Post

In this post, we will walk through adding form-based login to a Spring Boot application. Here’s what we’ll cover:

  1. Project Setup:
    Using Spring Initializr, we’ll create a project, starting with Spring Security disabled, enabling it later.
  2. Creating a Controller:
    We’ll build a simple controller to handle public and private pages.
  3. Manual Testing:
    We’ll manually test the public and private pages to confirm basic functionality before introducing security.
  4. Adding Automated Tests:
    We’ll add basic tests to ensure the application functions correctly, even after enabling Spring Security.
  5. Enabling Spring Security:
    We’ll enable Spring Security to restrict access to pages, add a login form, and protect the app.
  6. Custom Security Configuration:
    We will configure Spring Security to control user authentication and manage access to our pages.
  7. Testing Security Setup:
    We’ll revisit automated tests and add new ones to verify login, logout, and access control work as expected.

By the end, we will have a secure Spring Boot app with a working login system and automated tests.

Project Setup

Our first step is to head over to the Spring Initializr website to set up our project.

This part is pretty straightforward and something we’ve covered in previous Spring Boot-related posts. For today, we’re starting off with four key dependencies:

  1. Spring Boot DevTools – for hot reloading during development.
  2. Spring Web – so we can handle MVC and web-related functionality.
  3. Thymeleaf – since this is a form login tutorial, and we’ll be rendering the login form and other pages as HTML.
  4. Spring Security – to manage authentication and security.

However, even though our Spring Initializr project will include Spring Security, we’ll start with it commented out. The reason for this is that Spring Security immediately starts interacting with your application once imported, and we don’t want that just yet. We’ll enable it later when we’re ready to deal with its behaviour.

With that in mind, here’s what our pom.xml file looks like:

<?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>3.3.4</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>uk.co.a6software</groupId>
	<artifactId>spring-security-form-login-example</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>spring-security-form-login-example</name>
	<description>Spring Security Form Login Example </description>
	<url/>
	<licenses>
		<license/>
	</licenses>
	<developers>
		<developer/>
	</developers>
	<scm>
		<connection/>
		<developerConnection/>
		<tag/>
		<url/>
	</scm>
	<properties>
		<java.version>21</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-springsecurity6</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</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>
Code language: HTML, XML (xml)

Important: If copying along, I’ve had very mixed results with commenting things out in my pom.xml file. It is far more reliable to simply delete the lines initially, do the install, and then add them back in later.

Creating the Controller

Next up, we move on to perhaps the easiest and most fun part of this activity: creating a controller.

We’ll use a single controller for all of our actions today. Of course, you’re free to split this into multiple controllers, but for the sake of simplicity and brevity, we’ll keep everything in one controller.

package uk.co.a6software.spring_security_form_login_example.controllers;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class PageController {

    @GetMapping("/public")
    public String publicHome() {
        return "public";
    }

    @GetMapping("/private")
    public String privateHome() {
        return "private";
    }
}Code language: Java (java)

To start with, we’ll create two basic controller actions that will display a public and a private HTML page. These pages are as simple as their names suggest:

  • Public HTML – This page will be accessible to everyone, whether they are logged in or not.
  • Private HTML – This page will only be accessible to users who are logged in.

The public and private pages will serve as our initial test cases for securing specific sections of the application using Spring Security.

Alongside the controller, we’ll also create some HTML templates.

<!-- /src/main/resources/templates/public.html -->
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Welcome!</title>
</head>
<body>
    <h1>Welcome</h1>
    <p>This page is publicly available.</p>
    <p>Click to <a href="/private">visit a private page</a>.</p>
    <p>Click to <a href="/login">visit the login page</a>.</p>
    <p>This is a GET request and won't work. Click to <a th:href="@{/logout}">visit the logout page</a>.</p>
    <p>Examine the HTML for the button below, and see that Spring Security has injected a CSRF token. This is also a POST request.</p>
    <form th:action="@{/logout}" method="post">
        <input type="submit" value="Logout"/>
    </form>
</body>
</html>Code language: HTML, XML (xml)

And:

<!-- /src/main/resources/templates/private.html -->
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Private</title>
</head>
<body>
<h1>Private</h1>
<p>This page is only available once successfully logged in.</p>
<p>Click to <a href="/public">visit a public page</a>.</p>
<p>Click to <a href="/login">visit the login page</a>.</p>
<p>Click to <a th:href="@{/logout}">visit the logout page</a>.</p>
<form th:action="@{/logout}" method="post">
    <input type="submit" value="Logout"/>
</form>
</body>
</html>Code language: HTML, XML (xml)

It’s practically the same content. Just demo stuff.

Manually Testing the Pages

At this point, it’s a good idea to test that these two pages actually work in the browser.

Start up the project, navigate to localhost:8080, and visit /public and /private.

You should see our super exciting (and minimally styled) HTML pages displayed in all their glory.

This confirms that the basic setup for routing is functioning as expected before we add any security layers.

Adding Automated Tests

The thing is, we wouldn’t be good developers if we didn’t ensure that our code works as expected in a repeatable and reliable way. After all, you don’t want to manually check those two pages every single time you make a change anywhere on the website.

That’s where our automated test suite comes in, allowing us to verify functionality with ease and confidence, without the need for repetitive manual testing.

Initial Tests

We’re going to add a couple of tests here. One of them is going to fail until we introduce and properly configure Spring Security, but that’s okay for now.

Here are the initial tests:

package uk.co.a6software.spring_security_form_login_example.controllers;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@SpringBootTest
@AutoConfigureMockMvc
class PageControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void shouldAllowAnyVisitorToAccessPublicPage() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/public"))
                .andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
                .andExpect(MockMvcResultMatchers.view().name("public"));
    }Code language: Java (java)

Our first test will check that we can access /public, that we receive a 200 status, and, interestingly, that the correct view (public) is being requested.

Now, this was something suggested to me by IntelliSense in IntelliJ, and I thought it was an interesting approach. We’re rendering the view, and as long as the view name matches, we can be reasonably confident we’re rendering the correct page.

That said, I have mixed feelings about relying solely on the view name. While it’s good enough for this project, in a real-world scenario, I’d probably want more assurance that the rendered template actually contains the content I expect. So, for more robust testing, you might consider adding assertions that verify the page content as well.

    @Test
    public void shouldNotAllowAnyLoggedOutVisitorToAccessPrivatePage() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/private"))
                .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
                .andExpect(MockMvcResultMatchers.redirectedUrlPattern("**/login"));
    }Code language: Java (java)

The other test we’ll add is for ensuring that a visitor who isn’t logged in gets redirected when trying to access the /private page.

Specifically, we expect a 300-level status code, likely a 302, indicating that the visitor is being redirected to the login page.

Since /private is intended to be a secure page, this behaviour is exactly what we want.

As we currently do not have any Spring Security configuration, we get a test failure here.

The test checking that the /public page is accessible passes. But right now we haven’t locked down the /private page in any way.

Enabling Spring Security

Now that we’ve got our test suite in place, it’s time to add the Spring Security dependency back into our pom.xml file.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
         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>3.3.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>uk.co.a6software</groupId>
    <artifactId>spring-security-form-login-example</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-security-form-login-example</name>
    <description>Spring Security Form Login Example</description>
    <url/>
    <licenses>
        <license/>
    </licenses>
    <developers>
        <developer/>
    </developers>
    <scm>
        <connection/>
        <developerConnection/>
        <tag/>
        <url/>
    </scm>
    <properties>
        <java.version>21</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-springsecurity6</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </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>

Code language: HTML, XML (xml)

As I mentioned earlier, once these two dependencies are added and the project is restarted, both the /public and /private pages will no longer be accessible.

Spring Security will immediately start enforcing security rules across the application, which is exactly what we want at this stage.

The screenshot above shows what now happens if we try to visit either of the /public or /private pages.

We visit /public in this case, and get 302 redirected to /login.

Note that Spring Security has provided the /login page, along with the Bootstrap styled form.

Thinking back to our unit tests, at this point, I expected the second test to pass.

Since we’ve effectively locked down the entire application, I figured the first test (which was previously passing) would now start failing, and the second test (which was already failing due to the lack of redirection to the login form) would start passing.

But, to my surprise, it doesn’t, and I can’t quite figure out why this test isn’t passing in the default state. If you understand why this is, please do leave a comment and help me better understand what is (or isn’t) happening here.

Having A Little Play

We’ve got two failing tests, but we do actually have a functioning application at this point.

If you fire up the browser and try to access any page on the site—whether it’s a real page like /public or a fake one like /AAAAA—as we saw above, you’ll immediately be redirected to a login form.

This login form is styled using Bootstrap, and it’s not the most aesthetically pleasing thing. Perhaps we’d want to customise this (spoiler alert: we will later on), but for now, it does the job.

You can log in, and if you enter the wrong username or password, it provides a helpful prompt.

Try to log in with any nonsense:

Note the URL here.

On redirect, it got updated with a query parameter: http://0.0.0.0:8080/login?error

It is the presence of the query parameter that conditionally shows the “Bad credentials” error message.

What’s nice is that it gives a generic error message generically telling the user that either their username or password combination was incorrect. This is a good security feature. You might be surprised—though perhaps not—that many web applications out there reveal whether it was the username or the password that was incorrect. This is a security risk because it allows attackers to know if the username they supplied is valid, making it easier for them to brute-force the correct password.

Testing A Valid Login

If you check the terminal window when the Spring Boot app started, you’ll notice that it has generated a default password for you.

You can use that password, along with the default username user, to log in via the form.

When you first visited the site, your browser will have received a cookie with the name of JSESSIONID, and some assigned value.

Once you’ve logged in with valid credentials, you’ll receive an updated cookie with the same name (JSESSIONID), allowing you to access both pages /public and /private:

You can manually delete the cookie, refresh the page and you will be kicked out of the app.

Testing Log Out

Another nice feature is the built-in logout functionality. After logging in and doing whatever you need, when you log out, you’ll notice that the URL changes to /login?logout. The presence of that logout query parameter triggers a little overlay on the login form, informing you that you’ve successfully signed out.

I think this is a really nice touch—simple, but effective. What’s great is that you get this functionality completely for free, without needing to write any extra code.

And again, this is important, because we’ll revisit this feature later on when we customise the login and logout experience. It would be nice to keep that conditional “You have been signed out” messaging.

Examining Log Out

Curious as to why I’ve added in two different Logout links in the template?

<!-- /src/main/resources/templates/public.html -->
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta content="width=device-width, initial-scale=1" name="viewport">
    <title>Welcome!</title>
</head>
<body>
<h1>Welcome</h1>
<p>This page is publicly available.</p>
<p>Click to <a href="/private">visit a private page</a>.</p>
<p>Click to <a href="/login">visit the login page</a>.</p>
<p>This is a GET request and won't work. Click to <a th:href="@{/logout}">visit the logout page</a>.</p>
<p>Examine the HTML for the button below, and see that Spring Security has injected a CSRF token. This is also a POST
    request.</p>
<form method="post" th:action="@{/logout}">
    <input type="submit" value="Logout"/>
</form>
</body>
</html>
Code language: HTML, XML (xml)

You’ll notice there are two different ways to handle logout: one is the “naive” way, and the other is the correct way.

The naive approach tries to send a GET request to /logout, but this won’t work because Spring Security requires a CSRF token for logout requests. Simply sending a GET request will result in an error.

Instead, Spring Security expects you to submit a POST request with a CSRF token. To achieve this, you’ll need to use a form with the Thymeleaf action set to /logout. Spring Security will automatically inject the CSRF token into the form for you, allowing the logout flow to work as expected.

I’ve left both methods in the code to demonstrate that one works, and the other doesn’t. And I’ll admit, like many others, I initially tried it the wrong way too!

At this stage, we can manually validate that the default Spring Security configuration is in place and working, which is great because it’s secure by default.

However, some of these defaults aren’t exactly intuitive, and we’ll likely need to tweak the configuration to better suit our needs.

Custom Spring Security Configuration

Instead of worrying about testing that default behaviour, we’re going to move on to providing our custom Spring Security configuration.

While this configuration doesn’t involve a lot of code—just 48 lines including comments (or 39 without)—there’s a lot happening behind the scenes.

Much like in a previous post where we explored a simple way to log in using JSON, I mentioned that the best approach is often to take a look at the Spring Security Examples project and try to find a security configuration that’s close to, if not identical to, what you’re trying to implement.

So, for this configuration, here’s a link to the one it’s based on: the form login example.

package uk.co.a6software.spring_security_form_login_example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(authorizeRequests ->
                        authorizeRequests
                                // explicitly allow the /public page for everyone
                                .requestMatchers("/public").permitAll()
                                // but everything else requires authentication (by default)
                                .anyRequest().authenticated()
                )
                .formLogin(formLogin ->
                        formLogin
                                // so we must explicitly allow access to /login for everyone
                                .loginPage("/login").permitAll()
                                // otherwise this would default redirect to /
                                .defaultSuccessUrl("/private", true)
                )
                .logout(logout ->
                        logout
                                // again, need to explicitly set AND allow access to this url
                                // for everyone
                                .logoutSuccessUrl("/login?logout").permitAll())
                .build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        // for config, see https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/in-memory.html
        // for deprecation warning, see https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/core/userdetails/User.html
        User.UserBuilder users = User.withDefaultPasswordEncoder();
        UserDetails user = users.username("billy").password("password").roles("USER").build();
        return new InMemoryUserDetailsManager(user);
    }
}
Code language: Java (java)

Be absolutely sure to get the @Configuration annotation added on line 12, or none of this is going to work. This annotation marks the class as being configuration for our app, and will be inspected for @Beans that further setup our desired settings.

I should state that I didn’t just know to add this stuff in.

This comes from reading books, following examples, and reading the online docs. Also I am far from an expert on this, I’m merely sharing what I learn as I go.

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {Code language: Java (java)

Our first bean defines the security config for incoming HTTP requests. Note that Spring is injecting the HttpSecurity http instance for us as an argument when it calls the method.

Note that the method definition states that we will return a SecurityFilterChain instance.

This happens because of:

    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                // all the other stuff
                .build();
    }Code language: Java (java)

It’s that final .build() call that converts all the other method calls in-between into a properly configured object of that type.

http
                .authorizeHttpRequests(authorizeRequests ->
                        authorizeRequests
                                // explicitly allow the /public page for everyone
                                .requestMatchers("/public").permitAll()
                                // but everything else requires authentication (by default)
                                .anyRequest().authenticated()
                )Code language: Java (java)

Here we configure what HTTP requests will be authorised. As the comments say, we will allow everyone to access /public, but all other URLs will require a logged in user.

This is pretty important to understand – because it does mean everything. Right now we will have locked down everything. CSS files, JavaScript files, images … everything.

Anything we do want to make publicly available, we need to explicitly say so.

Also note I use the authorizeHttpRequests method, not authorizeRequests.

Lots of examples I have seen use authorizeRequests, but that method is deprecated:

This is one of the biggest challenges I have with Spring. It’s so long-lived (I don’t want to say old) that as it has evolved there is lots of left over, yet still supported approaches. Knowing which is the most preferable to use is an on-going challenge.

                .formLogin(formLogin ->
                        formLogin
                                // so we must explicitly allow access to /login for everyone
                                .loginPage("/login").permitAll()
                                // otherwise this would default redirect to /
                                .defaultSuccessUrl("/private", true)
                )Code language: Java (java)

Next, we’ll set up form login by overriding the default login page that Spring Security provides.

We specify that the login page will be available at /login.

Crucially we add .permitAll() to ensure everyone can access the login page.

Once logged in, we’ll set it to redirect users to /private.

                .logout(logout ->
                        logout
                                // again, need to explicitly set AND allow access to this url
                                // for everyone
                                .logoutSuccessUrl("/login?logout").permitAll())Code language: Java (java)

To preserve Spring Security’s nice default logout behaviour, we’ll configure the logout success URL to /login?logout, which will display a message indicating that the user has been logged out.

As with the login page, we’ll add .permitAll() to ensure users can access this page after logging out. Otherwise, once a user logs out, they will be blocked from accessing the /login?logout page because they’re no longer authenticated, which would lead to a catch-22 situation. Without this, users would be unable to even access the logout confirmation page—bad times indeed.

Additionally, we need to handle CSRF protection on logout. Spring Security requires a POST request with a CSRF token for logout to work, so we set up a form that submits the logout request with the necessary token.

Creating A Custom User Account

We’re also going to set up a custom user. There are many, many ways to achieve this it would seem.

We’re keeping things basic. As basic as possible, actually.

This means we’ll need to use the in-memory UserDetailsManager.

To be honest, I had to revisit the documentation for this because I had forgotten the specifics, even though I originally read about it in the Spring Security in Action book. Fortunately, the Spring documentation and example code provides a clear guide for setting this up.

    @Bean
    public UserDetailsService userDetailsService() {
        // for config, see https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/in-memory.html
        // for deprecation warning, see https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/core/userdetails/User.html
        User.UserBuilder users = User.withDefaultPasswordEncoder();
        UserDetails user = users.username("billy").password("password").roles("USER").build();
        return new InMemoryUserDetailsManager(user);
    }Code language: Java (java)

Now, you might notice a deprecation warning when using this approach. However, if you dig into what the deprecation is actually saying, it’s clear that they aren’t planning to remove this functionality. Instead, it’s deprecated to caution you against using it in a production or enterprise environment, where it could potentially cause issues. For our purposes, though, this is perfectly fine since we’re far from production.

Lastly, don’t forget to annotate this method with @Bean to ensure it’s managed by Spring.

Testing The Tests

With the custom SecurityConfiguration in place, our previously created automated tests should now both be passing.

It’s a small victory, but it gives us more confidence moving forwards.

It would be nice to set up a couple more tests for Login and Logout. We will do that shortly.

Customising The Log In Form View

We’ve done the hard work of setting up Spring Security to use our custom log in and log out flow.

Now we must add in a controller action to render our /login page.

First, we’ll add a third controller action inside our PagesController to handle the login page. This involves adding a @GetMapping("/login") that returns the login.html template path:

package uk.co.a6software.spring_security_form_login_example.controllers;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class PageController {
    @GetMapping("/")
    public String index() {
        return "redirect:/public";
    }

    @GetMapping("/login")
    String login() {
        return "login";
    }

    @GetMapping("/public")
    public String publicHome() {
        return "public";
    }

    @GetMapping("/private")
    public String privateHome() {
        return "private";
    }
}
Code language: Java (java)

I’ve added in a simple redirect, should someone visit the site root (/) then they get forwarded on to the /public page instead.

We’ll also need to create the login.html template itself.

I got the basic structure for this from the Spring documentation (scroll to the bottom of the page), and you can customise it as you like.

<!-- /src/main/resources/templates/login.html -->
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
    <title>Please Log In</title>
</head>
<body>
<h1>Please Log In</h1>
<div th:if="${param.error}">
    Invalid username and password.</div>
<div th:if="${param.logout}">
    You have been logged out.</div>
<form th:action="@{/login}" method="post">
    <div>
        <label>
            <input type="text" name="username" placeholder="Username"/>
        </label>
    </div>
    <div>
        <label>
            <input type="password" name="password" placeholder="Password"/>
        </label>
    </div>
    <input type="submit" value="Log in" />
</form>
</body>
</html>Code language: HTML, XML (xml)

The key parts of this template are the Thymeleaf conditional blocks, which will display error messages if the login attempt fails, and a logout confirmation message when the user is redirected back to the page after logging out. These conditions help provide a more interactive and user-friendly login experience.

Stop and restart your app, and re-visit the /login page. If you thought it looked bad before, you are in for a real treat:

It may not look like much, but it is ours.

Updating The Automated Tests

Now let’s return to the more involved task of making our tests a bit more robust. At this point, our first two tests should be in a good state:

  1. We should be able to log in successfully.
  2. We should be able to access /public without logging in.
  3. We should verify that accessing /private while not logged in redirects us to the login form.

These tests will confirm that the login flow and public/private page access work as expected with our custom Spring Security configuration in place.

There are two more tests we need to add.

The third test will verify that if you’re logged in and try to access /private, the request should be successful. Similar to the /public test we added earlier, we’ll check for a 200 status code and ensure that the view returned by the controller is private.

The fourth and final test for today is to verify that when a user successfully logs out, they are redirected back to /login?logout. I thought this would be a useful test because if you start tinkering with the security configuration, it’s easy to accidentally break this functionality.

Here’s the test code in full:

package uk.co.a6software.spring_security_form_login_example.controllers;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@SpringBootTest
@AutoConfigureMockMvc
class PageControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void shouldAllowAnyVisitorToAccessPublicPage() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/public"))
                .andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
                .andExpect(MockMvcResultMatchers.view().name("public"));
    }

    @Test
    public void shouldNotAllowAnyLoggedOutVisitorToAccessPrivatePage() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/private"))
                .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
                .andExpect(MockMvcResultMatchers.redirectedUrlPattern("**/login"));
    }

    @Test
    @WithMockUser(username = "user")
    public void shouldAllowLoggedInUserToAccessPrivatePage() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/private"))
                .andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
                .andExpect(MockMvcResultMatchers.view().name("private"));
    }

    @Test
    @WithMockUser(username = "user")
    public void shouldRedirectBackToLoginAfterLogout() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/logout")
                        .with(SecurityMockMvcRequestPostProcessors.csrf()))
                .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
                .andExpect(MockMvcResultMatchers.redirectedUrl("/login?logout"));

    }
}
Code language: Java (java)

Let’s finish of by breaking these two tests down further.

    @Test
    @WithMockUser(username = "user")
    public void shouldAllowLoggedInUserToAccessPrivatePage() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/private"))
                .andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
                .andExpect(MockMvcResultMatchers.view().name("private"));
    }Code language: Java (java)

This one wasn’t too hard to write.

It’s practically identical to the other tests, and we already learned about and used @WithMockUser last time.

The second test I found much harder:

    @Test
    @WithMockUser(username = "user")
    public void shouldRedirectBackToLoginAfterLogout() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/logout")
                        .with(SecurityMockMvcRequestPostProcessors.csrf()))
                .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
                .andExpect(MockMvcResultMatchers.redirectedUrl("/login?logout"));

    }
Code language: Java (java)

The critical part of this test is mocking the CSRF flow.

As we saw earlier when manually testing the site, simply sending a GET request for logout won’t work.

In this case, we’re mocking a POST request, and it’s essential to ensure that the CSRF token is included in the request for the test to pass.

Without properly mocking the CSRF token, the logout process will fail, just as it would in a real scenario without the correct token in place.

I stumbled on this one for a good long while and in the end had to ask ChatGPT how to solve it. To me that always feels like I’ve failed. But it did give me a working solution, so there is that.

Wrapping Up

Spring Security is a powerful, flexible framework, but it comes with its complexities.

This post demonstrated how to set up a basic yet functional security flow, allowing users to log in, access protected resources, and log out, all while maintaining security best practices like CSRF protection.

The tests we’ve written should give you a solid starting point for ensuring your security configuration works as intended.

That said, it’s easy to see how quickly things can become more complicated as you dive deeper into custom configurations.

Reading the online documentation, a good chunk of Laurentiu Spilca’s book, working through a lot of the Spring Academy and, when possible finding existing examples all helped massively with this task.

Example Code

You can see the code over on GitHub.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.