From c8585baebc9fc35ed12a3321acf47730c967b5d3 Mon Sep 17 00:00:00 2001
From: Gaetan Boismal <gaetan.boismal@forgerock.com>
Date: Tue, 24 May 2016 15:45:03 +0000
Subject: [PATCH] OPENDJ-2880 Rest2Ldap as an OAuth2 Resource Server
---
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java | 141 ++++++++++++++++++++++++++++++++++++++++++++++-
1 files changed, 138 insertions(+), 3 deletions(-)
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java
index 6362c6a..b215dd5 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java
@@ -17,6 +17,7 @@
package org.forgerock.opendj.rest2ldap;
import static org.forgerock.http.util.Json.readJsonLenient;
+import static org.forgerock.json.JsonValueFunctions.duration;
import static org.forgerock.json.JsonValueFunctions.enumConstant;
import static org.forgerock.json.JsonValueFunctions.setOf;
import static org.forgerock.opendj.rest2ldap.Rest2LDAP.configureConnectionFactory;
@@ -24,11 +25,17 @@
import static org.forgerock.opendj.rest2ldap.authz.Authorizations.*;
import static org.forgerock.opendj.rest2ldap.authz.ConditionalFilters.*;
import static org.forgerock.opendj.rest2ldap.authz.CredentialExtractors.*;
+import static org.forgerock.opendj.rest2ldap.authz.Authorizations.newCtsAccessTokenResolver;
+import static org.forgerock.opendj.rest2ldap.authz.Authorizations.newFileAccessTokenResolver;
+import static org.forgerock.opendj.rest2ldap.authz.Authorizations.newRfc7662AccessTokenResolver;
import static org.forgerock.util.Reject.checkNotNull;
import static org.forgerock.util.Utils.closeSilently;
+import static org.forgerock.opendj.rest2ldap.authz.Authorizations.newConditionalOAuth2ResourceServerFilter;
import java.io.IOException;
import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
@@ -36,15 +43,25 @@
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+
+import org.forgerock.authz.modules.oauth2.AccessTokenInfo;
+import org.forgerock.authz.modules.oauth2.AccessTokenException;
+import org.forgerock.authz.modules.oauth2.AccessTokenResolver;
+import org.forgerock.authz.modules.oauth2.cache.CachingAccessTokenResolver;
+import org.forgerock.authz.modules.oauth2.resolver.OpenAmAccessTokenResolver;
import org.forgerock.http.Filter;
import org.forgerock.http.Handler;
import org.forgerock.http.HttpApplication;
import org.forgerock.http.HttpApplicationException;
import org.forgerock.http.filter.Filters;
import org.forgerock.http.handler.Handlers;
+import org.forgerock.http.handler.HttpClientHandler;
import org.forgerock.http.io.Buffer;
import org.forgerock.http.protocol.Headers;
import org.forgerock.json.JsonValue;
+import org.forgerock.json.JsonValueException;
import org.forgerock.json.resource.RequestHandler;
import org.forgerock.json.resource.Router;
import org.forgerock.json.resource.http.CrestHttp;
@@ -59,7 +76,12 @@
import org.forgerock.util.Factory;
import org.forgerock.util.Function;
import org.forgerock.util.Pair;
+import org.forgerock.util.PerItemEvictionStrategyCache;
+import org.forgerock.util.annotations.VisibleForTesting;
import org.forgerock.util.promise.NeverThrowsException;
+import org.forgerock.util.promise.Promise;
+import org.forgerock.util.time.Duration;
+import org.forgerock.util.time.TimeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -68,6 +90,18 @@
private static final String DEFAULT_ROOT_FACTORY = "root";
private static final String DEFAULT_BIND_FACTORY = "bind";
+ /** Keys for json oauth2 configuration. */
+ private static final String RESOLVER_CONFIG_OBJECT = "resolver";
+ private static final String REALM = "realm";
+ private static final String SCOPES = "requiredScopes";
+ private static final String AUTHZID_TEMPLATE = "authzIdTemplate";
+ private static final String CACHE_EXPIRATION_DEFAULT = "5 minutes";
+
+ /** Keys for json oauth2 access token cache configuration. */
+ private static final String CACHE_CONFIG_OBJECT = "accessTokenCache";
+ private static final String CACHE_ENABLED = "enabled";
+ private static final String CACHE_EXPIRATION = "cacheExpiration";
+
private static final Logger LOG = LoggerFactory.getLogger(Rest2LDAPHttpApplication.class);
/** URL to the JSON configuration file. */
@@ -77,8 +111,19 @@
protected final Schema schema;
private final Map<String, ConnectionFactory> connectionFactories = new HashMap<>();
+ /** Used for token caching. */
+ private ScheduledExecutorService executorService;
- private enum Policy { BASIC, ANONYMOUS }
+ /** Define the method which should be used to resolve an OAuth2 access token. */
+ private enum OAuth2ResolverType {
+ RFC7662,
+ OPENAM,
+ CTS,
+ FILE
+ }
+
+ @VisibleForTesting
+ enum Policy { OAUTH2, BASIC, ANONYMOUS }
private enum BindStrategy { SIMPLE, SEARCH, SASL_PLAIN }
@@ -107,6 +152,7 @@
public final Handler start() throws HttpApplicationException {
try {
final JsonValue configuration = readJson(configurationUrl);
+ executorService = Executors.newSingleThreadScheduledExecutor();
configureConnectionFactories(configuration.get("ldapConnectionFactories"));
return Handlers.chainOf(
CrestHttp.newHttpHandler(configureRest2Ldap(configuration)),
@@ -155,11 +201,18 @@
closeSilently(factory);
}
connectionFactories.clear();
+ if (executorService != null) {
+ executorService.shutdown();
+ executorService = null;
+ }
}
- private Filter buildAuthorizationFilter(final JsonValue config) {
+ private Filter buildAuthorizationFilter(final JsonValue config) throws HttpApplicationException {
final Set<Policy> policies = config.get("policies").as(setOf(enumConstant(Policy.class)));
final List<ConditionalFilter> filters = new ArrayList<>(policies.size());
+ if (policies.contains(Policy.OAUTH2)) {
+ filters.add(buildOAuth2Filter(config.get("oauth2")));
+ }
if (policies.contains(Policy.BASIC)) {
filters.add(buildBasicFilter(config.get("basic")));
}
@@ -169,6 +222,88 @@
return newAuthorizationFilter(filters);
}
+ @VisibleForTesting
+ ConditionalFilter buildOAuth2Filter(final JsonValue config) throws HttpApplicationException {
+ final String realm = config.get(REALM).defaultTo("no_realm").asString();
+ final Set<String> scopes = config.get(SCOPES).required().asSet(String.class);
+ final AccessTokenResolver resolver =
+ createCachedTokenResolverIfNeeded(config, parseUnderlyingResolver(config));
+ final ConditionalFilter oAuth2Filter = newConditionalOAuth2ResourceServerFilter(
+ realm, scopes, resolver, config.get(AUTHZID_TEMPLATE).required().asString());
+ return newConditionalFilter(
+ Filters.chainOf(oAuth2Filter.getFilter(),
+ newProxyAuthzFilter(getConnectionFactory(DEFAULT_ROOT_FACTORY))),
+ oAuth2Filter.getCondition());
+ }
+
+ @VisibleForTesting
+ AccessTokenResolver createCachedTokenResolverIfNeeded(
+ final JsonValue config, final AccessTokenResolver resolver) {
+ final JsonValue cacheConfig = config.get(CACHE_CONFIG_OBJECT);
+ if (cacheConfig.isNull() || !cacheConfig.get(CACHE_ENABLED).defaultTo(Boolean.FALSE).asBoolean()) {
+ return resolver;
+ }
+ final Duration expiration = parseCacheExpiration(
+ cacheConfig.get(CACHE_EXPIRATION).defaultTo(CACHE_EXPIRATION_DEFAULT));
+
+ final PerItemEvictionStrategyCache<String, Promise<AccessTokenInfo, AccessTokenException>> cache =
+ new PerItemEvictionStrategyCache<>(executorService, expiration);
+ cache.setMaxTimeout(expiration);
+ return new CachingAccessTokenResolver(TimeService.SYSTEM, resolver, cache);
+ }
+
+ @VisibleForTesting
+ AccessTokenResolver parseUnderlyingResolver(final JsonValue configuration) throws HttpApplicationException {
+ final JsonValue resolver = configuration.get(RESOLVER_CONFIG_OBJECT).required();
+ switch (resolver.as(enumConstant(OAuth2ResolverType.class))) {
+ case RFC7662:
+ return parseRfc7662Resolver(configuration);
+ case OPENAM:
+ return new OpenAmAccessTokenResolver(new HttpClientHandler(),
+ TimeService.SYSTEM,
+ configuration.get("openam").get("endpointURL").required().asString());
+ case CTS:
+ final JsonValue cts = configuration.get("cts").required();
+ return newCtsAccessTokenResolver(
+ getConnectionFactory(cts.get("ldapConnectionFactory").defaultTo(DEFAULT_ROOT_FACTORY).asString()),
+ cts.get("baseDN").required().asString());
+ case FILE:
+ return newFileAccessTokenResolver(configuration.get("file").get("folderPath").required().asString());
+ default:
+ throw new JsonValueException(resolver, "is not a supported access token resolver");
+ }
+ }
+
+ private AccessTokenResolver parseRfc7662Resolver(final JsonValue configuration) throws HttpApplicationException {
+ final JsonValue rfc7662 = configuration.get("rfc7662").required();
+ final String introspectionEndPointURL = rfc7662.get("endpointURL").required().asString();
+ try {
+ return newRfc7662AccessTokenResolver(new HttpClientHandler(),
+ new URI(introspectionEndPointURL),
+ rfc7662.get("clientId").required().asString(),
+ rfc7662.get("clientSecret").required().asString());
+ } catch (final URISyntaxException e) {
+ throw new IllegalArgumentException("The token introspection endpoint '"
+ + introspectionEndPointURL + "' URL has an invalid syntax: " + e.getLocalizedMessage(), e);
+ }
+ }
+
+ private Duration parseCacheExpiration(final JsonValue expirationJson) {
+ try {
+ final Duration expiration = expirationJson.as(duration());
+ if (expiration.isZero() || expiration.isUnlimited()) {
+ throw new JsonValueException(expirationJson, "The cache expiration duration cannot be "
+ + (expiration.isZero() ? "zero" : "unlimited."));
+ }
+ return expiration;
+ } catch (final Exception e) {
+ throw new JsonValueException(expirationJson,
+ "Malformed duration value '" + expirationJson.toString() + "' for cache expiration. "
+ + "The duration syntax supports all human readable notations from day ('days'', 'day'', 'd'') "
+ + "to nanosecond ('nanoseconds', 'nanosecond', 'nanosec', 'nanos', 'nano', 'ns')");
+ }
+ }
+
/**
* Creates a new {@link Filter} in charge of injecting {@link AuthenticatedConnectionContext}.
*
@@ -263,7 +398,7 @@
private AuthenticationStrategy buildSASLBindStrategy(JsonValue config) {
return newSASLPlainStrategy(
getConnectionFactory(config.get("ldapConnectionFactory").defaultTo(DEFAULT_BIND_FACTORY).asString()),
- schema, config.get("authcIdTemplate").defaultTo("u:%s").asString());
+ schema, config.get(AUTHZID_TEMPLATE).defaultTo("u:%s").asString());
}
private AuthenticationStrategy buildSearchThenBindStrategy(JsonValue config) {
--
Gitblit v1.10.0