Introduction

In many enterprise applications, it’s common to require additional fields beyond just the username and password during the login process. For example, you might need to capture the user’s domain or any other custom field. Spring Security provides a flexible and extensible authentication framework that allows you to customize the login process to meet your specific requirements.

In this article, we’ll explore two approaches to add an extra “domain” field to the default Spring Security login form. The first approach will focus on reusing existing Spring Security components, while the second approach will involve creating custom classes for more advanced use cases.

Dependencies

Before we dive into the implementation details, let’s look at the required dependencies that need to be added to your pom.xml file:

XML
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.0</version>
    <!-- Use the latest version -->
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</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-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>org.thymeleaf.extras</groupId>
        <artifactId>thymeleaf-extras-springsecurity6</artifactId>
    </dependency>
</dependencies>

Approach 1: Reusing Existing Spring Security Components

In this approach, we’ll leverage the existing DaoAuthenticationProvider and UsernamePasswordAuthenticationToken classes provided by Spring Security. The key components involved are:

  • SimpleAuthenticationFilter: An extension of UsernamePasswordAuthenticationFilter to handle the extra domain field.
  • SimpleUserDetailsService: An implementation of UserDetailsService to load user details based on the username and domain.
  • User: An extension of the User class provided by Spring Security to include the domain field.
  • SecurityConfig: The Spring Security configuration class to wire up the required components and set up security rules.
  • login.html: The Thymeleaf template for the login page, including fields for username, password, and domain.

SimpleAuthenticationFilter

The SimpleAuthenticationFilter class extends UsernamePasswordAuthenticationFilter and overrides the attemptAuthentication method to handle the extra domain field. In this method, we concatenate the username and domain values, create a UsernamePasswordAuthenticationToken instance with the combined value as the principal, and pass it to the authentication manager for authentication.

Java
public class SimpleAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        UsernamePasswordAuthenticationToken authRequest = getAuthRequest(request);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    private UsernamePasswordAuthenticationToken getAuthRequest(HttpServletRequest request) {
        String username = obtainUsername(request);
        String password = obtainPassword(request);
        String domain = obtainDomain(request);

        String usernameDomain = String.format("%s%s%s", username.trim(), 
                               String.valueOf(Character.LINE_SEPARATOR), domain);
        return new UsernamePasswordAuthenticationToken(usernameDomain, password);
    }

    private String obtainDomain(HttpServletRequest request) {
        return request.getParameter("domain");
    }


}

User class:

Java
public class User extends org.springframework.security.core.userdetails.User {

    private final String domain;

    public User(String username, String password, String domain, 
                    Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);
        this.domain = domain;
    }

    public String getDomain() {
        return domain;
    }
}

SimpleUserDetailsService

The SimpleUserDetailsService implements the UserDetailsService interface and provides the loadUserByUsername method. In this method, we split the combined username and domain value, retrieve the User object from the user repository, and return it as the UserDetails instance.

Java
public class SimpleUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public SimpleUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) 
                      throws UsernameNotFoundException {
        String[] usernameAndDomain = StringUtils.split(username, String.valueOf(Character.LINE_SEPARATOR));
        if (usernameAndDomain == null || usernameAndDomain.length != 2) {
            throw new UsernameNotFoundException("Username and domain must be provided");
        }
        User user = userRepository.findUser(usernameAndDomain[0], usernameAndDomain[1]);
        if (user == null) {
            throw new UsernameNotFoundException(
                    String.format("Username not found for domain, username=%s, domain=%s", usernameAndDomain[0], usernameAndDomain[1]));
        }
        return user;
    }
}

SecurityConfig

In the SecurityConfig class, we configure Spring Security by adding the SimpleAuthenticationFilter before the default UsernamePasswordAuthenticationFilter in the filter chain. We also wire up the SimpleUserDetailsService with the DaoAuthenticationProvider and set up the necessary security rules.

Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public UserDetailsService userDetailsService() {
        return new SimpleUserDetailsService();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        return http.getSharedObject(AuthenticationManagerBuilder.class)
                .userDetailsService(userDetailsService())
                .passwordEncoder(passwordEncoder())
                .and()
                .build();
    }

    @Bean
    public SimpleAuthenticationFilter authenticationFilter(AuthenticationManager authenticationManager) 
              throws Exception {
        SimpleAuthenticationFilter filter = new SimpleAuthenticationFilter();
        filter.setAuthenticationManager(authenticationManager);
        filter.setAuthenticationFailureHandler(failureHandler());
        return filter;
    }

    @Bean
    public AuthenticationFailureHandler failureHandler() {
        return new SimpleUrlAuthenticationFailureHandler();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .addFilterBefore(authenticationFilter(http.getSharedObject(AuthenticationManagerBuilder.class).build()),
                 UsernamePasswordAuthenticationFilter.class)
                .authorizeHttpRequests()
                .requestMatchers("/css/**", "/index").permitAll()
                .requestMatchers("/user/**").authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .and()
                .logout()
                .logoutUrl("/logout");
        return http.build();
    }
}

