Skip to content

Commit 5fe29f9

Browse files
authored
Add AllRequiredFactorsAuthorizationManager.anyOf
2 parents 12997b6 + ff820a8 commit 5fe29f9

6 files changed

Lines changed: 337 additions & 0 deletions

File tree

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020
import java.time.Instant;
2121
import java.util.ArrayList;
2222
import java.util.Collections;
23+
import java.util.LinkedHashSet;
2324
import java.util.List;
2425
import java.util.Objects;
2526
import java.util.Optional;
27+
import java.util.Set;
2628
import java.util.function.Consumer;
2729
import java.util.function.Supplier;
2830
import java.util.stream.Collectors;
@@ -40,6 +42,7 @@
4042
* is not expired for each {@link RequiredFactor}.
4143
*
4244
* @author Rob Winch
45+
* @author Evgeniy Cheban
4346
* @since 7.0
4447
* @see AuthorityAuthorizationManager
4548
*/
@@ -49,6 +52,27 @@ public final class AllRequiredFactorsAuthorizationManager<T> implements Authoriz
4952

5053
private final List<RequiredFactor> requiredFactors;
5154

55+
/**
56+
* Creates an {@link AuthorizationManager} that grants access if at least one
57+
* {@link AllRequiredFactorsAuthorizationManager} granted. When all managers deny,
58+
* collects the unique {@link RequiredFactorError}s from each manager.
59+
* @param <T> the type of object that is being authorized
60+
* @param managers the {@link AllRequiredFactorsAuthorizationManager}s to use; cannot
61+
* be empty or contain null elements
62+
* @return the {@link AuthorizationManager} to use
63+
* @since 7.1
64+
* @see AuthorizationManagers#anyOf(AuthorizationManager[])
65+
*/
66+
@SafeVarargs
67+
public static <T> AuthorizationManager<T> anyOf(AllRequiredFactorsAuthorizationManager<T>... managers) {
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);
74+
}
75+
5276
/**
5377
* Creates a new instance.
5478
* @param requiredFactors the authorities that are required.
@@ -150,6 +174,38 @@ public static <T> Builder<T> builder() {
150174
return new Builder<>();
151175
}
152176

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+
153209
/**
154210
* A builder for {@link AllRequiredFactorsAuthorizationManager}.
155211
*

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@
3131
import static org.assertj.core.api.Assertions.assertThat;
3232
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
3333
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
34+
import static org.assertj.core.api.InstanceOfAssertFactories.type;
3435

3536
/**
3637
* Test {@link AllRequiredFactorsAuthorizationManager}.
3738
*
3839
* @author Rob Winch
40+
* @author Evgeniy Cheban
3941
* @since 7.0
4042
*/
4143
class AllRequiredFactorsAuthorizationManagerTests {
@@ -51,6 +53,15 @@ class AllRequiredFactorsAuthorizationManagerTests {
5153
.validDuration(Duration.ofHours(1))
5254
.build();
5355

56+
private static final RequiredFactor REQUIRED_OTT = RequiredFactor
57+
.withAuthority(FactorGrantedAuthority.OTT_AUTHORITY)
58+
.build();
59+
60+
private static final RequiredFactor EXPIRING_OTT = RequiredFactor
61+
.withAuthority(FactorGrantedAuthority.OTT_AUTHORITY)
62+
.validDuration(Duration.ofHours(1))
63+
.build();
64+
5465
@Test
5566
void authorizeWhenGranted() {
5667
AllRequiredFactorsAuthorizationManager<Object> allFactors = AllRequiredFactorsAuthorizationManager.builder()
@@ -219,6 +230,105 @@ void authorizeWhenDifferentFactorGrantedAuthorityThenMissing() {
219230
assertThat(result.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_PASSWORD));
220231
}
221232

233+
@Test
234+
void anyOfWhenOneGrantedThenGranted() {
235+
AllRequiredFactorsAuthorizationManager<Object> expiringPasswordAndOtt = AllRequiredFactorsAuthorizationManager
236+
.builder()
237+
.requireFactor(EXPIRING_PASSWORD)
238+
.requireFactor(EXPIRING_OTT)
239+
.build();
240+
AllRequiredFactorsAuthorizationManager<Object> passwordAndExpiringOtt = AllRequiredFactorsAuthorizationManager
241+
.builder()
242+
.requireFactor(REQUIRED_PASSWORD)
243+
.requireFactor(EXPIRING_OTT)
244+
.build();
245+
FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(EXPIRING_PASSWORD.getAuthority())
246+
.issuedAt(Instant.now().minus(Duration.ofHours(2)))
247+
.build();
248+
FactorGrantedAuthority ottFactor = FactorGrantedAuthority.withAuthority(EXPIRING_OTT.getAuthority()).build();
249+
AuthorizationManager<Object> anyOf = AllRequiredFactorsAuthorizationManager.anyOf(expiringPasswordAndOtt,
250+
passwordAndExpiringOtt);
251+
Authentication authentication = new TestingAuthenticationToken("user", "password", passwordFactor, ottFactor);
252+
AuthorizationResult result = anyOf.authorize(() -> authentication, DOES_NOT_MATTER);
253+
assertThat(result).isNotNull();
254+
assertThat(result.isGranted()).isTrue();
255+
}
256+
257+
@Test
258+
void anyOfWhenSameAuthorityDifferentValidDurationThenBothErrorsReturned() {
259+
AllRequiredFactorsAuthorizationManager<Object> passwordAndOtt = AllRequiredFactorsAuthorizationManager.builder()
260+
.requireFactor(REQUIRED_PASSWORD)
261+
.requireFactor(REQUIRED_OTT)
262+
.build();
263+
AllRequiredFactorsAuthorizationManager<Object> passwordAndExpiringOtt = AllRequiredFactorsAuthorizationManager
264+
.builder()
265+
.requireFactor(REQUIRED_PASSWORD)
266+
.requireFactor(EXPIRING_OTT)
267+
.build();
268+
FactorGrantedAuthority passwordFactor = FactorGrantedAuthority.withAuthority(REQUIRED_PASSWORD.getAuthority())
269+
.build();
270+
AuthorizationManager<Object> anyOf = AllRequiredFactorsAuthorizationManager.anyOf(passwordAndOtt,
271+
passwordAndExpiringOtt);
272+
Authentication authentication = new TestingAuthenticationToken("user", "password", passwordFactor);
273+
AuthorizationResult result = anyOf.authorize(() -> authentication, DOES_NOT_MATTER);
274+
assertThat(result).asInstanceOf(type(FactorAuthorizationDecision.class)).satisfies((decision) -> {
275+
assertThat(decision.isGranted()).isFalse();
276+
assertThat(decision.getFactorErrors()).containsExactly(RequiredFactorError.createMissing(REQUIRED_OTT),
277+
RequiredFactorError.createMissing(EXPIRING_OTT));
278+
});
279+
}
280+
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+
222332
@Test
223333
void setClockWhenNullThenIllegalArgumentException() {
224334
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+
}

0 commit comments

Comments
 (0)