Przeglądaj źródła

Add JwtIssuerReactiveAuthenticationManagerResolver

Fixes gh-7857
Josh Cummings 5 lat temu
rodzic
commit
a90e579350

+ 71 - 0
docs/manual/src/docs/asciidoc/_includes/reactive/oauth2/resource-server.adoc

@@ -1005,6 +1005,77 @@ ReactiveOpaqueTokenIntrospector introspector() {
 }
 ----
 
+[[oauth2resourceserver-multitenancy]]
+== Multi-tenancy
+
+A resource server is considered multi-tenant when there are multiple strategies for verifying a bearer token, keyed by some tenant identifier.
+
+For example, your resource server may accept bearer tokens from two different authorization servers.
+Or, your authorization server may represent a multiplicity of issuers.
+
+In each case, there are two things that need to be done and trade-offs associated with how you choose to do them:
+
+1. Resolve the tenant
+2. Propagate the tenant
+
+=== Resolving the Tenant By Claim
+
+One way to differentiate tenants is by the issuer claim. Since the issuer claim accompanies signed JWTs, this can be done with the `JwtIssuerReactiveAuthenticationManagerResolver`, like so:
+
+[source,java]
+----
+JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerReactiveAuthenticationManagerResolver
+    ("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");
+
+http
+    .authorizeRequests(authorize -> authorize
+        .anyRequest().authenticated()
+    )
+    .oauth2ResourceServer(oauth2 -> oauth2
+        .authenticationManagerResolver(authenticationManagerResolver)
+    );
+----
+
+This is nice because the issuer endpoints are loaded lazily.
+In fact, the corresponding `JwtReactiveAuthenticationManager` is instantiated only when the first request with the corresponding issuer is sent.
+This allows for an application startup that is independent from those authorization servers being up and available.
+
+==== Dynamic Tenants
+
+Of course, you may not want to restart the application each time a new tenant is added.
+In this case, you can configure the `JwtIssuerReactiveAuthenticationManagerResolver` with a repository of `ReactiveAuthenticationManager` instances, which you can edit at runtime, like so:
+
+[source,java]
+----
+private Mono<ReactiveAuthenticationManager> addManager(
+		Map<String, ReactiveAuthenticationManager> authenticationManagers, String issuer) {
+
+	return Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(issuer))
+            .subscribeOn(Schedulers.boundedElastic())
+            .map(JwtReactiveAuthenticationManager::new)
+            .doOnNext(authenticationManager -> authenticationManagers.put(issuer, authenticationManager));
+}
+
+// ...
+
+JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver =
+        new JwtIssuerReactiveAuthenticationManagerResolver(authenticationManagers::get);
+
+http
+    .authorizeRequests(authorize -> authorize
+        .anyRequest().authenticated()
+    )
+    .oauth2ResourceServer(oauth2 -> oauth2
+        .authenticationManagerResolver(authenticationManagerResolver)
+    );
+----
+
+In this case, you construct `JwtIssuerReactiveAuthenticationManagerResolver` with a strategy for obtaining the `ReactiveAuthenticationManager` given the issuer.
+This approach allows us to add and remove elements from the repository (shown as a `Map` in the snippet) at runtime.
+
+NOTE: It would be unsafe to simply take any issuer and construct an `ReactiveAuthenticationManager` from it.
+The issuer should be one that the code can verify from a trusted source like a whitelist.
+
 == Bearer Token Propagation
 
 Now that you're in possession of a bearer token, it might be handy to pass that to downstream services.

+ 176 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolver.java

