Skip to content

Commit 78701f9

Browse files
committed
Document RequiredFactor Valid Duration
Issue gh-17997
1 parent 2b4e36c commit 78701f9

File tree

7 files changed

+439
-15
lines changed

7 files changed

+439
-15
lines changed

docs/modules/ROOT/pages/servlet/authentication/mfa.adoc

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ We have demonstrated how to configure an entire application to require MFA (Glob
5555
However, there are times that an application only wants parts of the application to require MFA.
5656
Consider the following requirements:
5757

58-
- URLs that begin with `/admin/**` should require the authorities `FACTOR_OTT`, `FACTOR_PASSWORD`, `ROLE_ADMIN`.
58+
- URLs that begin with `/admin/` should require the authorities `FACTOR_OTT`, `FACTOR_PASSWORD`, `ROLE_ADMIN`.
5959
- URLs that begin with `/user/settings` should require the authorities `FACTOR_OTT`, `FACTOR_PASSWORD`
6060
- Every other URL requires an authenticated user
6161

@@ -72,6 +72,30 @@ By not publishing it as a Bean, we are able to selectively use the `Authorizatio
7272
There is no MFA requirement, because the `AuthorizationManagerFactory` is not used.
7373
<5> Set up the authentication mechanisms that can provide the required factors.
7474

75+
[[valid-duration]]
76+
== Specifying a Valid Duration
77+
78+
At times, we may want to define authorization rules based upon how recently we authenticated.
79+
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.
80+
81+
Remember at the time of authentication, a `FactorGrantedAuthority` is added to the `Authentication`.
82+
The `FactorGrantedAuthority` specifies when it was `issuedAt`, but does not describe how long it is valid for.
83+
This is intentional, because it allows a single `FactorGrantedAuthority` to be used with different ``validDuration``s.
84+
85+
Let's take a look at an example that illustrates how to meet the following requirements:
86+
87+
- URLs that begin with `/admin/` should require that a password has been provided within the last 30 minutes
88+
- URLs that being with `/user/settings` should require that a password has been provided within the last hour
89+
- Otherwise, authentication is required, but it does not care if it is a password or how long ago authentication occurred
90+
91+
include-code::./ValidDurationConfiguration[tag=httpSecurity,indent=0]
92+
<1> First we define `passwordIn30m` as a requirement for a password within 30 minutes
93+
<2> Next, we define `passwordInHour` as a requirement for a password within an hour
94+
<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
95+
<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
96+
<5> Otherwise, authentication is required, but it does not care if it is a password or how long ago authentication occurred
97+
<6> Set up the authentication mechanisms that can provide the required factors.
98+
7599
[[programmatic-mfa]]
76100
== Programmatic MFA
77101

docs/src/test/java/org/springframework/security/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,12 @@ class SelectiveMfaConfiguration {
2424
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
2525
// @formatter:off
2626
// <1>
27-
AuthorizationManagerFactory<Object> mfa =
28-
AuthorizationManagerFactories.<Object>multiFactor()
29-
.requireFactors(
30-
FactorGrantedAuthority.PASSWORD_AUTHORITY,
31-
FactorGrantedAuthority.OTT_AUTHORITY
32-
)
33-
.build();
27+
var mfa = AuthorizationManagerFactories.multiFactor()
28+
.requireFactors(
29+
FactorGrantedAuthority.PASSWORD_AUTHORITY,
30+
FactorGrantedAuthority.OTT_AUTHORITY
31+
)
32+
.build();
3433
http
3534
.authorizeHttpRequests((authorize) -> authorize
3635
// <2>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package org.springframework.security.docs.servlet.authentication.validduration;
2+
3+
import java.time.Duration;
4+
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.security.authorization.AuthorizationManagerFactories;
8+
import org.springframework.security.authorization.AuthorizationManagerFactory;
9+
import org.springframework.security.config.Customizer;
10+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
11+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
12+
import org.springframework.security.core.authority.FactorGrantedAuthority;
13+
import org.springframework.security.core.userdetails.User;
14+
import org.springframework.security.core.userdetails.UserDetailsService;
15+
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
16+
import org.springframework.security.web.SecurityFilterChain;
17+
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
18+
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
19+
20+
@EnableWebSecurity
21+
@Configuration(proxyBeanMethods = false)
22+
class ValidDurationConfiguration {
23+
24+
// tag::httpSecurity[]
25+
@Bean
26+
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
27+
// @formatter:off
28+
// <1>
29+
var passwordIn30m = AuthorizationManagerFactories.multiFactor()
30+
.requireFactor( (factor) -> factor
31+
.passwordAuthority()
32+
.validDuration(Duration.ofMinutes(30))
33+
)
34+
.build();
35+
// <2>
36+
var passwordInHour = AuthorizationManagerFactories.multiFactor()
37+
.requireFactor( (factor) -> factor
38+
.passwordAuthority()
39+
.validDuration(Duration.ofHours(1))
40+
)
41+
.build();
42+
http
43+
.authorizeHttpRequests((authorize) -> authorize
44+
// <3>
45+
.requestMatchers("/admin/**").access(passwordIn30m.hasRole("ADMIN"))
46+
// <4>
47+
.requestMatchers("/user/settings/**").access(passwordInHour.authenticated())
48+
// <5>
49+
.anyRequest().authenticated()
50+
)
51+
// <6>
52+
.formLogin(Customizer.withDefaults());
53+
// @formatter:on
54+
return http.build();
55+
}
56+
// end::httpSecurity[]
57+
58+
@Bean
59+
UserDetailsService userDetailsService() {
60+
return new InMemoryUserDetailsManager(
61+
User.withDefaultPasswordEncoder()
62+
.username("user")
63+
.password("password")
64+
.authorities("app")
65+
.build()
66+
);
67+
}
68+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright 2004-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.docs.servlet.authentication.validduration;
18+
19+
import java.time.Duration;
20+
import java.time.Instant;
21+
22+
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.api.extension.ExtendWith;
24+
25+
import org.springframework.beans.factory.annotation.Autowired;
26+
import org.springframework.security.authentication.TestingAuthenticationToken;
27+
import org.springframework.security.config.test.SpringTestContext;
28+
import org.springframework.security.config.test.SpringTestContextExtension;
29+
import org.springframework.security.core.authority.FactorGrantedAuthority;
30+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
31+
import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
32+
import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
33+
import org.springframework.test.context.TestExecutionListeners;
34+
import org.springframework.test.context.junit.jupiter.SpringExtension;
35+
import org.springframework.test.web.servlet.MockMvc;
36+
import org.springframework.test.web.servlet.request.RequestPostProcessor;
37+
import org.springframework.web.bind.annotation.GetMapping;
38+
import org.springframework.web.bind.annotation.RestController;
39+
40+
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
41+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
42+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
43+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
44+
45+
/**
46+
* Tests {@link CustomX509Configuration}.
47+
*
48+
* @author Rob Winch
49+
*/
50+
@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
51+
@TestExecutionListeners(WithSecurityContextTestExecutionListener.class)
52+
public class ValidDurationConfigurationTests {
53+
54+
public final SpringTestContext spring = new SpringTestContext(this);
55+
56+
@Autowired
57+
MockMvc mockMvc;
58+
59+
@Test
60+
void adminWhenExpiredThenRequired() throws Exception {
61+
this.spring.register(
62+
ValidDurationConfiguration.class, Http200Controller.class).autowire();
63+
// @formatter:off
64+
this.mockMvc.perform(get("/admin/").with(admin(Duration.ofMinutes(31))))
65+
.andExpect(status().is3xxRedirection())
66+
.andExpect(redirectedUrlPattern("http://localhost/login?*"));
67+
// @formatter:on
68+
}
69+
70+
@Test
71+
void adminWhenNotExpiredThenOk() throws Exception {
72+
this.spring.register(
73+
ValidDurationConfiguration.class, Http200Controller.class).autowire();
74+
// @formatter:off
75+
this.mockMvc.perform(get("/admin/").with(admin(Duration.ofMinutes(29))))
76+
.andExpect(status().isOk());
77+
// @formatter:on
78+
}
79+
80+
@Test
81+
void settingsWhenExpiredThenRequired() throws Exception {
82+
this.spring.register(
83+
ValidDurationConfiguration.class, Http200Controller.class).autowire();
84+
// @formatter:off
85+
this.mockMvc.perform(get("/user/settings").with(user(Duration.ofMinutes(61))))
86+
.andExpect(status().is3xxRedirection())
87+
.andExpect(redirectedUrlPattern("http://localhost/login?*"));
88+
// @formatter:on
89+
}
90+
91+
@Test
92+
void settingsWhenNotExpiredThenOk() throws Exception {
93+
this.spring.register(
94+
ValidDurationConfiguration.class, Http200Controller.class).autowire();
95+
// @formatter:off
96+
this.mockMvc.perform(get("/user/settings").with(user(Duration.ofMinutes(59))))
97+
.andExpect(status().isOk());
98+
// @formatter:on
99+
}
100+
101+
private static RequestPostProcessor admin(Duration sinceAuthn) {
102+
return authn("admin", sinceAuthn);
103+
}
104+
105+
private static RequestPostProcessor user(Duration sinceAuthn) {
106+
return authn("user", sinceAuthn);
107+
}
108+
109+
private static RequestPostProcessor authn(String username, Duration sinceAuthn) {
110+
Instant issuedAt = Instant.now().minus(sinceAuthn);
111+
FactorGrantedAuthority factor = FactorGrantedAuthority
112+
.withAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)
113+
.issuedAt(issuedAt)
114+
.build();
115+
String role = username.toUpperCase();
116+
TestingAuthenticationToken authn = new TestingAuthenticationToken(username, "",
117+
factor, new SimpleGrantedAuthority("ROLE_" + role));
118+
return authentication(authn);
119+
}
120+
121+
@RestController
122+
static class Http200Controller {
123+
@GetMapping("/**")
124+
String ok() {
125+
return "ok";
126+
}
127+
}
128+
}

docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/selectivemfa/SelectiveMfaConfiguration.kt

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,12 @@ internal class SelectiveMfaConfiguration {
2424
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
2525
// @formatter:off
2626
// <1>
27-
val mfa: AuthorizationManagerFactory<Any> =
28-
AuthorizationManagerFactories.multiFactor<Any>()
29-
.requireFactors(
30-
FactorGrantedAuthority.PASSWORD_AUTHORITY,
31-
FactorGrantedAuthority.OTT_AUTHORITY
32-
)
33-
.build()
27+
val mfa = AuthorizationManagerFactories.multiFactor<Any>()
28+
.requireFactors(
29+
FactorGrantedAuthority.PASSWORD_AUTHORITY,
30+
FactorGrantedAuthority.OTT_AUTHORITY
31+
)
32+
.build()
3433
http {
3534
authorizeHttpRequests {
3635
// <2>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package org.springframework.security.kt.docs.servlet.authentication.validduration
2+
3+
import org.springframework.context.annotation.Bean
4+
import org.springframework.context.annotation.Configuration
5+
import org.springframework.security.authorization.AuthorizationManagerFactories
6+
import org.springframework.security.authorization.AuthorizationManagerFactory
7+
import org.springframework.security.config.annotation.web.builders.HttpSecurity
8+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
9+
import org.springframework.security.config.annotation.web.invoke
10+
import org.springframework.security.core.authority.FactorGrantedAuthority
11+
import org.springframework.security.core.userdetails.User
12+
import org.springframework.security.core.userdetails.UserDetailsService
13+
import org.springframework.security.provisioning.InMemoryUserDetailsManager
14+
import org.springframework.security.web.SecurityFilterChain
15+
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
16+
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
17+
import java.time.Duration
18+
19+
@EnableWebSecurity
20+
@Configuration(proxyBeanMethods = false)
21+
internal class ValidDurationConfiguration {
22+
// tag::httpSecurity[]
23+
@Bean
24+
@Throws(Exception::class)
25+
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
26+
// @formatter:off
27+
// <1>
28+
val passwordIn30m = AuthorizationManagerFactories.multiFactor<Any>()
29+
.requireFactor( { factor -> factor
30+
.passwordAuthority()
31+
.validDuration(Duration.ofMinutes(30))
32+
})
33+
.build()
34+
// <2>
35+
val passwordInHour = AuthorizationManagerFactories.multiFactor<Any>()
36+
.requireFactor( { factor -> factor
37+
.passwordAuthority()
38+
.validDuration(Duration.ofHours(1))
39+
})
40+
.build()
41+
http {
42+
authorizeHttpRequests {
43+
// <3>
44+
authorize("/admin/**", passwordIn30m.hasRole("ADMIN"))
45+
// <4>
46+
authorize("/user/settings/**", passwordInHour.authenticated())
47+
// <5>
48+
authorize(anyRequest, authenticated)
49+
}
50+
// <6>
51+
formLogin { }
52+
}
53+
// @formatter:on
54+
return http.build()
55+
}
56+
57+
// end::httpSecurity[]
58+
@Bean
59+
fun userDetailsService(): UserDetailsService {
60+
return InMemoryUserDetailsManager(
61+
User.withDefaultPasswordEncoder()
62+
.username("user")
63+
.password("password")
64+
.authorities("app")
65+
.build()
66+
)
67+
}
68+
69+
@Bean
70+
fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
71+
return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
72+
}
73+
}

0 commit comments

Comments
 (0)