From 78701f94eeb36d42bd4189e601c2a2c3f19ac3f3 Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:48:37 -0500 Subject: [PATCH] Document RequiredFactor Valid Duration Issue gh-17997 --- .../pages/servlet/authentication/mfa.adoc | 26 +++- .../SelectiveMfaConfiguration.java | 13 +- .../ValidDurationConfiguration.java | 68 +++++++++ .../ValidDurationConfigurationTests.java | 128 +++++++++++++++++ .../selectivemfa/SelectiveMfaConfiguration.kt | 13 +- .../ValidDurationConfiguration.kt | 73 ++++++++++ .../ValidDurationConfigurationTests.kt | 133 ++++++++++++++++++ 7 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/authentication/validduration/ValidDurationConfiguration.java create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/authentication/validduration/ValidDurationConfigurationTests.java create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/validduration/ValidDurationConfiguration.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/validduration/ValidDurationConfigurationTests.kt diff --git a/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc b/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc index f8ac9f7063..730d9a5b73 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/mfa.adoc @@ -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 diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.java index 86c93b2e0a..b35542c76f 100644 --- a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.java +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.java @@ -24,13 +24,12 @@ class SelectiveMfaConfiguration { SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // @formatter:off // <1> - AuthorizationManagerFactory mfa = - AuthorizationManagerFactories.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> diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/validduration/ValidDurationConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/validduration/ValidDurationConfiguration.java new file mode 100644 index 0000000000..9b76c90298 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/validduration/ValidDurationConfiguration.java @@ -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() + ); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/validduration/ValidDurationConfigurationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/validduration/ValidDurationConfigurationTests.java new file mode 100644 index 0000000000..6900e0bb2b --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/validduration/ValidDurationConfigurationTests.java @@ -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"; + } + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.kt index 8442a0b454..56e1666d6a 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.kt @@ -24,13 +24,12 @@ internal class SelectiveMfaConfiguration { fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { // @formatter:off // <1> - val mfa: AuthorizationManagerFactory = - AuthorizationManagerFactories.multiFactor() - .requireFactors( - FactorGrantedAuthority.PASSWORD_AUTHORITY, - FactorGrantedAuthority.OTT_AUTHORITY - ) - .build() + val mfa = AuthorizationManagerFactories.multiFactor() + .requireFactors( + FactorGrantedAuthority.PASSWORD_AUTHORITY, + FactorGrantedAuthority.OTT_AUTHORITY + ) + .build() http { authorizeHttpRequests { // <2> diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/validduration/ValidDurationConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/validduration/ValidDurationConfiguration.kt new file mode 100644 index 0000000000..7119b52723 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/validduration/ValidDurationConfiguration.kt @@ -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() + .requireFactor( { factor -> factor + .passwordAuthority() + .validDuration(Duration.ofMinutes(30)) + }) + .build() + // <2> + val passwordInHour = AuthorizationManagerFactories.multiFactor() + .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") + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/validduration/ValidDurationConfigurationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/validduration/ValidDurationConfigurationTests.kt new file mode 100644 index 0000000000..75b21b2fbd --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/validduration/ValidDurationConfigurationTests.kt @@ -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" + } + } +}