| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | | |
| | |
| | | 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. */ |
| | |
| | | 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 } |
| | | |
| | |
| | | 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)), |
| | |
| | | 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"))); |
| | | } |
| | |
| | | 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}. |
| | | * |
| | |
| | | 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) { |