Browse Source

Document RequiredFactor Valid Duration

Issue gh-17997
pull/17753/merge
Rob Winch 2 months ago
parent
commit
78701f94ee
No known key found for this signature in database
  1. 26
      docs/modules/ROOT/pages/servlet/authentication/mfa.adoc
  2. 13
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.java
  3. 68
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/validduration/ValidDurationConfiguration.java
  4. 128
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/validduration/ValidDurationConfigurationTests.java
  5. 13
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.kt
  6. 73
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/validduration/ValidDurationConfiguration.kt
  7. 133
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/validduration/ValidDurationConfigurationTests.kt

26
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. However, there are times that an application only wants parts of the application to require MFA.
Consider the following requirements: 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` - URLs that begin with `/user/settings` should require the authorities `FACTOR_OTT`, `FACTOR_PASSWORD`
- Every other URL requires an authenticated user - 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. There is no MFA requirement, because the `AuthorizationManagerFactory` is not used.
<5> Set up the authentication mechanisms that can provide the required factors. <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]]
== Programmatic MFA == Programmatic MFA

13
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 { SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off // @formatter:off
// <1> // <1>
AuthorizationManagerFactory<Object> mfa = var mfa = AuthorizationManagerFactories.multiFactor()
AuthorizationManagerFactories.<Object>multiFactor() .requireFactors(
.requireFactors( FactorGrantedAuthority.PASSWORD_AUTHORITY,
FactorGrantedAuthority.PASSWORD_AUTHORITY, FactorGrantedAuthority.OTT_AUTHORITY
FactorGrantedAuthority.OTT_AUTHORITY )
) .build();
.build();
http http
.authorizeHttpRequests((authorize) -> authorize .authorizeHttpRequests((authorize) -> authorize
// <2> // <2>

68
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
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";
}
}
}

13
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? { fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
// @formatter:off // @formatter:off
// <1> // <1>
val mfa: AuthorizationManagerFactory<Any> = val mfa = AuthorizationManagerFactories.multiFactor<Any>()
AuthorizationManagerFactories.multiFactor<Any>() .requireFactors(
.requireFactors( FactorGrantedAuthority.PASSWORD_AUTHORITY,
FactorGrantedAuthority.PASSWORD_AUTHORITY, FactorGrantedAuthority.OTT_AUTHORITY
FactorGrantedAuthority.OTT_AUTHORITY )
) .build()
.build()
http { http {
authorizeHttpRequests { authorizeHttpRequests {
// <2> // <2>

73
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
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"
}
}
}
Loading…
Cancel
Save