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:
<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 ofUsernamePasswordAuthenticationFilter
to handle the extra domain field.SimpleUserDetailsService
: An implementation ofUserDetailsService
to load user details based on the username and domain.User
: An extension of theUser
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.
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:
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.
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.
@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
.
<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 ofUsernamePasswordAuthenticationFilter
to handle the extra domain field.CustomUserDetailsService
: A custom interface defining theloadUserByUsernameAndDomain
CustomAuthenticationFilter
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.
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.
@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
@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
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
.
@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
- Custom Authentication Filter: Extending the
UsernamePasswordAuthenticationFilter
to handle additional fields or custom authentication logic. - Custom UserDetailsService: Implementing the
UserDetailsService
interface to load user details based on custom criteria, such as additional fields or data sources. - Custom AuthenticationProvider: Implementing the
AuthenticationProvider
interface to handle custom authentication logic, validation, and user retrieval. - Custom AuthenticationToken: Extending the
UsernamePasswordAuthenticationToken
or creating a custom token to include additional fields or data. - Spring Security Configuration: Configuring Spring Security to wire up custom components, set up filter chains, and define security rules.
- 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.