001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2015-2016 ForgeRock AS.
015 */
016package org.forgerock.opendj.rest2ldap;
017
018import static org.forgerock.http.handler.Handlers.chainOf;
019import static org.forgerock.http.handler.HttpClientHandler.OPTION_KEY_MANAGERS;
020import static org.forgerock.http.handler.HttpClientHandler.OPTION_TRUST_MANAGERS;
021import static org.forgerock.json.JsonValueFunctions.duration;
022import static org.forgerock.json.JsonValueFunctions.enumConstant;
023import static org.forgerock.json.JsonValueFunctions.setOf;
024import static org.forgerock.opendj.ldap.KeyManagers.useSingleCertificate;
025import static org.forgerock.opendj.rest2ldap.Rest2LdapJsonConfigurator.*;
026import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
027import static org.forgerock.opendj.rest2ldap.Utils.newJsonValueException;
028import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.newSaslPlainStrategy;
029import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.newSearchThenBindStrategy;
030import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.newSimpleBindStrategy;
031import static org.forgerock.opendj.rest2ldap.authz.Authorization.*;
032import static org.forgerock.opendj.rest2ldap.authz.ConditionalFilters.newConditionalFilter;
033import static org.forgerock.opendj.rest2ldap.authz.CredentialExtractors.httpBasicExtractor;
034import static org.forgerock.opendj.rest2ldap.authz.CredentialExtractors.newCustomHeaderExtractor;
035import static org.forgerock.util.Reject.checkNotNull;
036import static org.forgerock.util.Utils.closeSilently;
037import static org.forgerock.util.Utils.joinAsString;
038
039import java.io.Closeable;
040import java.io.File;
041import java.io.IOException;
042import java.net.URI;
043import java.net.URISyntaxException;
044import java.net.URL;
045import java.util.ArrayList;
046import java.util.Collection;
047import java.util.HashMap;
048import java.util.List;
049import java.util.Map;
050import java.util.Set;
051import java.util.concurrent.ScheduledExecutorService;
052import java.util.concurrent.ScheduledThreadPoolExecutor;
053
054import javax.net.ssl.KeyManager;
055import javax.net.ssl.TrustManager;
056import javax.net.ssl.X509KeyManager;
057
058import org.forgerock.http.Filter;
059import org.forgerock.http.Handler;
060import org.forgerock.http.HttpApplication;
061import org.forgerock.http.HttpApplicationException;
062import org.forgerock.http.filter.Filters;
063import org.forgerock.http.handler.HttpClientHandler;
064import org.forgerock.http.io.Buffer;
065import org.forgerock.http.oauth2.AccessTokenException;
066import org.forgerock.http.oauth2.AccessTokenInfo;
067import org.forgerock.http.oauth2.AccessTokenResolver;
068import org.forgerock.http.oauth2.resolver.CachingAccessTokenResolver;
069import org.forgerock.http.oauth2.resolver.OpenAmAccessTokenResolver;
070import org.forgerock.http.protocol.Headers;
071import org.forgerock.http.swagger.OpenApiRequestFilter;
072import org.forgerock.i18n.LocalizableMessage;
073import org.forgerock.i18n.LocalizedIllegalArgumentException;
074import org.forgerock.i18n.slf4j.LocalizedLogger;
075import org.forgerock.json.JsonValue;
076import org.forgerock.json.resource.CrestApplication;
077import org.forgerock.json.resource.RequestHandler;
078import org.forgerock.json.resource.Resources;
079import org.forgerock.json.resource.http.CrestHttp;
080import org.forgerock.opendj.ldap.Connection;
081import org.forgerock.opendj.ldap.ConnectionFactory;
082import org.forgerock.opendj.ldap.DN;
083import org.forgerock.opendj.ldap.SearchScope;
084import org.forgerock.opendj.ldap.schema.Schema;
085import org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategy;
086import org.forgerock.opendj.rest2ldap.authz.ConditionalFilters.ConditionalFilter;
087import org.forgerock.services.context.SecurityContext;
088import org.forgerock.util.Factory;
089import org.forgerock.util.Function;
090import org.forgerock.util.Options;
091import org.forgerock.util.Pair;
092import org.forgerock.util.PerItemEvictionStrategyCache;
093import org.forgerock.util.annotations.VisibleForTesting;
094import org.forgerock.util.promise.NeverThrowsException;
095import org.forgerock.util.promise.Promise;
096import org.forgerock.util.time.Duration;
097import org.forgerock.util.time.TimeService;
098
099import com.forgerock.opendj.util.ManifestUtil;
100
101/** Rest2ldap HTTP application. */
102public class Rest2LdapHttpApplication implements HttpApplication {
103    private static final String DEFAULT_ROOT_FACTORY = "root";
104    private static final String DEFAULT_BIND_FACTORY = "bind";
105
106    /** Keys for json oauth2 configuration. */
107    private static final String RESOLVER_CONFIG_OBJECT = "resolver";
108    private static final String REALM = "realm";
109    private static final String SCOPES = "requiredScopes";
110    private static final String AUTHZID_TEMPLATE = "authzIdTemplate";
111    private static final String CACHE_EXPIRATION_DEFAULT = "5 minutes";
112
113    /** Keys for json oauth2 access token cache configuration. */
114    private static final String CACHE_CONFIG_OBJECT = "accessTokenCache";
115    private static final String CACHE_ENABLED = "enabled";
116    private static final String CACHE_EXPIRATION = "cacheExpiration";
117
118    private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
119
120    /** The name of the JSON configuration directory in which config.json and rest2ldap/rest2ldap.json are located. */
121    protected final File configDirectory;
122
123    /** Schema used to perform DN validations. */
124    protected final Schema schema;
125
126    private final Map<String, ConnectionFactory> connectionFactories = new HashMap<>();
127    /** Used for token caching. */
128    private ScheduledExecutorService executorService;
129
130    /** Resources which have to be closed when this application is stopped. */
131    private final Collection<Closeable> closeableResources = new ArrayList<>();
132
133    private TrustManager trustManager;
134    private X509KeyManager keyManager;
135
136    /** Define the method which should be used to resolve an OAuth2 access token. */
137    private enum OAuth2ResolverType {
138        RFC7662, OPENAM, CTS, FILE;
139
140        private static String listValues() {
141            final List<String> values = new ArrayList<>();
142            for (final OAuth2ResolverType value : OAuth2ResolverType.values()) {
143                values.add(value.name().toLowerCase());
144            }
145            return joinAsString(",", values);
146        }
147    }
148
149    @VisibleForTesting
150    enum Policy { OAUTH2, BASIC, ANONYMOUS }
151
152    private enum BindStrategy {
153        SIMPLE("simple"),
154        SEARCH("search"),
155        SASL_PLAIN("sasl-plain");
156
157        private final String jsonField;
158
159        BindStrategy(final String jsonField) {
160            this.jsonField = jsonField;
161        }
162
163        private static String listValues() {
164            final List<String> values = new ArrayList<>();
165            for (final BindStrategy mapping : BindStrategy.values()) {
166                values.add(mapping.jsonField);
167            }
168            return joinAsString(",", values);
169        }
170    }
171
172    /**
173     * Default constructor called by the HTTP Framework which will use the default configuration directory.
174     */
175    public Rest2LdapHttpApplication() {
176        try {
177            // The null check is required for unit test mocks because the resource does not exist.
178            final URL configUrl = getClass().getResource("/config.json");
179            this.configDirectory = configUrl != null ? new File(configUrl.toURI()).getParentFile() : null;
180        } catch (final URISyntaxException e) {
181            throw new IllegalStateException(e);
182        }
183        this.schema = Schema.getDefaultSchema();
184    }
185
186    /**
187     * Creates a new Rest2LDAP HTTP application using the provided configuration directory.
188     *
189     * @param configDirectory
190     *         The name of the JSON configuration directory in which config.json and rest2ldap/rest2ldap.json are
191     *         located.
192     * @param schema
193     *         The {@link Schema} used to perform DN validations
194     */
195    public Rest2LdapHttpApplication(final File configDirectory, final Schema schema) {
196        this.configDirectory = checkNotNull(configDirectory, "configDirectory cannot be null");
197        this.schema = checkNotNull(schema, "schema cannot be null");
198    }
199
200    @Override
201    public final Handler start() throws HttpApplicationException {
202        try {
203            logger.info(INFO_REST2LDAP_STARTING.get(configDirectory));
204
205            final ScheduledThreadPoolExecutor scheduledExecutor = new ScheduledThreadPoolExecutor(1);
206            scheduledExecutor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
207            scheduledExecutor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
208            closeOnStop(new Closeable() {
209                @Override
210                public void close() throws IOException {
211                    scheduledExecutor.shutdown();
212                }
213            });
214            executorService = scheduledExecutor;
215
216            final JsonValue config = readJson(new File(configDirectory, "config.json"));
217            configureSecurity(config.get("security"));
218            configureConnectionFactories(config.get("ldapConnectionFactories"));
219            final Filter authorizationFilter = buildAuthorizationFilter(config.get("authorization").required());
220            return chainOf(newHttpHandler(configureRest2Ldap(configDirectory)),
221                           new OpenApiRequestFilter(),
222                           new ErrorLoggerFilter(),
223                           authorizationFilter);
224        } catch (final Exception e) {
225            final LocalizableMessage errorMsg = ERR_FAIL_PARSE_CONFIGURATION.get(e.getLocalizedMessage());
226            logger.error(errorMsg, e);
227            stop();
228            throw new HttpApplicationException(errorMsg.toString(), e);
229        }
230    }
231
232    private static RequestHandler configureRest2Ldap(final File configDirectory) throws IOException {
233        final File rest2LdapConfigDirectory = new File(configDirectory, "rest2ldap");
234        final Options options = configureOptions(readJson(new File(rest2LdapConfigDirectory, "rest2ldap.json")));
235        final File endpointsDirectory = new File(rest2LdapConfigDirectory, "endpoints");
236        return configureEndpoints(endpointsDirectory, options);
237    }
238
239    private static Handler newHttpHandler(final RequestHandler requestHandler) {
240        final org.forgerock.json.resource.ConnectionFactory factory =
241                Resources.newInternalConnectionFactory(requestHandler);
242        return CrestHttp.newHttpHandler(new CrestApplication() {
243            @Override
244            public org.forgerock.json.resource.ConnectionFactory getConnectionFactory() {
245                return factory;
246            }
247
248            @Override
249            public String getApiId() {
250                return "frapi:opendj:rest2ldap";
251            }
252
253            @Override
254            public String getApiVersion() {
255                return ManifestUtil.getVersionWithRevision("opendj-core");
256            }
257        });
258    }
259
260    private void configureSecurity(final JsonValue configuration) {
261        trustManager = configureTrustManager(configuration);
262        keyManager = configureKeyManager(configuration);
263    }
264
265    private void configureConnectionFactories(final JsonValue config) {
266        // Make sure that the mandatory root connection factory exists (used to perform the proxy-authz operations).
267        config.get(DEFAULT_ROOT_FACTORY).required();
268        connectionFactories.clear();
269        for (String name : config.keys()) {
270            connectionFactories
271                .put(name, closeOnStop(configureConnectionFactory(config, name, trustManager, keyManager)));
272        }
273    }
274
275    private <T extends Closeable> T closeOnStop(T resource) {
276        closeableResources.add(resource);
277        return resource;
278    }
279
280    @Override
281    public Factory<Buffer> getBufferFactory() {
282        // Use container default buffer factory.
283        return null;
284    }
285
286    @Override
287    public void stop() {
288        closeSilently(closeableResources);
289        closeableResources.clear();
290        connectionFactories.clear();
291        executorService = null;
292    }
293
294    private Filter buildAuthorizationFilter(final JsonValue config) throws HttpApplicationException {
295        final Set<Policy> policies = config.get("policies").as(setOf(enumConstant(Policy.class)));
296        final List<ConditionalFilter> filters = new ArrayList<>(policies.size());
297        if (policies.contains(Policy.OAUTH2)) {
298            filters.add(buildOAuth2Filter(config.get("oauth2")));
299        }
300        if (policies.contains(Policy.BASIC)) {
301            filters.add(buildBasicFilter(config.get("basic")));
302        }
303        if (policies.contains(Policy.ANONYMOUS)) {
304            filters.add(buildAnonymousFilter(config.get("anonymous")));
305        }
306        return newAuthorizationFilter(filters);
307    }
308
309    @VisibleForTesting
310    ConditionalFilter buildOAuth2Filter(final JsonValue config) throws HttpApplicationException {
311        final String realm = config.get(REALM).defaultTo("no_realm").asString();
312        final Set<String> scopes = config.get(SCOPES).required().as(setOf(String.class));
313        final AccessTokenResolver resolver =
314                createCachedTokenResolverIfNeeded(config, parseUnderlyingResolver(config));
315        final String resolverName = config.get(RESOLVER_CONFIG_OBJECT).asString();
316        final ConditionalFilter oAuth2Filter = newConditionalOAuth2ResourceServerFilter(
317                realm, scopes, resolver, config.get(resolverName).get(AUTHZID_TEMPLATE).required().asString());
318        return newConditionalFilter(
319                Filters.chainOf(oAuth2Filter.getFilter(),
320                                newProxyAuthzFilter(getConnectionFactory(DEFAULT_ROOT_FACTORY))),
321                oAuth2Filter.getCondition());
322    }
323
324    @VisibleForTesting
325    AccessTokenResolver createCachedTokenResolverIfNeeded(
326            final JsonValue config, final AccessTokenResolver resolver) {
327        final JsonValue cacheConfig = config.get(CACHE_CONFIG_OBJECT);
328        if (cacheConfig.isNull() || !cacheConfig.get(CACHE_ENABLED).defaultTo(Boolean.FALSE).asBoolean()) {
329            return resolver;
330        }
331        final Duration expiration = parseCacheExpiration(
332                cacheConfig.get(CACHE_EXPIRATION).defaultTo(CACHE_EXPIRATION_DEFAULT));
333
334        final PerItemEvictionStrategyCache<String, Promise<AccessTokenInfo, AccessTokenException>> cache =
335                new PerItemEvictionStrategyCache<>(executorService, expiration);
336        cache.setMaxTimeout(expiration);
337        return new CachingAccessTokenResolver(TimeService.SYSTEM, resolver, cache);
338    }
339
340    @VisibleForTesting
341    AccessTokenResolver parseUnderlyingResolver(final JsonValue configuration) throws HttpApplicationException {
342        final JsonValue resolver = configuration.get(RESOLVER_CONFIG_OBJECT).required();
343        switch (resolver.as(enumConstant(OAuth2ResolverType.class))) {
344        case RFC7662:
345            return parseRfc7662Resolver(configuration);
346        case OPENAM:
347            final JsonValue openAm = configuration.get("openam");
348            return new OpenAmAccessTokenResolver(newHttpClientHandler(openAm),
349                                                 TimeService.SYSTEM,
350                                                 openAm.get("endpointUrl").required().asString());
351        case CTS:
352            final JsonValue cts = configuration.get("cts").required();
353            return newCtsAccessTokenResolver(
354                getConnectionFactory(cts.get("ldapConnectionFactory").defaultTo(DEFAULT_ROOT_FACTORY).asString()),
355                                     cts.get("baseDn").required().asString());
356        case FILE:
357            return newFileAccessTokenResolver(configuration.get("file").get("folderPath").required().asString());
358        default:
359            throw newJsonValueException(resolver,
360                                        ERR_CONFIG_OAUTH2_UNSUPPORTED_ACCESS_TOKEN_RESOLVER.get(
361                                                resolver.getObject(), OAuth2ResolverType.listValues()));
362        }
363    }
364
365    private AccessTokenResolver parseRfc7662Resolver(final JsonValue configuration) throws HttpApplicationException {
366        final JsonValue rfc7662 = configuration.get("rfc7662").required();
367        final String introspectionEndPointURL = rfc7662.get("endpointUrl").required().asString();
368        try {
369            return newRfc7662AccessTokenResolver(newHttpClientHandler(rfc7662),
370                                                 new URI(introspectionEndPointURL),
371                                                 rfc7662.get("clientId").required().asString(),
372                                                 rfc7662.get("clientSecret").required().asString());
373        } catch (final URISyntaxException e) {
374            throw new IllegalArgumentException(ERR_CONIFG_OAUTH2_INVALID_INTROSPECT_URL.get(
375                    introspectionEndPointURL, e.getLocalizedMessage()).toString(), e);
376        }
377    }
378
379    private HttpClientHandler newHttpClientHandler(final JsonValue config) throws HttpApplicationException {
380        final Options httpOptions = Options.defaultOptions();
381        if (trustManager != null) {
382            httpOptions.set(OPTION_TRUST_MANAGERS, new TrustManager[] { trustManager });
383        }
384        if (keyManager != null) {
385            final String keyAlias = config.get("sslCertAlias").asString();
386            httpOptions.set(OPTION_KEY_MANAGERS,
387                    new KeyManager[] { keyAlias != null ? useSingleCertificate(keyAlias, keyManager) : keyManager });
388        }
389        return closeOnStop(new HttpClientHandler(httpOptions));
390    }
391
392    private Duration parseCacheExpiration(final JsonValue expirationJson) {
393        try {
394            final Duration expiration = expirationJson.as(duration());
395            if (expiration.isZero() || expiration.isUnlimited()) {
396                throw newJsonValueException(expirationJson,
397                                            expiration.isZero() ? ERR_CONIFG_OAUTH2_CACHE_ZERO_DURATION.get()
398                                                                : ERR_CONIFG_OAUTH2_CACHE_UNLIMITED_DURATION.get());
399            }
400            return expiration;
401        } catch (final Exception e) {
402            throw newJsonValueException(expirationJson,
403                                        ERR_CONFIG_OAUTH2_CACHE_INVALID_DURATION.get(expirationJson.toString()));
404        }
405    }
406
407    /**
408     * Creates a new {@link Filter} in charge of injecting {@link AuthenticatedConnectionContext}.
409     *
410     * @param connectionFactory
411     *            The {@link ConnectionFactory} providing the {@link Connection} injected as
412     *            {@link AuthenticatedConnectionContext}
413     * @return a newly created {@link Filter}
414     */
415    protected Filter newProxyAuthzFilter(final ConnectionFactory connectionFactory) {
416        return newProxyAuthorizationFilter(connectionFactory);
417    }
418
419    private ConditionalFilter buildAnonymousFilter(final JsonValue config) {
420        return newAnonymousFilter(getConnectionFactory(config.get("ldapConnectionFactory")
421                                                             .defaultTo(DEFAULT_ROOT_FACTORY)
422                                                             .asString()));
423    }
424
425    /**
426     * Creates a new {@link Filter} in charge of injecting {@link AuthenticatedConnectionContext} directly from a
427     * {@link ConnectionFactory}.
428     *
429     * @param connectionFactory
430     *            The {@link ConnectionFactory} used to get the {@link Connection}
431     * @return a newly created {@link Filter}
432     */
433    protected ConditionalFilter newAnonymousFilter(ConnectionFactory connectionFactory) {
434        return newConditionalDirectConnectionFilter(connectionFactory);
435    }
436
437    /**
438     * Gets a {@link ConnectionFactory} from its name.
439     *
440     * @param name
441     *            Name of the {@link ConnectionFactory} as specified in the configuration
442     * @return The associated {@link ConnectionFactory} or null if none can be found
443     */
444    protected ConnectionFactory getConnectionFactory(final String name) {
445        return connectionFactories.get(name);
446    }
447
448    private ConditionalFilter buildBasicFilter(final JsonValue config) {
449        final String bind = config.get("bind").required().asString();
450        final BindStrategy strategy = BindStrategy.valueOf(bind.toUpperCase().replace('-', '_'));
451        return newBasicAuthenticationFilter(buildBindStrategy(strategy, config.get(bind).required()),
452                config.get("supportAltAuthentication").defaultTo(Boolean.FALSE).asBoolean()
453                        ? newCustomHeaderExtractor(
454                                config.get("altAuthenticationUsernameHeader").required().asString(),
455                                config.get("altAuthenticationPasswordHeader").required().asString())
456                        : httpBasicExtractor());
457    }
458
459    /**
460     * Gets a {@link Filter} in charge of performing the HTTP-Basic Authentication. This filter create a
461     * {@link SecurityContext} reflecting the authenticated users.
462     *
463     * @param authenticationStrategy
464     *            The {@link AuthenticationStrategy} to use to authenticate the user.
465     * @param credentialsExtractor
466     *            Extract the user's credentials from the {@link Headers}.
467     * @return A new {@link Filter}
468     */
469    protected ConditionalFilter newBasicAuthenticationFilter(AuthenticationStrategy authenticationStrategy,
470            Function<Headers, Pair<String, String>, NeverThrowsException> credentialsExtractor) {
471        final ConditionalFilter httpBasicFilter =
472                newConditionalHttpBasicAuthenticationFilter(authenticationStrategy, credentialsExtractor);
473        return newConditionalFilter(Filters.chainOf(httpBasicFilter.getFilter(),
474                                                    newProxyAuthzFilter(getConnectionFactory(DEFAULT_ROOT_FACTORY))),
475                                    httpBasicFilter.getCondition());
476    }
477
478    private AuthenticationStrategy buildBindStrategy(final BindStrategy strategy, final JsonValue config) {
479        switch (strategy) {
480        case SIMPLE:
481            return buildSimpleBindStrategy(config);
482        case SEARCH:
483            return buildSearchThenBindStrategy(config);
484        case SASL_PLAIN:
485            return buildSaslBindStrategy(config);
486        default:
487            throw new LocalizedIllegalArgumentException(
488                    ERR_CONFIG_UNSUPPORTED_BIND_STRATEGY.get(strategy, BindStrategy.listValues()));
489        }
490    }
491
492    private AuthenticationStrategy buildSimpleBindStrategy(final JsonValue config) {
493        return newSimpleBindStrategy(getConnectionFactory(config.get("ldapConnectionFactory")
494                                                                .defaultTo(DEFAULT_BIND_FACTORY).asString()),
495                                     parseUserNameTemplate(config.get("bindDnTemplate").defaultTo("%s")),
496                                     schema);
497    }
498
499    private AuthenticationStrategy buildSaslBindStrategy(JsonValue config) {
500        return newSaslPlainStrategy(
501                getConnectionFactory(config.get("ldapConnectionFactory").defaultTo(DEFAULT_BIND_FACTORY).asString()),
502                schema, parseUserNameTemplate(config.get(AUTHZID_TEMPLATE).defaultTo("u:%s")));
503    }
504
505    private AuthenticationStrategy buildSearchThenBindStrategy(JsonValue config) {
506        return newSearchThenBindStrategy(
507                getConnectionFactory(
508                        config.get("searchLdapConnectionFactory").defaultTo(DEFAULT_ROOT_FACTORY).asString()),
509                getConnectionFactory(
510                        config.get("bindLdapConnectionFactory").defaultTo(DEFAULT_BIND_FACTORY).asString()),
511                DN.valueOf(config.get("baseDn").required().asString(), schema),
512                SearchScope.valueOf(config.get("scope").required().asString().toLowerCase()),
513                parseUserNameTemplate(config.get("filterTemplate").required()));
514    }
515
516    private String parseUserNameTemplate(final JsonValue template) {
517        return template.asString().replace("{username}", "%s");
518    }
519}