Skip to content

Commit ff820a8

Browse files
committed
Polish AllRequiredFactorsAuthorizationManager.anyOf
- Add validation - Extract to static inner class - Uniqueness determined by Set rather than requiredFactor This is important for the failure with the same RequiredFactor, but a different reason - Add documentation Signed-off-by: Robert Winch <362503+rwinch@users.noreply.github.com>
1 parent 6b09352 commit ff820a8

6 files changed

Lines changed: 271 additions & 21 deletions

File tree

core/src/main/java/org/springframework/security/authorization/AllRequiredFactorsAuthorizationManager.java

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@
2020
import java.time.Instant;
2121
import java.util.ArrayList;
2222
import java.util.Collections;
23-
import java.util.LinkedHashMap;
23+
import java.util.LinkedHashSet;
2424
import java.util.List;
25-
import java.util.Map;
2625
import java.util.Objects;
2726
import java.util.Optional;
27+
import java.util.Set;
2828
import java.util.function.Consumer;
2929
import java.util.function.Supplier;
3030
import java.util.stream.Collectors;
@@ -54,28 +54,23 @@ public final class AllRequiredFactorsAuthorizationManager<T> implements Authoriz
5454

5555
/**
5656
* Creates an {@link AuthorizationManager} that grants access if at least one
57-
* {@link AllRequiredFactorsAuthorizationManager} granted, collects
58-
* {@link RequiredFactorError}s omitting duplicate errors of the same factor.
57+
* {@link AllRequiredFactorsAuthorizationManager} granted. When all managers deny,
58+
* collects the unique {@link RequiredFactorError}s from each manager.
5959
* @param <T> the type of object that is being authorized
60-
* @param managers the {@link AllRequiredFactorsAuthorizationManager}s to use
60+
* @param managers the {@link AllRequiredFactorsAuthorizationManager}s to use; cannot
61+
* be empty or contain null elements
6162
* @return the {@link AuthorizationManager} to use
6263
* @since 7.1
6364
* @see AuthorizationManagers#anyOf(AuthorizationManager[])
6465
*/
6566
@SafeVarargs
6667
public static <T> AuthorizationManager<T> anyOf(AllRequiredFactorsAuthorizationManager<T>... managers) {
67-
return (authentication, object) -> {
68-
Map<String, RequiredFactorError> factorErrors = new LinkedHashMap<>();
69-
for (AllRequiredFactorsAuthorizationManager<T> manager : managers) {
70-
FactorAuthorizationDecision decision = manager.authorize(authentication, object);
71-
if (decision.isGranted()) {
72-
return decision;
73-
}
74-
decision.getFactorErrors()
75-
.forEach((e) -> factorErrors.putIfAbsent(e.getRequiredFactor().getAuthority(), e));
76-
}
77-
return new FactorAuthorizationDecision(List.copyOf(factorErrors.values()));
78-
};
68+
Assert.notEmpty(managers, "managers cannot be empty");
69+
Assert.noNullElements(managers, "managers cannot contain null elements");
70+
if (managers.length == 1) {
71+
return managers[0];
72+
}
73+
return new AnyOfFactorsAuthorizationManager<>(managers);
7974
}
8075