login.html

The login.html template includes input fields for username, password, and domain, which will be handled by the SimpleAuthenticationFilter.

HTML
<form class="form-signin" th:action="@{/login}" method="post">
    <h2 class="form-signin-heading">Please sign in</h2>
    <p>Example: user / domain / password</p>
    <p th:if="${param.error}" class="error">Invalid user, password, or domain</p>
    <p>
        <label for="username" class="sr-only">Username</label>
        <input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus/>
    </p>
    <p>
        <label for="domain" class="sr-only">Domain</label>
        <input type="text" id="domain" name="domain" class="form-control" placeholder="Domain" required autofocus/>
    </p>
    <p>
        <label for="password" class="sr-only">Password</label>
        <input type="password" id="password" name="password" class="form-control" placeholder="Password" required autofocus/>
    </p>
    <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button><br/>
    <p><a href="/index" th:href="@{/index}">Back to home page</a></p>
</form>

Approach 2: Custom Implementation

The second approach involves creating custom classes for more advanced use cases. The key components are:

  • CustomAuthenticationFilter: An extension of UsernamePasswordAuthenticationFilter to handle the extra domain field.
  • CustomUserDetailsService: A custom interface defining the loadUserByUsernameAndDomain

CustomAuthenticationFilter

Java
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public static final String SPRING_SECURITY_FORM_DOMAIN_KEY = "domain";

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        CustomAuthenticationToken authRequest = getAuthRequest(request);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    private CustomAuthenticationToken getAuthRequest(HttpServletRequest request) {
        String username = obtainUsername(request);
        String password = obtainPassword(request);
        String domain = obtainDomain(request);

        return new CustomAuthenticationToken(username, password, domain);
    }

    private String obtainDomain(HttpServletRequest request) {
        return request.getParameter(SPRING_SECURITY_FORM_DOMAIN_KEY);
    }

    // other methods
}

The CustomAuthenticationFilter is similar to the SimpleAuthenticationFilter, but it creates a CustomAuthenticationToken instance with the username, password, and domain as separate fields.

CustomUserDeatilsService:

The CustomUserDetailsService interface defines a loadUserByUsernameAndDomain method that takes both the username and domain as parameters.

Java
public interface CustomUserDetailsService {
    UserDetails loadUserByUsernameAndDomain(String username, String domain) throws UsernameNotFoundException;
}

CustomUserDetailsServiceImpl:

The CustomUserDetailsServiceImpl implements the CustomUserDetailsService interface and retrieves the User object from the UserRepository based on the provided username and domain.

Java
@Service
public class CustomUserDetailsServiceImpl implements CustomUserDetailsService {

    private final UserRepository userRepository;

    @Autowired
    public CustomUserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsernameAndDomain(String username, String domain) throws UsernameNotFoundException {
        if (StringUtils.isAnyBlank(username, domain)) {
            throw new UsernameNotFoundException("Username and domain must be provided");
        }
        User user = userRepository.findUser(username, domain);
        if (user == null) {
            throw new UsernameNotFoundException(
                    String.format("Username not found for domain, username=%s, domain=%s", username, domain));
        }
        return user;
    }
}

CustomUserDetailsAuthenticationProvider

Java
@Component
public class CustomUserDetailsAuthenticationProvider implements AuthenticationProvider {

    private final CustomUserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Autowired
    public CustomUserDetailsAuthenticationProvider(CustomUserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        CustomAuthenticationToken auth = (CustomAuthenticationToken) authentication;
        UserDetails loadedUser;

        try {
            loadedUser = userDetailsService.loadUserByUsernameAndDomain(auth.getPrincipal().toString(), auth.getDomain());
        } catch (UsernameNotFoundException notFound) {
            if (authentication.getCredentials() != null) {
                String presentedPassword = authentication.getCredentials().toString();
                if (!passwordEncoder.matches(presentedPassword, userNotFoundEncodedPassword)) {
                    throw notFound;
                }
            }
            throw notFound;
        } catch (Exception repositoryProblem) {
            throw new InternalAuthenticationServiceException(repositoryProblem.getMessage(), repositoryProblem);
        }

        if (!passwordEncoder.matches(authentication.getCredentials().toString(), loadedUser.getPassword())) {
            throw new BadCredentialsException("Invalid password");
        }

        return createSuccessAuthentication(loadedUser);
    }

