SpringBoot security second look: Authentication
Introduction
This time lets get hands-on spring security. Based on the previous post, there are two parts needed to setup a proper security measure for a website, SecurityFilterChain and UserDetailsManager.
SecurityFilterChain is a set of filters that determines if accessing a particular URL is allowed or not. UserDetailsManager, as the name suggests, performs CRUD actions regarding users.
hello-security
Checking out the example from hello-security-explicit
1 |
|
The code abocve shows:
To customize spring security configuration, add @EnableWebSecurity to inherit default spring-security configurations. Because we define our own SecurityFilterChain bean,
class DefaultWebSecurityCondition extends AllNestedConditions {}
from previous post will become false and hence defaultSecurityFilterChain in SpringBootWebSecurityConfiguration.java will not get created. However, here, if you look at the implementation of defaultSecurityFilterChain, it is the same thing from example code above.Because we define our own InMemoryUserDetailsManager, UserDetailsServiceAutoConfiguration will not be created because of @ConditionalOnMissingBean(value = {…, UserDetailsService.class})
The InMemoryUserDetailsManager is very similar to the default one in UserDetailsServiceAutoConfiguration.java. Here, I want to dive deeper in the password encoder. Based on the official docs, spring security requires each password to be prefixed using “{encoder_format}”. For example, a proper way to store a password should be “{noop}password” (no encoding) or “{bcrypt}password” {using bcrypt algorithm encoding}. The createDelegatingPasswordEncoder() method in org.springframework.security.crypto.factory.PasswordEncoderFactories returns a list of default password encoders used by spring security.
PasswordEncoderFactories.java 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256",
new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
return new DelegatingPasswordEncoder(encodingId, encoders);
}If you create a UserDetails without a password encoder, for example
UserDetails user = User.withUsername("user").password("password").build();
, Java will throwjava.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id “null”.
Custom defined User
In the real world application, it is often that our User will contain more domain knowledge, such as email, address etc. How to integrate this information is really important. Let’s take a look at the InMemoryUserDetailsManager class. The class implements two interfaces UserDetailsManager which in turn also implements UserDetailsService and UserDetailsPasswordService.
Code
1 | public interface UserDetailsPasswordService { |
To replicate what InMemoryUserDetailsManager has done (we don’t care about changing the password, so UserDetailsPasswordService is ignored).As an example, lets define a custom User class that uses Email as username for login. Lombok is used here. The code is similar to the User.class in org.springframework.security.core.userdetails.User
1 |
|
Here, we have a CustomUser class holding email and password, and repository storing lists of CustomUser. Now, to comply with spring security, we need a UserDetail class to specify the role/authority of our CustomUser.
1 |
|
The CustomUserDetail has no authority/role and is always valid. Here, instead of inheritance, I use composition. I feel that composition is easier to understand because it separates the user domain and the authority domain, the single responsibility principle.
I guess another way to make it more appropriate in turns of database design, is to make User class and UserDetail class standalone by themselves and have a third class joins them. But I guess that needs more experiments to see what works the best.
Enough with the sidetrack thought. Now, we need a service class that implements UserDetailsService.
1 |
|
On top, we initialized a repository that hold CustomUser login credential, noted that the CustomUserRepository Bean must be put under a class with @Configuration/@Document. Now if we run the application, we can use “user@mail.com” and “password” for login.
Conclusion
To configure custom information to login, you need to
- Obviously, define the class that contains the custom information, the class must either implement UserDetails interface (direct inheritance) or have a different class that implements UserDetails to include the class (composition).
- Define a repository class to store you information
- Define a service/manager class that implements UserDetailsService interface or UserDetailsManager interface. The spring’s underlying code will call the loadUserByUsername() method to look if the credential exists or not.
- The service/manager will use the repository to retrieve user by its key(username/email etc).
Until here, I have a question in my mind, where in code does spring security check the password?
Question: How is Authentication done in spring security?
This is the question I realize when I was writing the previous section. Because loadUserByUsername() method only returns a UserDetail object, where does the password comparison happened? To understand this problem, first we need to know how HTTP request is handled in Spring. So long story short, when the spring server receives an HTTP request, it will undergo a bunch of filters (called FilterChain), such as form character encoding filter, form value parsing filter etc., in ApplicationFilterChain.java in org.apache.catalina.core package.
DelegatingFilterProxy
The only thing we are interested here in the springSecurityFilterChain, which is a DelegatingFilterProxy class. You can consider DelegatingFilterProxy as a proxy to all SecurityFilterChains that have been configured. DelegatingFilterProxy was added by AbstractSecurityWebApplicationInitializer at the startup as part of the autoconfiguration in SecurityFilterAutoConfiguration.java
When DelegatingFilterProxy is processed, it is converted into a VirtualFilterChain that has the same behavior as ApplicationFilterChain, but is unique to spring security..
UsernamePasswordAuthenticationFilter
One of the filter here is UsernamePasswordAuthenticationFilter class in org.springframework.security.web.authentication that handles the form login authentication. UsernamePasswordAuthenticationFilter is added in FormLoginConfigurer.java in http.formLogin(withDefaults())
UsernamePasswordAuthenticationFilter does the following thing:
- Retrieve username and password from Http request and construct an Authentication object.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
- UsernamePasswordAuthenticationToken implicitly extends Authentication.class.
- Pass the Authentication object to the ProviderManager in org.springframework.security.authentication. Then the ProviderManager will choose one of the providers, in this case is the DaoAuthenticationProvider.java in org.springframework.security.authentication.dao.DaoAuthenticationProvider.
- DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider.java
- DaoAuthenticationProvider will call retrieveUser() (Line#10) that uses your loadUserByUsername() method to retrieve an UserDetails object. Then DaoAuthenticationProvider will use the 3 three checking methods from AbstractUserDetailsAuthenticationProvider:
- preAuthenticationChecks() (Line#22): checks for isAccountNonLocked(), isEnabled() and isAccountNonExpired().
- additionalAuthenticationChecks() (Line#23): real check happened here. For DaoAuthenticationProvider, it checks for password matches.
- postAuthenticationChecks() (Line#36) : checks for isCredentialsNonExpired().
After all these checks, an AuthenticationToken object is created and returned at the end.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported"));
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
}
- Once the AuthenticationToken is returned, there will be more operations such as cache or session storage etc. But I want to wail in the future to dig through more.
Other Filters
After UsernamePasswordAuthenticationFilter, we have DefaultLoginPageGeneratingFilter and DefaultLogoutPageGeneratingFilter. By the name, these two filters generate Spring provided login.html and logout.html when you try to access /login or /logout.
The last few filters deal with
Conclusion
I am starting to understand a bit how security filters work in spring security. I hope let’s get more and more hands-on the code next post.