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}