Skip to content

Commit f5da631

Browse files
committed
Add MultiTenantAuthenticationManagerResolver
A class with a number of handy request-based implementations of AuthenticationManagerResolver targeted at common multi-tenancy scenarios. Fixes: gh-6976
1 parent ecb13aa commit f5da631

File tree

3 files changed

+338
-10
lines changed

3 files changed

+338
-10
lines changed

samples/boot/oauth2resourceserver-multitenancy/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
import java.util.HashMap;
1919
import java.util.Map;
20-
import java.util.Optional;
2120
import javax.servlet.http.HttpServletRequest;
2221

2322
import org.springframework.beans.factory.annotation.Value;
@@ -27,12 +26,14 @@
2726
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
2827
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
2928
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
30-
import org.springframework.security.oauth2.server.resource.introspection.NimbusOAuth2TokenIntrospectionClient;
31-
import org.springframework.security.oauth2.server.resource.introspection.OAuth2TokenIntrospectionClient;
3229
import org.springframework.security.oauth2.jwt.JwtDecoder;
3330
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
3431
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
3532
import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionAuthenticationProvider;
33+
import org.springframework.security.oauth2.server.resource.introspection.NimbusOAuth2TokenIntrospectionClient;
34+
import org.springframework.security.oauth2.server.resource.introspection.OAuth2TokenIntrospectionClient;
35+
36+
import static org.springframework.security.web.authentication.MultiTenantAuthenticationManagerResolver.resolveFromPath;
3637