@@ -0,0 +1,176 @@
+/*
+ * Copyright 2002-2020 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.oauth2.server.resource.authentication;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Predicate;
+
+import com.nimbusds.jwt.JWTParser;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.lang.NonNull;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
+import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.jwt.ReactiveJwtDecoders;
+import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
+import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
+import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter;
+import org.springframework.util.Assert;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * An implementation of {@link ReactiveAuthenticationManagerResolver} that resolves a JWT-based
+ * {@link ReactiveAuthenticationManager} based on the
+ * <a href="https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> in a
+ * signed JWT (JWS).
+ *
+ * To use, this class must be able to determine whether or not the `iss` claim is trusted. Recall that
+ * anyone can stand up an authorization server and issue valid tokens to a resource server. The simplest way
+ * to achieve this is to supply a whitelist of trusted issuers in the constructor.
+ *
+ * This class derives the Issuer from the `iss` claim found in the {@link ServerWebExchange}'s
+ * <a href="https://tools.ietf.org/html/rfc6750#section-1.2" target="_blank">Bearer Token</a>.
+ *
+ * @author Josh Cummings
+ * @since 5.3
+ */
+public final class JwtIssuerReactiveAuthenticationManagerResolver
+		implements ReactiveAuthenticationManagerResolver<ServerWebExchange> {
+
+	private final ReactiveAuthenticationManagerResolver<String> issuerAuthenticationManagerResolver;
+	private final Converter<ServerWebExchange, Mono<String>> issuerConverter = new JwtClaimIssuerConverter();
+
+	/**
+	 * Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters
+	 *
+	 * @param trustedIssuers a whitelist of trusted issuers
+	 */
+	public JwtIssuerReactiveAuthenticationManagerResolver(String... trustedIssuers) {
+		this(Arrays.asList(trustedIssuers));
+	}
+
+	/**
+	 * Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters
+	 *
+	 * @param trustedIssuers a whitelist of trusted issuers
+	 */
+	public JwtIssuerReactiveAuthenticationManagerResolver(Collection<String> trustedIssuers) {
+		Assert.notEmpty(trustedIssuers, "trustedIssuers cannot be empty");
+		this.issuerAuthenticationManagerResolver =
+				new TrustedIssuerJwtAuthenticationManagerResolver
+						(Collections.unmodifiableCollection(trustedIssuers)::contains);
+	}
+
+	/**
+	 * Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters
+	 *
+	 * Note that the {@link ReactiveAuthenticationManagerResolver} provided in this constructor will need to
+	 * verify that the issuer is trusted. This should be done via a whitelist.
+	 *
+	 * One way to achieve this is with a {@link Map} where the keys are the known issuers:
+	 * <pre>
+	 *     Map&lt;String, ReactiveAuthenticationManager&gt; authenticationManagers = new HashMap&lt;&gt;();
+	 *     authenticationManagers.put("https://issuerOne.example.org", managerOne);
+	 *     authenticationManagers.put("https://issuerTwo.example.org", managerTwo);
+	 *     JwtIssuerReactiveAuthenticationManagerResolver resolver = new JwtIssuerReactiveAuthenticationManagerResolver
+	 *     	(issuer -> Mono.justOrEmpty(authenticationManagers.get(issuer));
+	 * </pre>
+	 *
+	 * The keys in the {@link Map} are the whitelist.
+	 *
+	 * @param issuerAuthenticationManagerResolver a strategy for resolving the {@link ReactiveAuthenticationManager}
+	 * by the issuer
+	 */
+	public JwtIssuerReactiveAuthenticationManagerResolver
+			(ReactiveAuthenticationManagerResolver<String> issuerAuthenticationManagerResolver) {
+
+		Assert.notNull(issuerAuthenticationManagerResolver, "issuerAuthenticationManagerResolver cannot be null");
+		this.issuerAuthenticationManagerResolver = issuerAuthenticationManagerResolver;
+	}
+
+	/**
+	 * Return an {@link AuthenticationManager} based off of the `iss` claim found in the request's bearer token
+	 *
+	 * @throws OAuth2AuthenticationException if the bearer token is malformed or an {@link ReactiveAuthenticationManager}
+	 * can't be derived from the issuer
+	 */
+	@Override
+	public Mono<ReactiveAuthenticationManager> resolve(ServerWebExchange exchange) {
+		return this.issuerConverter.convert(exchange)
+				.flatMap(issuer ->
+					this.issuerAuthenticationManagerResolver.resolve(issuer).switchIfEmpty(
+							Mono.error(new InvalidBearerTokenException("Invalid issuer " + issuer)))
+				);
+	}
+
+	private static class JwtClaimIssuerConverter
+			implements Converter<ServerWebExchange, Mono<String>> {
+
+		private final ServerBearerTokenAuthenticationConverter converter =
+				new ServerBearerTokenAuthenticationConverter();
+
+		@Override
+		public Mono<String> convert(@NonNull ServerWebExchange exchange) {
+			return this.converter.convert(exchange)
+					.cast(BearerTokenAuthenticationToken.class)
+					.flatMap(this::issuer);
+		}
+
+		private Mono<String> issuer(BearerTokenAuthenticationToken token) {
+			try {
+				String issuer = JWTParser.parse(token.getToken()).getJWTClaimsSet().getIssuer();
+				return Mono.justOrEmpty(issuer).switchIfEmpty(
+						Mono.error(new InvalidBearerTokenException("Missing issuer")));
+			} catch (Exception e) {
+				return Mono.error(new InvalidBearerTokenException(e.getMessage()));
+			}
+		}
+	}
+
+	private static class TrustedIssuerJwtAuthenticationManagerResolver
+			implements ReactiveAuthenticationManagerResolver<String> {
+
+		private final Map<String, Mono<? extends ReactiveAuthenticationManager>> authenticationManagers =
+				new ConcurrentHashMap<>();
+		private final Predicate<String> trustedIssuer;
+
+		TrustedIssuerJwtAuthenticationManagerResolver(Predicate<String> trustedIssuer) {
+			this.trustedIssuer = trustedIssuer;
+		}
+
+		@Override
+		public Mono<ReactiveAuthenticationManager> resolve(String issuer) {
+			return Mono.just(issuer)
+					.filter(this.trustedIssuer)
+					.flatMap(iss ->
+						this.authenticationManagers.computeIfAbsent(iss, k ->
+							Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(iss))
+									.subscribeOn(Schedulers.boundedElastic())
+									.map(JwtReactiveAuthenticationManager::new)
+									.cache())
+					);
+		}
+	}
+}

