Document RequiredFactor Valid Duration
Issue gh-17997
This commit is contained in:
@@ -55,7 +55,7 @@ We have demonstrated how to configure an entire application to require MFA (Glob
|
||||
However, there are times that an application only wants parts of the application to require MFA.
|
||||
Consider the following requirements:
|
||||
|
||||
- URLs that begin with `/admin/**` should require the authorities `FACTOR_OTT`, `FACTOR_PASSWORD`, `ROLE_ADMIN`.
|
||||
- URLs that begin with `/admin/` should require the authorities `FACTOR_OTT`, `FACTOR_PASSWORD`, `ROLE_ADMIN`.
|
||||
- URLs that begin with `/user/settings` should require the authorities `FACTOR_OTT`, `FACTOR_PASSWORD`
|
||||
- Every other URL requires an authenticated user
|
||||
|
||||
@@ -72,6 +72,30 @@ By not publishing it as a Bean, we are able to selectively use the `Authorizatio
|
||||
There is no MFA requirement, because the `AuthorizationManagerFactory` is not used.
|
||||
<5> Set up the authentication mechanisms that can provide the required factors.
|
||||
|
||||
[[valid-duration]]
|
||||
== Specifying a Valid Duration
|
||||
|
||||
At times, we may want to define authorization rules based upon how recently we authenticated.
|
||||
For example, an application may want to require that the user has authenticated within the last hour in order to allow access to the `/user/settings` endpoint.
|
||||
|
||||
Remember at the time of authentication, a `FactorGrantedAuthority` is added to the `Authentication`.
|
||||
The `FactorGrantedAuthority` specifies when it was `issuedAt`, but does not describe how long it is valid for.
|
||||
This is intentional, because it allows a single `FactorGrantedAuthority` to be used with different ``validDuration``s.
|
||||
|
||||
Let's take a look at an example that illustrates how to meet the following requirements:
|
||||
|
||||
- URLs that begin with `/admin/` should require that a password has been provided within the last 30 minutes
|
||||
- URLs that being with `/user/settings` should require that a password has been provided within the last hour
|
||||
- Otherwise, authentication is required, but it does not care if it is a password or how long ago authentication occurred
|
||||
|
||||
include-code::./ValidDurationConfiguration[tag=httpSecurity,indent=0]
|
||||
<1> First we define `passwordIn30m` as a requirement for a password within 30 minutes
|
||||
<2> Next, we define `passwordInHour` as a requirement for a password within an hour
|
||||
<3> We use `passwordIn30m` to require that URLs that begin with `/admin/` should require that a password has been provided in the last 30 minutes and that the user has the `ROLE_ADMIN` authority
|
||||
<4> We use `passwordInHour` to require that URLs that begin with `/user/settings` should require that a password has been provided in the last hour
|
||||
<5> Otherwise, authentication is required, but it does not care if it is a password or how long ago authentication occurred
|
||||
<6> Set up the authentication mechanisms that can provide the required factors.
|
||||
|
||||
[[programmatic-mfa]]
|
||||
== Programmatic MFA
|
||||
|
||||
|
||||
+6
-7
@@ -24,13 +24,12 @@ class SelectiveMfaConfiguration {
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
// <1>
|
||||
AuthorizationManagerFactory<Object> mfa =
|
||||
AuthorizationManagerFactories.<Object>multiFactor()
|
||||
.requireFactors(
|
||||
FactorGrantedAuthority.PASSWORD_AUTHORITY,
|
||||
FactorGrantedAuthority.OTT_AUTHORITY
|
||||
)
|
||||
.build();
|
||||
var mfa = AuthorizationManagerFactories.multiFactor()
|
||||
.requireFactors(
|
||||
FactorGrantedAuthority.PASSWORD_AUTHORITY,
|
||||
FactorGrantedAuthority.OTT_AUTHORITY
|
||||
)
|
||||
.build();
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
// <2>
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package org.springframework.security.docs.servlet.authentication.validduration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authorization.AuthorizationManagerFactories;
|
||||
import org.springframework.security.authorization.AuthorizationManagerFactory;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.core.authority.FactorGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
|
||||
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
class ValidDurationConfiguration {
|
||||
|
||||
// tag::httpSecurity[]
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
// <1>
|
||||
var passwordIn30m = AuthorizationManagerFactories.multiFactor()
|
||||
.requireFactor( (factor) -> factor
|
||||
.passwordAuthority()
|
||||
.validDuration(Duration.ofMinutes(30))
|
||||
)
|
||||
.build();
|
||||
// <2>
|
||||
var passwordInHour = AuthorizationManagerFactories.multiFactor()
|
||||
.requireFactor( (factor) -> factor
|
||||
.passwordAuthority()
|
||||
.validDuration(Duration.ofHours(1))
|
||||
)
|
||||
.build();
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
// <3>
|
||||
.requestMatchers("/admin/**").access(passwordIn30m.hasRole("ADMIN"))
|
||||
// <4>
|
||||
.requestMatchers("/user/settings/**").access(passwordInHour.authenticated())
|
||||
// <5>
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
// <6>
|
||||
.formLogin(Customizer.withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
// end::httpSecurity[]
|
||||
|
||||
@Bean
|
||||
UserDetailsService userDetailsService() {
|
||||
return new InMemoryUserDetailsManager(
|
||||
User.withDefaultPasswordEncoder()
|
||||
.username("user")
|
||||
.password("password")
|
||||
.authorities("app")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.docs.servlet.authentication.validduration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.core.authority.FactorGrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
|
||||
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
|
||||
import org.springframework.test.context.TestExecutionListeners;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Tests {@link CustomX509Configuration}.
|
||||
*
|
||||
* @author Rob Winch
|
||||
*/
|
||||
@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
|
||||
@TestExecutionListeners(WithSecurityContextTestExecutionListener.class)
|
||||
public class ValidDurationConfigurationTests {
|
||||
|
||||
public final SpringTestContext spring = new SpringTestContext(this);
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void adminWhenExpiredThenRequired() throws Exception {
|
||||
this.spring.register(
|
||||
ValidDurationConfiguration.class, Http200Controller.class).autowire();
|
||||
// @formatter:off
|
||||
this.mockMvc.perform(get("/admin/").with(admin(Duration.ofMinutes(31))))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrlPattern("http://localhost/login?*"));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminWhenNotExpiredThenOk() throws Exception {
|
||||
this.spring.register(
|
||||
ValidDurationConfiguration.class, Http200Controller.class).autowire();
|
||||
// @formatter:off
|
||||
this.mockMvc.perform(get("/admin/").with(admin(Duration.ofMinutes(29))))
|
||||
.andExpect(status().isOk());
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsWhenExpiredThenRequired() throws Exception {
|
||||
this.spring.register(
|
||||
ValidDurationConfiguration.class, Http200Controller.class).autowire();
|
||||
// @formatter:off
|
||||
this.mockMvc.perform(get("/user/settings").with(user(Duration.ofMinutes(61))))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrlPattern("http://localhost/login?*"));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsWhenNotExpiredThenOk() throws Exception {
|
||||
this.spring.register(
|
||||
ValidDurationConfiguration.class, Http200Controller.class).autowire();
|
||||
// @formatter:off
|
||||
this.mockMvc.perform(get("/user/settings").with(user(Duration.ofMinutes(59))))
|
||||
.andExpect(status().isOk());
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
private static RequestPostProcessor admin(Duration sinceAuthn) {
|
||||
return authn("admin", sinceAuthn);
|
||||
}
|
||||
|
||||
private static RequestPostProcessor user(Duration sinceAuthn) {
|
||||
return authn("user", sinceAuthn);
|
||||
}
|
||||
|
||||
private static RequestPostProcessor authn(String username, Duration sinceAuthn) {
|
||||
Instant issuedAt = Instant.now().minus(sinceAuthn);
|
||||
FactorGrantedAuthority factor = FactorGrantedAuthority
|
||||
.withAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)
|
||||
.issuedAt(issuedAt)
|
||||
.build();
|
||||
String role = username.toUpperCase();
|
||||
TestingAuthenticationToken authn = new TestingAuthenticationToken(username, "",
|
||||
factor, new SimpleGrantedAuthority("ROLE_" + role));
|
||||
return authentication(authn);
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class Http200Controller {
|
||||
@GetMapping("/**")
|
||||
String ok() {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -24,13 +24,12 @@ internal class SelectiveMfaConfiguration {
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
|
||||
// @formatter:off
|
||||
// <1>
|
||||
val mfa: AuthorizationManagerFactory<Any> =
|
||||
AuthorizationManagerFactories.multiFactor<Any>()
|
||||
.requireFactors(
|
||||
FactorGrantedAuthority.PASSWORD_AUTHORITY,
|
||||
FactorGrantedAuthority.OTT_AUTHORITY
|
||||
)
|
||||
.build()
|
||||
val mfa = AuthorizationManagerFactories.multiFactor<Any>()
|
||||
.requireFactors(
|
||||
FactorGrantedAuthority.PASSWORD_AUTHORITY,
|
||||
FactorGrantedAuthority.OTT_AUTHORITY
|
||||
)
|
||||
.build()
|
||||
http {
|
||||
authorizeHttpRequests {
|
||||
// <2>
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package org.springframework.security.kt.docs.servlet.authentication.validduration
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.authorization.AuthorizationManagerFactories
|
||||
import org.springframework.security.authorization.AuthorizationManagerFactory
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.annotation.web.invoke
|
||||
import org.springframework.security.core.authority.FactorGrantedAuthority
|
||||
import org.springframework.security.core.userdetails.User
|
||||
import org.springframework.security.core.userdetails.UserDetailsService
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
|
||||
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
|
||||
import java.time.Duration
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
internal class ValidDurationConfiguration {
|
||||
// tag::httpSecurity[]
|
||||
@Bean
|
||||
@Throws(Exception::class)
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
|
||||
// @formatter:off
|
||||
// <1>
|
||||
val passwordIn30m = AuthorizationManagerFactories.multiFactor<Any>()
|
||||
.requireFactor( { factor -> factor
|
||||
.passwordAuthority()
|
||||
.validDuration(Duration.ofMinutes(30))
|
||||
})
|
||||
.build()
|
||||
// <2>
|
||||
val passwordInHour = AuthorizationManagerFactories.multiFactor<Any>()
|
||||
.requireFactor( { factor -> factor
|
||||
.passwordAuthority()
|
||||
.validDuration(Duration.ofHours(1))
|
||||
})
|
||||
.build()
|
||||
http {
|
||||
authorizeHttpRequests {
|
||||
// <3>
|
||||
authorize("/admin/**", passwordIn30m.hasRole("ADMIN"))
|
||||
// <4>
|
||||
authorize("/user/settings/**", passwordInHour.authenticated())
|
||||
// <5>
|
||||
authorize(anyRequest, authenticated)
|
||||
}
|
||||
// <6>
|
||||
formLogin { }
|
||||
}
|
||||
// @formatter:on
|
||||
return http.build()
|
||||
}
|
||||
|
||||
// end::httpSecurity[]
|
||||
@Bean
|
||||
fun userDetailsService(): UserDetailsService {
|
||||
return InMemoryUserDetailsManager(
|
||||
User.withDefaultPasswordEncoder()
|
||||
.username("user")
|
||||
.password("password")
|
||||
.authorities("app")
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
|
||||
return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2004-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.security.kt.docs.servlet.authentication.validduration
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken
|
||||
import org.springframework.security.config.test.SpringTestContext
|
||||
import org.springframework.security.config.test.SpringTestContextExtension
|
||||
import org.springframework.security.core.authority.FactorGrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
|
||||
import org.springframework.test.context.TestExecutionListeners
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
import org.springframework.test.web.servlet.request.RequestPostProcessor
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Tests [CustomX509Configuration].
|
||||
*
|
||||
* @author Rob Winch
|
||||
*/
|
||||
@ExtendWith(SpringExtension::class, SpringTestContextExtension::class)
|
||||
@TestExecutionListeners(WithSecurityContextTestExecutionListener::class)
|
||||
class ValidDurationConfigurationTests {
|
||||
@JvmField
|
||||
val spring: SpringTestContext = SpringTestContext(this)
|
||||
|
||||
@Autowired
|
||||
var mockMvc: MockMvc? = null
|
||||
|
||||
@Test
|
||||
@Throws(Exception::class)
|
||||
fun adminWhenExpiredThenRequired() {
|
||||
this.spring.register(
|
||||
ValidDurationConfiguration::class.java, Http200Controller::class.java
|
||||
).autowire()
|
||||
// @formatter:off
|
||||
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/admin/").with(admin(Duration.ofMinutes(31))))
|
||||
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
|
||||
.andExpect(MockMvcResultMatchers.redirectedUrlPattern("http://localhost/login?*"))
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(Exception::class)
|
||||
fun adminWhenNotExpiredThenOk() {
|
||||
this.spring.register(
|
||||
ValidDurationConfiguration::class.java, Http200Controller::class.java
|
||||
).autowire()
|
||||
// @formatter:off
|
||||
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/admin/").with(admin(Duration.ofMinutes(29))))
|
||||
.andExpect(MockMvcResultMatchers.status().isOk())
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(Exception::class)
|
||||
fun settingsWhenExpiredThenRequired() {
|
||||
this.spring.register(
|
||||
ValidDurationConfiguration::class.java, Http200Controller::class.java
|
||||
).autowire()
|
||||
// @formatter:off
|
||||
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/user/settings").with(user(Duration.ofMinutes(61))))
|
||||
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
|
||||
.andExpect(MockMvcResultMatchers.redirectedUrlPattern("http://localhost/login?*"))
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(Exception::class)
|
||||
fun settingsWhenNotExpiredThenOk() {
|
||||
this.spring.register(
|
||||
ValidDurationConfiguration::class.java, ValidDurationConfigurationTests.Http200Controller::class.java
|
||||
).autowire()
|
||||
// @formatter:off
|
||||
this.mockMvc!!.perform(MockMvcRequestBuilders.get("/user/settings").with(user(Duration.ofMinutes(59))))
|
||||
.andExpect(MockMvcResultMatchers.status().isOk())
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
private fun admin(sinceAuthn: Duration): RequestPostProcessor {
|
||||
return authn("admin", sinceAuthn)
|
||||
}
|
||||
|
||||
private fun user(sinceAuthn: Duration): RequestPostProcessor {
|
||||
return authn("user", sinceAuthn)
|
||||
}
|
||||
|
||||
private fun authn(username: String, sinceAuthn: Duration): RequestPostProcessor {
|
||||
val issuedAt = Instant.now().minus(sinceAuthn)
|
||||
val factor = FactorGrantedAuthority
|
||||
.withAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)
|
||||
.issuedAt(issuedAt)
|
||||
.build()
|
||||
val role = username.uppercase(Locale.getDefault())
|
||||
val authn = TestingAuthenticationToken(
|
||||
username, "",
|
||||
factor, SimpleGrantedAuthority("ROLE_" + role)
|
||||
)
|
||||
return SecurityMockMvcRequestPostProcessors.authentication(authn)
|
||||
}
|
||||
|
||||
@RestController
|
||||
internal class Http200Controller {
|
||||
@GetMapping("/**")
|
||||
fun ok(): String {
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user