3738
/**
3839
* @author Josh Cummings
@@ -64,13 +65,7 @@ AuthenticationManagerResolver<HttpServletRequest> multitenantAuthenticationManag
6465
Map<String, AuthenticationManager> authenticationManagers = new HashMap<>();
6566
authenticationManagers.put("tenantOne", jwt());
6667
authenticationManagers.put("tenantTwo", opaque());
67-
return request -> {
68-
String[] pathParts = request.getRequestURI().split("/");
69-
String tenantId = pathParts.length > 0 ? pathParts[1] : null;
70-
return Optional.ofNullable(tenantId)
71-
.map(authenticationManagers::get)
72-
.orElseThrow(() -> new IllegalArgumentException("unknown tenant"));
73-
};
68+
return resolveFromPath(authenticationManagers::get);
7469
}
7570

7671
AuthenticationManager jwt() {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*
2+
* Copyright 2002-2019 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.web.authentication;
18+
19+
import java.util.Optional;
20+
import javax.servlet.http.HttpServletRequest;
21+
22+
import org.springframework.core.convert.converter.Converter;
23+
import org.springframework.security.authentication.AuthenticationManager;
24+
import org.springframework.security.authentication.AuthenticationManagerResolver;
25+
import org.springframework.util.Assert;
26+
import org.springframework.web.util.UriComponents;
27+
import org.springframework.web.util.UriComponentsBuilder;
28+
29+
/**
30+
* An implementation of {@link AuthenticationManagerResolver} that separates the tasks of
31+
* extracting the request's tenant identifier and looking up an {@link AuthenticationManager}
32+
* by that tenant identifier.
33+
*
34+
* @author Josh Cummings
35+
* @since 5.2
36+
* @see AuthenticationManagerResolver
37+
*/
38+
public final class MultiTenantAuthenticationManagerResolver<T> implements AuthenticationManagerResolver<HttpServletRequest> {
39+
40+
private final Converter<HttpServletRequest, AuthenticationManager> authenticationManagerResolver;
41+
42+
/**
43+
* Constructs a {@link MultiTenantAuthenticationManagerResolver} with the provided parameters
44+
*
45+
* @param tenantResolver
46+
* @param authenticationManagerResolver
47+
*/
48+
public MultiTenantAuthenticationManagerResolver
49+
(Converter<HttpServletRequest, T> tenantResolver,
50+
Converter<T, AuthenticationManager> authenticationManagerResolver) {
51+
52+
Assert.notNull(tenantResolver, "tenantResolver cannot be null");
53+
Assert.notNull(authenticationManagerResolver, "authenticationManagerResolver cannot be null");
54+
55+
this.authenticationManagerResolver = request -> {
56+
Optional<T> context = Optional.ofNullable(tenantResolver.convert(request));
57+
return context.map(authenticationManagerResolver::convert)
58+
.orElseThrow(() -> new IllegalArgumentException
59+
("Could not resolve AuthenticationManager by reference " + context.orElse(null)));
60+
};
61+
}
62+
63+
@Override
64+
public AuthenticationManager resolve(HttpServletRequest context) {
65+
return this.authenticationManagerResolver.convert(context);
66+
}
67+
68+
/**
69+
* Creates an {@link AuthenticationManagerResolver} that will use a hostname's first label as
70+
* the resolution key for the underlying {@link AuthenticationManagerResolver}.
71+
*
72+
* For example, you might have a set of {@link AuthenticationManager}s defined like so:
73+
*
74+
* <pre>
75+
* Map<String, AuthenticationManager> authenticationManagers = new HashMap<>();
76+
* authenticationManagers.put("tenantOne", managerOne());
77+
* authenticationManagers.put("tenantTwo", managerTwo());
78+
* </pre>
79+
*
80+
* And that your system serves hostnames like <pre>https://tenantOne.example.org</pre>.
81+
*
82+
* Then, you could create an {@link AuthenticationManagerResolver} that uses the "tenantOne" value from
83+
* the hostname to resolve Tenant One's {@link AuthenticationManager} like so:
84+
*
85+
* <pre>
86+
* AuthenticationManagerResolver<HttpServletRequest> resolver =
87+
* resolveFromSubdomain(authenticationManagers::get);
88+
* </pre>
89+
*
90+
* {@link HttpServletRequest}
91+
* @param resolver A {@link String}-resolving {@link AuthenticationManagerResolver}
92+
* @return A hostname-resolving {@link AuthenticationManagerResolver}
93+
*/
94+
public static AuthenticationManagerResolver<HttpServletRequest>
95+
resolveFromSubdomain(Converter<String, AuthenticationManager> resolver) {
96+
97+
return new MultiTenantAuthenticationManagerResolver<>(request ->
98+
Optional.ofNullable(request.getServerName())
99+
.map(host -> host.split("\\."))
100+
.filter(segments -> segments.length > 0)
101+
.map(segments -> segments[0]).orElse(null), resolver);
102+
}
103+
104+
/**
105+
* Creates an {@link AuthenticationManagerResolver} that will use a request path's first segment as
106+
* the resolution key for the underlying {@link AuthenticationManagerResolver}.
107+
*
108+
* For example, you might have a set of {@link AuthenticationManager}s defined like so:
109+
*
110+
* <pre>
111+
* Map<String, AuthenticationManager> authenticationManagers = new HashMap<>();
112+
* authenticationManagers.put("tenantOne", managerOne());
113+
* authenticationManagers.put("tenantTwo", managerTwo());
114+
* </pre>
115+
*
116+
* And that your system serves requests like <pre>https://example.org/tenantOne</pre>.
117+
*
118+
* Then, you could create an {@link AuthenticationManagerResolver} that uses the "tenantOne" value from
119+
* the request to resolve Tenant One's {@link AuthenticationManager} like so:
120+
*
121+
* <pre>
122+
* AuthenticationManagerResolver<HttpServletRequest> resolver =
123+
* resolveFromPath(authenticationManagers::get);
124+
* </pre>
125+
*
126+
* {@link HttpServletRequest}
127+
* @param resolver A {@link String}-resolving {@link AuthenticationManagerResolver}
128+
* @return A path-resolving {@link AuthenticationManagerResolver}
129+
*/
130+
public static AuthenticationManagerResolver<HttpServletRequest>
131+
resolveFromPath(Converter<String, AuthenticationManager> resolver) {
132+
133+
return new MultiTenantAuthenticationManagerResolver<>(request ->
134+
Optional.ofNullable(request.getRequestURI())
135+
.map(UriComponentsBuilder::fromUriString)
136+
.map(UriComponentsBuilder::build)
137+
.map(UriComponents::getPathSegments)
138+
.filter(segments -> !segments.isEmpty())
139+
.map(segments -> segments.get(0)).orElse(null), resolver);
140+
}
141+
142+
/**
143+
* Creates an {@link AuthenticationManagerResolver} that will use a request headers's value as
144+
* the resolution key for the underlying {@link AuthenticationManagerResolver}.
145+
*
146+
* For example, you might have a set of {@link AuthenticationManager}s defined like so:
147+
*
148+
* <pre>
149+
* Map<String, AuthenticationManager> authenticationManagers = new HashMap<>();
150+
* authenticationManagers.put("tenantOne", managerOne());
151+
* authenticationManagers.put("tenantTwo", managerTwo());
152+
* </pre>
153+
*
154+
* And that your system serves requests with a header like <pre>X-Tenant-Id: tenantOne</pre>.
155+
*
156+
* Then, you could create an {@link AuthenticationManagerResolver} that uses the "tenantOne" value from
157+
* the request to resolve Tenant One's {@link AuthenticationManager} like so:
158+
*
159+
* <pre>
160+
* AuthenticationManagerResolver<HttpServletRequest> resolver =
161+
* resolveFromHeader("X-Tenant-Id", authenticationManagers::get);
162+
* </pre>
163+
*
164+
* {@link HttpServletRequest}
165+
* @param resolver A {@link String}-resolving {@link AuthenticationManagerResolver}
166+
* @return A header-resolving {@link AuthenticationManagerResolver}
167+
*/
168+
public static AuthenticationManagerResolver<HttpServletRequest>
169+
resolveFromHeader(String headerName, Converter<String, AuthenticationManager> resolver) {
170+
171+
return new MultiTenantAuthenticationManagerResolver<>
172+
(request -> request.getHeader(headerName), resolver);
173+
}
174+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* Copyright 2002-2019 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.web.authentication;
18+
19+
import java.util.Collections;
20+
import java.util.Map;
21+
import javax.servlet.http.HttpServletRequest;
22+
23+
import org.junit.Before;
24+
import org.junit.Test;
25+
import org.junit.runner.RunWith;
26+
import org.mockito.Mock;
27+
import org.mockito.junit.MockitoJUnitRunner;
28+
29+
import org.springframework.core.convert.converter.Converter;
30+
import org.springframework.security.authentication.AuthenticationManager;
31+
import org.springframework.security.authentication.AuthenticationManagerResolver;
32+
33+
import static org.assertj.core.api.Assertions.assertThat;
34+
import static org.assertj.core.api.Assertions.assertThatCode;
35+
import static org.mockito.Mockito.mock;
36+
import static org.mockito.Mockito.when;
37+
import static org.springframework.security.web.authentication.MultiTenantAuthenticationManagerResolver.resolveFromSubdomain;
38+
import static org.springframework.security.web.authentication.MultiTenantAuthenticationManagerResolver.resolveFromPath;
39+
import static org.springframework.security.web.authentication.MultiTenantAuthenticationManagerResolver.resolveFromHeader;
40+
41+
/**
42+
* Tests for {@link MultiTenantAuthenticationManagerResolver}
43+
*/
44+
@RunWith(MockitoJUnitRunner.class)
45+
public class MultiTenantAuthenticationManagerResolverTests {
46+
private static final String TENANT = "tenant";
47+
48+
@Mock
49+
AuthenticationManager authenticationManager;
50+
51+
@Mock
52+
HttpServletRequest request;
53+
54+
Map<String, AuthenticationManager> authenticationManagers;
55+
56+
@Before
57+
public void setup() {
58+
this.authenticationManagers = Collections.singletonMap(TENANT, this.authenticationManager);
59+
}
60+
61+
@Test
62+
public void resolveFromSubdomainWhenGivenResolverThenReturnsSubdomainParsingResolver() {
63+
AuthenticationManagerResolver<HttpServletRequest> fromSubdomain =
64+
resolveFromSubdomain(this.authenticationManagers::get);
65+
66+
when(this.request.getServerName()).thenReturn(TENANT + ".example.org");
67+
68+
AuthenticationManager authenticationManager = fromSubdomain.resolve(this.request);
69+
assertThat(authenticationManager).isEqualTo(this.authenticationManager);
70+
71+
when(this.request.getServerName()).thenReturn("wrong.example.org");
72+
73+
assertThatCode(() -> fromSubdomain.resolve(this.request))
74+
.isInstanceOf(IllegalArgumentException.class);
75+
76+
when(this.request.getServerName()).thenReturn("example");
77+
78+
assertThatCode(() -> fromSubdomain.resolve(this.request))
79+
.isInstanceOf(IllegalArgumentException.class);
80+
}
81+
82+
@Test
83+
public void resolveFromPathWhenGivenResolverThenReturnsPathParsingResolver() {
84+
AuthenticationManagerResolver<HttpServletRequest> fromPath =
85+
resolveFromPath(this.authenticationManagers::get);
86+
87+
when(this.request.getRequestURI()).thenReturn("/" + TENANT + "/otherthings");
88+
89+
AuthenticationManager authenticationManager = fromPath.resolve(this.request);
90+
assertThat(authenticationManager).isEqualTo(this.authenticationManager);
91+
92+
when(this.request.getRequestURI()).thenReturn("/otherthings");
93+
94+
assertThatCode(() -> fromPath.resolve(this.request))
95+
.isInstanceOf(IllegalArgumentException.class);
96+
97+
when(this.request.getRequestURI()).thenReturn("/");
98+
99+
assertThatCode(() -> fromPath.resolve(this.request))
100+
.isInstanceOf(IllegalArgumentException.class);
101+
}
102+
103+
@Test
104+
public void resolveFromHeaderWhenGivenResolverTheReturnsHeaderParsingResolver() {
105+
AuthenticationManagerResolver<HttpServletRequest> fromHeader =
106+
resolveFromHeader("X-Tenant-Id", this.authenticationManagers::get);
107+
108+
when(this.request.getHeader("X-Tenant-Id")).thenReturn(TENANT);
109+
110+
AuthenticationManager authenticationManager = fromHeader.resolve(this.request);
111+
assertThat(authenticationManager).isEqualTo(this.authenticationManager);
112+
113+
when(this.request.getHeader("X-Tenant-Id")).thenReturn("wrong");
114+
115+
assertThatCode(() -> fromHeader.resolve(this.request))
116+
.isInstanceOf(IllegalArgumentException.class);
117+
118+
when(this.request.getHeader("X-Tenant-Id")).thenReturn(null);
119+
120+
assertThatCode(() -> fromHeader.resolve(this.request))
121+
.isInstanceOf(IllegalArgumentException.class);
122+
}
123+
124+
@Test
125+
public void resolveWhenGivenTenantResolverThenResolves() {
126+
AuthenticationManagerResolver<HttpServletRequest> byRequestConverter =
127+
new MultiTenantAuthenticationManagerResolver<>(HttpServletRequest::getQueryString,
128+
this.authenticationManagers::get);
129+
130+
when(this.request.getQueryString()).thenReturn(TENANT);
131+
132+
AuthenticationManager authenticationManager = byRequestConverter.resolve(this.request);
133+
assertThat(authenticationManager).isEqualTo(this.authenticationManager);
134+
135+
when(this.request.getQueryString()).thenReturn("wrong");
136+
137+
assertThatCode(() -> byRequestConverter.resolve(this.request))
138+
.isInstanceOf(IllegalArgumentException.class);
139+
140+
when(this.request.getQueryString()).thenReturn(null);
141+
142+
assertThatCode(() -> byRequestConverter.resolve(this.request))
143+
.isInstanceOf(IllegalArgumentException.class);
144+
}
145+
146+
@Test
147+
public void constructorWhenUsingNullTenantResolverThenException() {
148+
assertThatCode(() -> new MultiTenantAuthenticationManagerResolver
149+
(null, mock(Converter.class)))
150+
.isInstanceOf(IllegalArgumentException.class);
151+
}
152+
153+
@Test
154+
public void constructorWhenUsingNullAuthenticationManagerResolverThenException() {
155+
assertThatCode(() -> new MultiTenantAuthenticationManagerResolver
156+
(mock(Converter.class), null))
157+
.isInstanceOf(IllegalArgumentException.class);
158+
}
159+
}

0 commit comments

Comments
 (0)