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