    private Authentication createSuccessAuthentication(UserDetails userDetails) {
        UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities());
        return auth;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return CustomAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

In the authenticate method, we first retrieve the UserDetails by calling the loadUserByUsernameAndDomain method of the CustomUserDetailsService. Then, we validate the provided password against the loaded user’s password using the PasswordEncoder. If the passwords match, we create a new UsernamePasswordAuthenticationToken with the loaded user details and return it as the successful authentication.

The createSuccessAuthentication method is a helper method that creates the UsernamePasswordAuthenticationToken with the loaded user details.

The supports method checks if the provided Authentication object is an instance of CustomAuthenticationToken.

CustomAuthenticationToken

Java
public class CustomAuthenticationToken extends UsernamePasswordAuthenticationToken {

    private final String domain;

    public CustomAuthenticationToken(Object principal, Object credentials, String domain) {
        super(principal, credentials);
        this.domain = domain;
    }

    public String getDomain() {
        return domain;
    }
}

The CustomAuthenticationToken extends the UsernamePasswordAuthenticationToken and includes the domain field.

SecurityConfig

The SecurityConfig class is already shown in the previous section, where we wire up the CustomUserDetailsAuthenticationProvider and configure the CustomAuthenticationFilter.

Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {


    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        return http.getSharedObject(AuthenticationManagerBuilder.class)
                .authenticationProvider(customAuthProvider())
                .build();
    }


    @Bean
    public CustomAuthenticationFilter authenticationFilter(AuthenticationManager authenticationManager) throws Exception {
        CustomAuthenticationFilter filter = new CustomAuthenticationFilter();
        filter.setAuthenticationManager(authenticationManager);
        filter.setAuthenticationFailureHandler(failureHandler());
        return filter;
    }

    @Bean
    public AuthenticationFailureHandler failureHandler() {
        return new SimpleUrlAuthenticationFailureHandler();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .addFilterBefore(authenticationFilter(http.getSharedObject(AuthenticationManagerBuilder.class).build()), UsernamePasswordAuthenticationFilter.class)
                .authorizeHttpRequests()
                .requestMatchers("/css/**", "/index").permitAll()
                .requestMatchers("/user/**").authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .and()
                .logout()
                .logoutUrl("/logout");
        return http.build();
    }
}

login.html

The login.html template remains the same as in the previous approach, including input fields for username, password, and domain.

Explanation

Both approaches demonstrate how to include an extra “domain” field in the Spring Security login process. The first approach reuses existing Spring Security components, while the second approach involves creating custom classes for more advanced use cases.

In the first approach, we extend the UsernamePasswordAuthenticationFilter and concatenate the username and domain values to create a UsernamePasswordAuthenticationToken. The SimpleUserDetailsService then splits the combined value and retrieves the User object from the repository.

In the second approach, we create a CustomAuthenticationToken that includes the domain field separately. The CustomUserDetailsService interface defines a loadUserByUsernameAndDomain method, and the CustomUserDetailsAuthenticationProvider implements the AuthenticationProvider interface to handle the custom authentication logic.

The SecurityConfig class is responsible for wiring up the required components and configuring the filter chain and security rules.

Both approaches allow you to capture an extra “domain” field during the login process and authenticate users based on the provided username, domain, and password.

Key Components

  1. Custom Authentication Filter: Extending the UsernamePasswordAuthenticationFilter to handle additional fields or custom authentication logic.
  2. Custom UserDetailsService: Implementing the UserDetailsService interface to load user details based on custom criteria, such as additional fields or data sources.
  3. Custom AuthenticationProvider: Implementing the AuthenticationProvider interface to handle custom authentication logic, validation, and user retrieval.
  4. Custom AuthenticationToken: Extending the UsernamePasswordAuthenticationToken or creating a custom token to include additional fields or data.
  5. Spring Security Configuration: Configuring Spring Security to wire up custom components, set up filter chains, and define security rules.
  6. Thymeleaf Integration: Using Thymeleaf templates to render the login form and handle additional input fields.

Conclusion:

In this article, we have explored two approaches to include an extra “domain” field in the Spring Security login process using the latest Spring Boot version. We covered the required dependencies, complete working code examples, explanations of the code, and relevant sub-topics.

By following these approaches, you can customize the Spring Security authentication process to meet your specific requirements. Features such as capturing additional fields or implementing custom authentication logic. The flexibility and extensibility of Spring Security allow you to adapt the framework to your application’s needs while maintaining robust security measures.

By |Last Updated: May 24th, 2024|Categories: Spring Security|