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}