+ 186 - 0
oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/JwtIssuerReactiveAuthenticationManagerResolverTests.java

@@ -0,0 +1,186 @@
+/*
+ * Copyright 2002-2020 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.oauth2.server.resource.authentication;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSObject;
+import com.nimbusds.jose.Payload;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.PlainJWT;
+import net.minidev.json.JSONObject;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
+import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.jose.TestKeys;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.Mockito.mock;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.ISS;
+
+/**
+ * Tests for {@link JwtIssuerReactiveAuthenticationManagerResolver}
+ */
+public class JwtIssuerReactiveAuthenticationManagerResolverTests {
+	private static final String DEFAULT_RESPONSE_TEMPLATE = "{\n"
+			+ "    \"issuer\": \"%s\", \n"
+			+ "    \"jwks_uri\": \"%s/.well-known/jwks.json\" \n"
+			+ "}";
+
+	private String jwt = jwt("iss", "trusted");
+	private String evil = jwt("iss", "\"");
+	private String noIssuer = jwt("sub", "sub");
+
+	@Test
+	public void resolveWhenUsingTrustedIssuerThenReturnsAuthenticationManager() throws Exception {
+		try (MockWebServer server = new MockWebServer()) {
+			String issuer = server.url("").toString();
+			server.enqueue(new MockResponse()
+					.setResponseCode(200)
+					.setHeader("Content-Type", "application/json")
+					.setBody(String.format(DEFAULT_RESPONSE_TEMPLATE, issuer, issuer)));
+			JWSObject jws = new JWSObject(new JWSHeader(JWSAlgorithm.RS256),
+					new Payload(new JSONObject(Collections.singletonMap(ISS, issuer))));
+			jws.sign(new RSASSASigner(TestKeys.DEFAULT_PRIVATE_KEY));
+
+			JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver =
+					new JwtIssuerReactiveAuthenticationManagerResolver(issuer);
+			MockServerWebExchange exchange = withBearerToken(jws.serialize());
+
+			ReactiveAuthenticationManager authenticationManager =
+					authenticationManagerResolver.resolve(exchange).block();
+			assertThat(authenticationManager).isNotNull();
+
+			ReactiveAuthenticationManager cachedAuthenticationManager =
+					authenticationManagerResolver.resolve(exchange).block();
+			assertThat(authenticationManager).isSameAs(cachedAuthenticationManager);
+		}
+	}
+
+	@Test
+	public void resolveWhenUsingUntrustedIssuerThenException() {
+		JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerReactiveAuthenticationManagerResolver("other", "issuers");
+		MockServerWebExchange exchange = withBearerToken(this.jwt);
+
+		assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessageContaining("Invalid issuer");
+	}
+
+	@Test
+	public void resolveWhenUsingCustomIssuerAuthenticationManagerResolverThenUses() {
+		ReactiveAuthenticationManager authenticationManager = mock(ReactiveAuthenticationManager.class);
+		JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerReactiveAuthenticationManagerResolver(issuer -> Mono.just(authenticationManager));
+		MockServerWebExchange exchange = withBearerToken(this.jwt);
+
+		assertThat(authenticationManagerResolver.resolve(exchange).block())
+				.isSameAs(authenticationManager);
+	}
+
+	@Test
+	public void resolveWhenUsingExternalSourceThenRespondsToChanges() {
+		MockServerWebExchange exchange = withBearerToken(this.jwt);
+
+		Map<String, ReactiveAuthenticationManager> authenticationManagers = new HashMap<>();
+		JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerReactiveAuthenticationManagerResolver(issuer -> Mono.justOrEmpty(authenticationManagers.get(issuer)));
+		assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessageContaining("Invalid issuer");
+
+		ReactiveAuthenticationManager authenticationManager = mock(ReactiveAuthenticationManager.class);
+		authenticationManagers.put("trusted", authenticationManager);
+		assertThat(authenticationManagerResolver.resolve(exchange).block())
+				.isSameAs(authenticationManager);
+
+		authenticationManagers.clear();
+		assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessageContaining("Invalid issuer");
+	}
+
+	@Test
+	public void resolveWhenBearerTokenMalformedThenException() {
+		JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerReactiveAuthenticationManagerResolver("trusted");
+		MockServerWebExchange exchange = withBearerToken("jwt");
+		assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessageNotContaining("Invalid issuer");
+	}
+
+	@Test
+	public void resolveWhenBearerTokenNoIssuerThenException() {
+		JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerReactiveAuthenticationManagerResolver("trusted");
+		MockServerWebExchange exchange = withBearerToken(this.noIssuer);
+		assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessageContaining("Missing issuer");
+	}
+
+	@Test
+	public void resolveWhenBearerTokenEvilThenGenericException() {
+		JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver =
+				new JwtIssuerReactiveAuthenticationManagerResolver("trusted");
+		MockServerWebExchange exchange = withBearerToken(this.evil);
+		assertThatCode(() -> authenticationManagerResolver.resolve(exchange).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.hasMessage("Invalid token");
+	}
+
+	@Test
+	public void constructorWhenNullOrEmptyIssuersThenException() {
+		assertThatCode(() -> new JwtIssuerReactiveAuthenticationManagerResolver((Collection) null))
+				.isInstanceOf(IllegalArgumentException.class);
+		assertThatCode(() -> new JwtIssuerReactiveAuthenticationManagerResolver(Collections.emptyList()))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void constructorWhenNullAuthenticationManagerResolverThenException() {
+		assertThatCode(() -> new JwtIssuerReactiveAuthenticationManagerResolver((ReactiveAuthenticationManagerResolver) null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	private String jwt(String claim, String value) {
+		PlainJWT jwt = new PlainJWT(new JWTClaimsSet.Builder().claim(claim, value).build());
+		return jwt.serialize();
+	}
+
+	private MockServerWebExchange withBearerToken(String token) {
+		MockServerHttpRequest request = MockServerHttpRequest.get("/")
+				.header("Authorization", "Bearer " + token).build();
+		return MockServerWebExchange.from(request);
+	}
+}