Quellcode durchsuchen

Document RequiredFactor Valid Duration

Issue gh-17997
Rob Winch vor 2 Tagen
Ursprung
Commit
78701f94ee

+ 25 - 1
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
 

+ 6 - 7
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<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
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()
+		);
+	}
+}

+ 128 - 0
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";
+		}
+	}
+}

+ 6 - 7
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<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
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<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
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"
+        }
+    }
+}