This version is still in development and is not considered stable yet. For the latest stable version, please use Spring Security 6.5.5! |
Adaptive Authentication
Since authentication needs can vary from person-to-person and even from one login attempt to the next, Spring Security supports adapting authentication requirements to each situation.
Some of the most common applications of this principal are:
-
Re-authentication - Users need to provide authentication again in order to enter an area of elevated security
-
Multi-factor Authentication - Users need more than one authentication mechanism to pass in order to access secured resources
-
Authorizing More Scopes - Users are allowed to consent to a subset of scopes from an OAuth 2.0 Authorization Server. Then, if later on a scope that they did not grant is needed, consent can be re-requested for just that scope.
-
Opting-in to Stronger Authentication Mechanisms - Users may not be ready yet to start using MFA, but the application wants to allow the subset of security-minded users to opt-in.
-
Requiring Additional Steps for Suspicious Logins - The application may notice that the user’s IP address has changed, that they are behind a VPN, or some other consideration that requires additional verification
Re-authentication
The most common of these is re-authentication. Imagine an application configured in the following way:
-
Java
-
Kotlin
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
return http.build();
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
http {
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
formLogin { }
oneTimeTokenLogin { }
}
return http.build()
}
By default, this application has two authentication mechanisms that it allows, meaning that the user could use either one and be fully-authenticated.
If there is a set of endpoints that require a specific factor, we can specify that in authorizeHttpRequests
as follows:
-
Java
-
Kotlin
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/profile/**").hasAuthority(GrantedAuthorities.FACTOR_OTT_AUTHORITY) (1)
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
return http.build();
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
http {
authorizeHttpRequests {
authorize("/profile/**", hasAuthority(GrantedAuthorities.FACTOR_OTT_AUTHORITY)) (1)
authorize(anyRequest, authenticated)
}
formLogin { }
oneTimeTokenLogin { }
}
return http.build()
}
1 | - States that all /profile/** endpoints require one-time-token login to be authorized |
Given the above configuration, users can log in with any mechanism that you support. And, if they want to visit the profile page, then Spring Security will redirect them to the One-Time-Token Login page to obtain it.
In this way, the authority given to a user is directly proportional to the amount of proof given. This adaptive approach allows users to give only the proof needed to perform their intended operations.
Multi-Factor Authentication
You may require that all users require both One-Time-Token login and Username/Password login to access any part of your site.
To require both, you can state an authorization rule with anyRequest
like so:
-
Java
-
Kotlin
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().hasAllAuthorities(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, GrantedAuthorities.FACTOR_OTT_AUTHORITY) (1)
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
return http.build();
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
http {
authorizeHttpRequests {
authorize(anyRequest, hasAllAuthorities(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, GrantedAuthorities.FACTOR_OTT_AUTHORITY))
}
formLogin { }
oneTimeTokenLogin { }
}
return http.build()
}
1 | - This states that both FACTOR_PASSWORD and FACTOR_OTT are needed to use any part of the application |
Spring Security behind the scenes knows which endpoint to go to depending on which authority is missing. If the user logged in initially with their username and password, then Spring Security redirects to the One-Time-Token Login page. If the user logged in initially with a token, then Spring Security redirects to the Username/Password Login page.
Requiring MFA For All Endpoints
Specifying all authorities for each request pattern could be unwanted boilerplate:
-
Java
-
Kotlin
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/admin/**").hasAllAuthorities(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, GrantedAuthorities.FACTOR_OTT_AUTHORITY, "ROLE_ADMIN") (1)
.anyRequest().hasAllAuthorities(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, GrantedAuthorities.FACTOR_OTT_AUTHORITY)
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
return http.build();
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
http {
authorizeHttpRequests {
authorize("/admin/**", hasAllAuthorities(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, GrantedAuthorities.FACTOR_OTT_AUTHORITY, "ROLE_ADMIN")) (1)
authorize(anyRequest, hasAllAuthorities(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, GrantedAuthorities.FACTOR_OTT_AUTHORITY))
}
formLogin { }
oneTimeTokenLogin { }
}
return http.build()
}
1 | - Since all authorities need to be specified for each endpoint, deploying MFA in this way can create unwanted boilerplate |
This can be remedied by publishing an AuthorizationManagerFactory
bean like so:
-
Java
-
Kotlin
@Bean
AuthorizationManagerFactory<Object> authz() {
return DefaultAuthorizationManagerFactory.builder()
.requireAdditionalAuthorities(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, GrantedAuthorities.FACTOR_OTT_AUTHORITY).build();
}
@Bean
fun authz(): AuthorizationManagerFactory<Object> {
return DefaultAuthorizationManagerFactory.builder<Object>()
.requireAdditionalAuthorities(GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, GrantedAuthorities.FACTOR_OTT_AUTHORITY).build()
}
This yields a more familiar configuration:
-
Java
-
Kotlin
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
return http.build();
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
http {
authorizeHttpRequests {
authorize("/admin/**", hasRole("ADMIN"))
authorize(anyRequest, authenticated)
}
formLogin { }
oneTimeTokenLogin { }
}
return http.build()
}
@EnableGlobalMultiFactorAuthentication
You can simplify the configuration even further by using @EnableGlobalMultiFactorAuthentication
to create the AuthorizationManagerFactory
for you.
@EnableGlobalMultiFactorAuthentication(authorities = {
GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY,
GrantedAuthorities.FACTOR_OTT_AUTHORITY })
Authorizing More Scopes
You can also configure exception handling to direct Spring Security on how to obtain a missing scope.
Consider an application that requires a specific OAuth 2.0 scope for a given endpoint:
-
Java
-
Kotlin
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/profile/**").hasAuthority("SCOPE_profile:read")
.anyRequest().authenticated()
)
.x509(Customizer.withDefaults())
.oauth2Login(Customizer.withDefaults());
return http.build();
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
http {
authorizeHttpRequests {
authorize("/profile/**", hasAuthority("SCOPE_profile:read"))
authorize(anyRequest, authenticated)
}
x509 { }
oauth2Login { }
}
return http.build()
}
If this is also configured with an AuthorizationManagerFactory
bean like this one:
-
Java
-
Kotlin
@Bean
AuthorizationManagerFactory<Object> authz() {
return DefaultAuthorizationManagerFactory.builder()
.requireAdditionalAuthorities(GrantedAuthorities.FACTOR_X509_AUTHORITY, GrantedAuthorities.FACTOR_AUTHORIZATION_CODE_AUTHORITY)
.build();
}
@Bean
fun authz(): AuthorizationManagerFactory<Object> {
return DefaultAuthorizationManagerFactory.builder<Object>()
.requireAdditionalAuthorities(GrantedAuthorities.FACTOR_X509_AUTHORITY, GrantedAuthorities.FACTOR_AUTHORIZATION_CODE_AUTHORITY)
.build()
}
Then the application will require an X.509 certificate as well as authorization from an OAuth 2.0 authorization server.
In the event that the user does not consent to profile:read
, this application as it stands will issue a 403.
However, if you have a way for the application to re-ask for consent, then you can implement this in an AuthenticationEntryPoint
like the following:
-
Java
-
Kotlin
@Component
class ScopeRetrievingAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException, ServletException {
response.sendRedirect("https://authz.example.org/authorize?scope=profile:read");
}
}
@Component
internal class ScopeRetrievingAuthenticationEntryPoint : AuthenticationEntryPoint {
override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) {
response.sendRedirect("https://authz.example.org/authorize?scope=profile:read")
}
}
Then, your filter chain declaration can bind this entry point to the given authority like so:
-
Java
-
Kotlin
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, ScopeRetrievingAuthenticationEntryPoint oauth2) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/profile/**").hasAuthority("SCOPE_profile:read")
.anyRequest().authenticated()
)
.x509(Customizer.withDefaults())
.oauth2Login(Customizer.withDefaults())
.exceptionHandling((exceptions) -> exceptions
.defaultDeniedHandlerForMissingAuthority(oauth2, "SCOPE_profile:read")
);
return http.build();
}
@Bean
fun securityFilterChain(http: HttpSecurity, oauth2: ScopeRetrievingAuthenticationEntryPoint): DefaultSecurityFilterChain? {
http {
authorizeHttpRequests {
authorize("/profile/**", hasAuthority("SCOPE_profile:read"))
authorize(anyRequest, authenticated)
}
x509 { }
oauth2Login { }
}
http.exceptionHandling { e: ExceptionHandlingConfigurer<HttpSecurity> -> e
.defaultDeniedHandlerForMissingAuthority(oauth2, "SCOPE_profile:read")
}
return http.build()
}
Programmatically Decide Which Authorities Are Required
AuthorizationManager
is the core interface for making authorization decisions.
Consider an authorization manager that looks at the logged in user to decide which factors are necessary:
-
Java
-
Kotlin
@Component
class UserBasedOttAuthorizationManager implements AuthorizationManager<Object> {
@Override
public AuthorizationResult authorize(Supplier<? extends @Nullable Authentication> authentication, Object context) {
if ("admin".equals(authentication.get().getName())) {
return AuthorityAuthorizationManager.hasAuthority(GrantedAuthorities.FACTOR_OTT_AUTHORITY)
.authorize(authentication, context);
} else {
return new AuthorizationDecision(true);
}
}
}
@Component
internal open class UserBasedOttAuthorizationManager : AuthorizationManager<Object> {
override fun authorize(
authentication: Supplier<out Authentication?>, context: Object): AuthorizationResult {
return if ("admin" == authentication.get().name) {
AuthorityAuthorizationManager.hasAuthority<Object>(GrantedAuthorities.FACTOR_OTT_AUTHORITY)
.authorize(authentication, context)
} else {
AuthorizationDecision(true)
}
}
}
In this case, using One-Time-Token is only required for those who have opted in.
This can then be enforced by a custom AuthorizationManagerFactory
implementation:
-
Java
-
Kotlin
@Bean
AuthorizationManagerFactory<Object> authorizationManagerFactory(UserBasedOttAuthorizationManager optIn) {
DefaultAuthorizationManagerFactory<Object> defaults = new DefaultAuthorizationManagerFactory<>();
defaults.setAdditionalAuthorization(optIn);
return defaults;
}
@Bean
fun authorizationManagerFactory(optIn: UserBasedOttAuthorizationManager?): AuthorizationManagerFactory<Object> {
val defaults = DefaultAuthorizationManagerFactory<Object>()
defaults.setAdditionalAuthorization(optIn)
return defaults
}