Reputation: 11
Use Case:
Requirements:
Issue Faced:
ReactiveAuthenticationManagerResolver
as below:import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoders;
import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@Component
public class TenantAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver<ServerWebExchange> {
private static final String ACCOUNT_URI_PREFIX = "/accounts/";
private static final String ACCOUNTS = "/accounts";
private static final String EMPTY_STRING = "";
private final Map<String, String> tenants = new HashMap<>();
private final Map<String, JwtReactiveAuthenticationManager> authenticationManagers = new HashMap<>();
public TenantAuthenticationManagerResolver() {
this.tenants.put("neo4j", "http://localhost:8080/realms/realm1");
this.tenants.put("testac", "http://localhost:8080/realms/realm2");
}
@Override
public Mono<ReactiveAuthenticationManager> resolve(ServerWebExchange exchange) {
return Mono.just(this.authenticationManagers.computeIfAbsent(toTenant(exchange), this::fromTenant));
}
private String toTenant(ServerWebExchange exchange) {
try {
String tenant = "system";
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
if (path.startsWith(ACCOUNT_URI_PREFIX)) {
tenant = extractAccountFromPath(path);
}
return tenant;
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
}
private JwtReactiveAuthenticationManager fromTenant(String tenant) {
return Optional.ofNullable(this.tenants.get(tenant))
.map(ReactiveJwtDecoders::fromIssuerLocation)
.map(JwtReactiveAuthenticationManager::new)
.orElseThrow(() -> new IllegalArgumentException("Unknown tenant"));
}
private String extractAccountFromPath(String path) {
String removeAccountTag = path.replace(ACCOUNTS, EMPTY_STRING);
int indexOfSlash = removeAccountTag.indexOf("/");
return removeAccountTag.substring(indexOfSlash + 1, removeAccountTag.indexOf("/", indexOfSlash + 1));
}
}
SecurityWebFilterChain
configuration as below:import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfig {
@Autowired
TenantAuthenticationManagerResolver authenticationManagerResolver;
@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.csrf().disable()
.authorizeExchange()
.pathMatchers("/health").hasAnyAuthority("ROLE_USER")
.anyExchange().authenticated()
.and()
.oauth2Client()
.and()
.oauth2Login()
.and()
.oauth2ResourceServer()
.authenticationManagerResolver(authenticationManagerResolver);
return http.build();
}
}
application.properties
:server.port=8090
logging.level.org.springframework.security=DEBUG
spring.security.oauth2.client.registration.keycloak.provider=keycloak
spring.security.oauth2.client.registration.keycloak.client-id=test-client
spring.security.oauth2.client.registration.keycloak.client-secret=ZV4kAKjeNW2KEnYejojOCsi0vqt9vMiS
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.scope=openid
spring.security.oauth2.client.registration.keycloak.redirect-uri={baseUrl}/login/oauth2/code/keycloak
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8080/realms/master
So it seems that multi-tenancy is not working as expected and it is only working for the tenant configured in application.properties
.
application.properties
.spring.security.oauth2.client.registration.keycloak.keystore=C:\\Work\\test-client.jks
spring.security.oauth2.client.registration.keycloak.keystore-type=JKS
spring.security.oauth2.client.registration.keycloak.keystore-password=changeit
spring.security.oauth2.client.registration.keycloak.key-password=changeit
spring.security.oauth2.client.registration.keycloak.key-alias=proactive-outreach-admin
spring.security.oauth2.client.registration.keycloak.truststore=C:\\Work\\test-client.jks
spring.security.oauth2.client.registration.keycloak.truststore-password=changeit
Note: JWKS is not event working for master realm configured in application.properties
.
Need help here as I am stuck for many days without any breakthrough. Let me know if any more information is required.
Upvotes: 1
Views: 1277
Reputation: 12835
As a reminder, clients consume resources (send REST requests) and resource-servers serve resources (respond to this REST requests).
Tokens acquisition and refreshing is the responsibility of clients, not resource-servers.
Your REST API, being an OAuth2 resource-server, should not care about login, logout nor OAuth2 flows. From its point of view, all that matters is if a request is authorized with an access-token emitted by one of the issuers (tenants) it trusts. Remove .oauth2Client()
and .oauth2Login()
from your resource-server security filter-chain.
WebClient
, @FeignClient
, RestTemplate
or whatever you use for inter micro-service requests are OAuth2 clients. It should be configured with either client-credentials (to emit requests in its own name) or forward the access-token from the original request (to emit a request on behalf of the authenticated user).
Your React app might be an OAuth2 "public" client. An other option is adopting the BFF pattern where the application in the browser in not OAuth2 at all (it is secured with good old session) and a middleware on the server (possibly spring-cloud-gateway) is the OAuth2 client, keeping the tokens and translating security from session to access-token before forwarding requests to resource-server. In both cases (public client and BFF) the OAuth2 client will use authorization-code
flow to authenticate users and then refresh-token
to maintain valid access-tokens. The user redirection to the authorization-server should be initiated by the OAuth2 client and the authorization-code should be returned to this same client.
React
associated with either OAuth2
, OpenID
or OIDC
)An app needs to be both a client with .oauth2Login()
and a resource-server only when it serves server-side rendered UI (with Thymeleaf, JSF and so on) and publicly exposes a REST API. In that case, define two separated security filter-chains as exposed in this other answer: Use Keycloak Spring Adapter with Spring Boot 3
You already follow the recommended way to implement multi-tenancy on a resource-server: override the default authentication-manager resolver with one capable of providing the right authentication-manager depending on the request: http.oauth2ResourceServer().authenticationManagerResolver(authenticationManagerResolver)
I'm not sure using a part of the request path is the best option: a JWT comes with an issuer
claim which precisely contains which authorization-server emitted this token. This is the way I implement in spring-addons-webflux-jwt-resource-server
(a thin wrapper around spring-boot-starter-oauth2-resource-server
).
Last your code is probably missing an authorities converter (to get authorities from Keycloak realm_access.roles
instead of scope
) and possibly it would be better to use preferred_username
rather than subject
as username.
spring-addons-webflux-jwt-resource-server
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-webflux-jwt-resource-server</artifactId>
<version>6.0.16</version>
</dependency>
@EnableReactiveMethodSecurity
@Configuration
public class SecurityConfig {
@Bean
public AuthorizeExchangeSpecPostProcessor authorizeExchangeSpecPostProcessor() {
return (ServerHttpSecurity.AuthorizeExchangeSpec spec) -> spec
.pathMatchers("/health").hasRole("USER")
.anyExchange().authenticated();
}
}
neo4j: http://localhost:8080/realms/realm1
neo4j-client: test-client
testac: http://localhost:8080/realms/realm2
testac-client: test-client
allowed-origins:
- https://localhost
- https://localhost:8100
- https://localhost:4200
com:
c4-soft:
springaddons:
security:
cors:
- path: /**
allowed-origins: ${allowed-origins}
issuers:
- location: ${neo4j}
username-claim: preferred_username
authorities:
claims:
- realm_access.roles
- resource_access.${neo4j-client}.role
- location: ${testac}
username-claim: preferred_username
authorities:
claims:
- realm_access.roles
- resource_access.${testac-client}.role
permit-all:
- "/health/readiness"
- "/health/liveness"
No, I did not forget authentication-manager resolver nor security filter-chain bean with resource-server config, everything is auto-configured from properties. Also, user name and roles should be mapped correctly.
At this point, the REST API should accept request with access-token emitted by any of the configured issuers. Use Postman or any other OAuth2 REST client to try (not your browser).
WebClient
with client-credentialsExpose such a bean:
@Bean
WebClient webClient(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService) {
var oauth = new ServletOAuth2AuthorizedClientExchangeFilterFunction(
new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrationRepository,
authorizedClientService));
oauth.setDefaultClientRegistrationId("internal");
return WebClient.builder().apply(oauth.oauth2Configuration()).build();
}
With such properties (mind the authorization-grant-type
value):
internal: http://localhost:8080/realms/realm1
spring:
security:
oauth2:
client:
provider:
internal:
issuer-uri: ${internal}
registration:
internal:
authorization-grant-type: client_credentials
client-id: internal
client-secret: change-me
provider: internal
scope:
- openid
- offline_access
If you add client configuration just for OAuth2 login because you try to issue a GET request with your browser, read the first section again and forget about that. Instead, require actual OAuth2 clients to authorize their requests and use a REST client for your tests (capable of fetching tokens and sending GET requests as well as POST, PUT, and DELETE ones). Again, it is client business to handle OAuth2 flows to acquire access-tokens.
But if your application also hosts server-side rendered UI (Thymeleaf, JSP, etc.), then, as exposed in the solution linked above, add a second security filter-chain for your client resources. With "my" starter, just add:
SecurityConfig
:@Order(Ordered.HIGHEST_PRECEDENCE)
@Bean
SecurityWebFilterChain uiFilterChain(ServerHttpSecurity http, ServerProperties serverProperties, GrantedAuthoritiesMapper authoritiesMapper)
throws Exception {
http.securityMatcher(
new OrServerWebExchangeMatcher(
// UiController pages
new PathPatternParserServerWebExchangeMatcher("/ui/**"),
// those two are required to access Spring generated login page
// and OAuth2 client callback endpoints
new PathPatternParserServerWebExchangeMatcher("/login/**"),
new PathPatternParserServerWebExchangeMatcher("/oauth2/**")));
http.oauth2Login(Customizer.withDefaults());
http.authorizeExchange().pathMatchers("/login/**", "/oauth2/**").permitAll().anyExchange().authenticated();
return http.build();
}
/**
* @param authoritiesConverter We are in spring-addons, we have a {@link ConfigurableClaimSet2AuthoritiesConverter} in the context!
* @return a mapper from oauth2Login result to granted authorities
*/
@Bean
GrantedAuthoritiesMapper userAuthoritiesMapper(Converter<Map<String, Object>, Collection<? extends GrantedAuthority>> authoritiesConverter) {
return (authorities) -> {
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
authorities.forEach(authority -> {
if (authority instanceof OidcUserAuthority oidcAuth) {
mappedAuthorities.addAll(authoritiesConverter.convert(oidcAuth.getIdToken().getClaims()));
} else if (authority instanceof OAuth2UserAuthority oauth2Auth) {
mappedAuthorities.addAll(authoritiesConverter.convert(oauth2Auth.getAttributes()));
}
});
return mappedAuthorities;
};
}
spring:
security:
oauth2:
client:
provider:
neo4j:
issuer-uri: ${neo4j}
testac:
issuer-uri: ${testac}
registration:
neo4j-client:
authorization-grant-type: authorization_code
client-id: ${neo4j-client}
client-secret: change-me
provider: neo4j
scope:
- openid
- offline_access
testac-client:
authorization-grant-type: authorization_code
client-id: ${testac-client}
client-secret: change-me
provider: neo4j
scope:
- openid
- offline_access
Now, OAuth2 login generated page should prompt for which client to use. As clients are configured with different issuers, the client is now multi-tenant too.
Note that the conf above assumes that both clients will use authorization-code flow.
Upvotes: -1