8176
/**
@@ -179,6 +174,38 @@ public static <T> Builder<T> builder() {
179174
return new Builder<>();
180175
}
181176

177+
/**
178+
* An {@link AuthorizationManager} that grants access if at least one
179+
* {@link AllRequiredFactorsAuthorizationManager} granted. When all deny, collects the
180+
* unique {@link RequiredFactorError}s from each manager.
181+
*
182+
* @param <T> the type of object being authorized
183+
*/
184+
private static final class AnyOfFactorsAuthorizationManager<T> implements AuthorizationManager<T> {
185+
186+
private final AllRequiredFactorsAuthorizationManager<T>[] managers;
187+
188+
AnyOfFactorsAuthorizationManager(AllRequiredFactorsAuthorizationManager<T>[] managers) {
189+
Assert.notEmpty(managers, "managers cannot be empty");
190+
Assert.noNullElements(managers, "managers cannot contain null elements");
191+
this.managers = managers;
192+
}
193+
194+
@Override
195+
public AuthorizationResult authorize(Supplier<? extends @Nullable Authentication> authentication, T object) {
196+
Set<RequiredFactorError> factorErrors = new LinkedHashSet<>();
197+
for (AllRequiredFactorsAuthorizationManager<T> manager : this.managers) {
198+
FactorAuthorizationDecision decision = manager.authorize(authentication, object);
199+
if (decision.isGranted()) {
200+
return decision;
201+
}
202+
factorErrors.addAll(decision.getFactorErrors());
203+
}
204+
return new FactorAuthorizationDecision(List.copyOf(factorErrors));
205+
}
206+
207+
}
208+
182209
/**
183210
* A builder for {@link AllRequiredFactorsAuthorizationManager}.
184211
*

core/src/test/java/org/springframework/security/authorization/AllRequiredFactorsAuthorizationManagerTests.java

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ class AllRequiredFactorsAuthorizationManagerTests {
4444

4545
private static final Object DOES_NOT_MATTER = new Object();
4646

47-
private static final RequiredFactor REQUIRED_PASSWORD = RequiredFactor
47+
private static RequiredFactor REQUIRED_PASSWORD = RequiredFactor
4848
.withAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)
4949
.build();
5050

51-
private static final RequiredFactor EXPIRING_PASSWORD = RequiredFactor
51+
private static RequiredFactor EXPIRING_PASSWORD = RequiredFactor
5252
.withAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)
5353
.validDuration(Duration.ofHours(1))
5454
.build();
@@ -255,7 +255,7 @@ void anyOfWhenOneGrantedThenGranted() {
255255
}
256256

257257
@Test
258-
void anyOfWhenRequiredFactorMissingThenMissing() {
258+
void anyOfWhenSameAuthorityDifferentValidDurationThenBothErrorsReturned() {
259259
AllRequiredFactorsAuthorizationManager<Object> passwordAndOtt = AllRequiredFactorsAuthorizationManager.builder()
260260
.requireFactor(REQUIRED_PASSWORD)
261261
.requireFactor(REQUIRED_OTT)
@@ -273,10 +273,62 @@ void anyOfWhenRequiredFactorMissingThenMissing() {
273273
AuthorizationResult result = anyOf.authorize(() -> authentication, DOES_NOT_MATTER);
274274
assertThat(result).asInstanceOf(type(FactorAuthorizationDecision.class)).satisfies((decision) -> {
275275
assertThat(decision.isGranted()).isFalse();
276-
assertThat(decision.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_OTT));
276+
assertThat(decision.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_OTT),
277+
RequiredFactorError.createMissing(EXPIRING_OTT));
277278
});
278279
}
279280

281+
@Test
282+
void anyOfWhenIdenticalErrorInMultipleManagersThenDeduplicated() {
283+
AllRequiredFactorsAuthorizationManager<Object> passwordAndOtt = AllRequiredFactorsAuthorizationManager.builder()
284+
.requireFactor(REQUIRED_PASSWORD)
285+
.requireFactor(REQUIRED_OTT)
286+
.build();
287+
AllRequiredFactorsAuthorizationManager<Object> passwordOnly = AllRequiredFactorsAuthorizationManager.builder()
288+
.requireFactor(REQUIRED_PASSWORD)
289+
.build();
290+
AuthorizationManager<Object> anyOf = AllRequiredFactorsAuthorizationManager.anyOf(passwordAndOtt, passwordOnly);
291+
Authentication authentication = new TestingAuthenticationToken("user", "password", "ROLE_USER");
292+
AuthorizationResult result = anyOf.authorize(() -> authentication, DOES_NOT_MATTER);
293+
assertThat(result).asInstanceOf(type(FactorAuthorizationDecision.class)).satisfies((decision) -> {
294+
assertThat(decision.isGranted()).isFalse();
295+
assertThat(decision.getFactorErrors()).containsOnly(RequiredFactorError.createMissing(REQUIRED_PASSWORD),
296+
RequiredFactorError.createMissing(REQUIRED_OTT));
297+
});
298+
}
299+
300+
@Test
301+
void anyOfWhenDeniedThenErrorsRetainedInManagerOrder() {
302+
AllRequiredFactorsAuthorizationManager<Object> passwordOnly = AllRequiredFactorsAuthorizationManager.builder()
303+
.requireFactor(REQUIRED_PASSWORD)
304+
.build();
305+
AllRequiredFactorsAuthorizationManager<Object> ottOnly = AllRequiredFactorsAuthorizationManager.builder()
306+
.requireFactor(REQUIRED_OTT)
307+
.build();
308+
AuthorizationManager<Object> anyOf = AllRequiredFactorsAuthorizationManager.anyOf(passwordOnly, ottOnly);
309+
Authentication authentication = new TestingAuthenticationToken("user", "password", "ROLE_USER");
310+
AuthorizationResult result = anyOf.authorize(() -> authentication, DOES_NOT_MATTER);
311+
assertThat(result).asInstanceOf(type(FactorAuthorizationDecision.class)).satisfies((decision) -> {
312+
assertThat(decision.isGranted()).isFalse();
313+
assertThat(decision.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_PASSWORD),
314+
RequiredFactorError.createMissing(REQUIRED_OTT));
315+
});
316+
}
317+
318+
@Test
319+
void anyOfWhenEmptyManagersThenIllegalArgumentException() {
320+
assertThatIllegalArgumentException().isThrownBy(() -> AllRequiredFactorsAuthorizationManager.anyOf());
321+
}
322+
323+
@Test
324+
void anyOfWhenSingleManagerThenReturnsSameInstance() {
325+
AllRequiredFactorsAuthorizationManager<Object> manager = AllRequiredFactorsAuthorizationManager.builder()
326+
.requireFactor(REQUIRED_PASSWORD)
327+
.build();
328+
AuthorizationManager<Object> result = AllRequiredFactorsAuthorizationManager.anyOf(manager);
329+
assertThat(result == manager).isTrue();
330+
}
331+
280332
@Test
281333
void setClockWhenNullThenIllegalArgumentException() {
282334
AllRequiredFactorsAuthorizationManager<Object> allFactors = AllRequiredFactorsAuthorizationManager.builder()

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,23 @@ include-code::./ValidDurationConfiguration[tag=httpSecurity,indent=0]
125125
<5> Otherwise, authentication is required, but it does not care if it is a password or how long ago authentication occurred
126126
<6> Set up the authentication mechanisms that can provide the required factors.
127127

128+
[[all-factors-anyof]]
129+
== AllRequiredFactorsAuthorizationManager.anyOf
130+
131+
In the previous examples, access requires satisfying that the user has authenticated with all factors.
132+
There are times when an application wants to allow users to satisfy one of several different combinations of factors.
133+
javadoc:org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager#anyOf(AllRequiredFactorsAuthorizationManager...)[AllRequiredFactorsAuthorizationManager.anyOf] grants access if at least one of the provided combinations of factors is satisfied.
134+
135+
Consider a scenario where a user can authenticate with WebAuthn alone, or with both a password and a one-time token.
136+
137+
include-code::./AnyOfRequiredFactorsConfiguration[tag=httpSecurity,indent=0]
138+
<1> Require WebAuthn
139+
<2> Require both a password and a one-time token
140+
<3> Combine the combinations of factors with `anyOf`, granting access if either is satisfied
141+
<4> URLs that begin with `/protected/**` require the user to satisfy either combination of factors
142+
<5> All other requests require only authentication
143+
<6> Set up the authentication mechanisms that can provide the required factors
144+
128145
[[programmatic-mfa]]
129146
== Programmatic MFA
130147

docs/modules/ROOT/pages/whats-new.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
== Core
55

66
* https://github.com/spring-projects/spring-security/pull/18634[gh-18634] - Added javadoc:org.springframework.security.util.matcher.InetAddressMatcher[]
7+
* https://github.com/spring-projects/spring-security/issues/18960[gh-18960] - Added xref:servlet/authentication/mfa.adoc#all-factors-anyof[AllRequiredFactorsAuthorizationManager.anyOf]
78

89
== Web
910
* https://github.com/spring-projects/spring-security/issues/18755[gh-18755] - Include `charset` in `WWW-Authenticate` header
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package org.springframework.security.docs.servlet.authentication.allfactorsanyof;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager;
6+
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
7+
import org.springframework.security.config.Customizer;
8+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
9+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
10+
import org.springframework.security.core.userdetails.User;
11+
import org.springframework.security.core.userdetails.UserDetailsService;
12+
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
13+
import org.springframework.security.web.SecurityFilterChain;
14+
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
15+
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
16+
17+
@EnableWebSecurity
18+
@Configuration(proxyBeanMethods = false)
19+
class AnyOfRequiredFactorsConfiguration {
20+
21+
// tag::httpSecurity[]
22+
@Bean
23+
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
24+
// @formatter:off
25+
// <1>
26+
AllRequiredFactorsAuthorizationManager<Object> webauthn = AllRequiredFactorsAuthorizationManager
27+
.<Object>builder()
28+
.requireFactor((factor) -> factor.webauthnAuthority())
29+
.build();
30+
// <2>
31+
AllRequiredFactorsAuthorizationManager<Object> passwordAndOtt = AllRequiredFactorsAuthorizationManager
32+
.<Object>builder()
33+
.requireFactor((factor) -> factor.passwordAuthority())
34+
.requireFactor((factor) -> factor.ottAuthority())
35+
.build();
36+
// <3>
37+
DefaultAuthorizationManagerFactory<Object> mfa = new DefaultAuthorizationManagerFactory<>();
38+
mfa.setAdditionalAuthorization(AllRequiredFactorsAuthorizationManager.anyOf(webauthn, passwordAndOtt));
39+
http
40+
.authorizeHttpRequests((authorize) -> authorize
41+
// <4>
42+
.requestMatchers("/protected/**").access(mfa.authenticated())
43+
// <5>
44+
.anyRequest().authenticated()
45+
)
46+
// <6>
47+
.formLogin(Customizer.withDefaults())
48+
.oneTimeTokenLogin(Customizer.withDefaults())
49+
.webAuthn((webAuthn) -> webAuthn
50+
.rpName("Spring Security")
51+
.rpId("example.com")
52+
.allowedOrigins("https://example.com")
53+
);
54+
// @formatter:on
55+
return http.build();
56+
}
57+
58+
// end::httpSecurity[]
59+
60+
@Bean
61+
UserDetailsService userDetailsService() {
62+
return new InMemoryUserDetailsManager(
63+
User.withDefaultPasswordEncoder()
64+
.username("user")
65+
.password("password")
66+
.authorities("app")
67+
.build()
68+
);
69+
}
70+
71+
@Bean
72+
OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
73+
return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
74+
}
75+
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package org.springframework.security.kt.docs.servlet.authentication.allfactorsanyof
2+
3+
import org.springframework.context.annotation.Bean
4+
import org.springframework.context.annotation.Configuration
5+
import org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager
6+
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory
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.userdetails.User
11+
import org.springframework.security.core.userdetails.UserDetailsService
12+
import org.springframework.security.provisioning.InMemoryUserDetailsManager
13+
import org.springframework.security.web.SecurityFilterChain
14+
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
15+
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
16+
17+
@EnableWebSecurity
18+
@Configuration(proxyBeanMethods = false)
19+
internal class AnyOfRequiredFactorsConfiguration {
20+
21+
// tag::httpSecurity[]
22+
@Bean
23+
@Throws(Exception::class)
24+
fun springSecurity(http: HttpSecurity): SecurityFilterChain? {
25+
// @formatter:off
26+
// <1>
27+
val webauthn = AllRequiredFactorsAuthorizationManager.builder<Any>()
28+
.requireFactor { factor -> factor.webauthnAuthority() }
29+
.build()
30+
// <2>
31+
val passwordAndOtt = AllRequiredFactorsAuthorizationManager.builder<Any>()
32+
.requireFactor { factor -> factor.passwordAuthority() }
33+
.requireFactor { factor -> factor.ottAuthority() }
34+
.build()
35+
// <3>
36+
val mfa = DefaultAuthorizationManagerFactory<Any>()
37+
mfa.setAdditionalAuthorization(AllRequiredFactorsAuthorizationManager.anyOf(webauthn, passwordAndOtt))
38+
http {
39+
authorizeHttpRequests {
40+
// <4>
41+
authorize("/protected/**", mfa.authenticated())
42+
// <5>
43+
authorize(anyRequest, authenticated)
44+
}
45+
// <6>
46+
formLogin { }
47+
oneTimeTokenLogin { }
48+
webAuthn {
49+
rpName = "Spring Security"
50+
rpId = "example.com"
51+
allowedOrigins = setOf("https://example.com")
52+
}
53+
}
54+
// @formatter:on
55+
return http.build()
56+
}
57+
58+
// end::httpSecurity[]
59+
60+
@Suppress("DEPRECATION")
61+
@Bean
62+
fun userDetailsService(): UserDetailsService {
63+
return InMemoryUserDetailsManager(
64+
User.withDefaultPasswordEncoder()
65+
.username("user")
66+
.password("password")
67+
.authorities("app")
68+
.build()
69+
)
70+
}
71+
72+
@Bean
73+
fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
74+
return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
75+
}
76+
77+
}

0 commit comments

Comments
 (0)