From e5e0de3b39b2b91f479fdcd09d907c69bcdaa851 Mon Sep 17 00:00:00 2001
From: Matthew Swift <matthew.swift@forgerock.com>
Date: Thu, 21 Mar 2013 08:42:44 +0000
Subject: [PATCH] Partial fix for OPENDJ-694: Implement HTTP BASIC authentication
---
opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplate.java | 120 ++++++++
opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Context.java | 143 ++++++++-
opendj-sdk/opendj3/opendj-rest2ldap-servlet/src/main/webapp/opendj-rest2ldap-servlet.json | 33 +
opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAP.java | 211 ++++++++-----
opendj-sdk/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplateTest.java | 123 ++++++++
opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java | 45 --
opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java | 48 ++
opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthenticatedConnectionContext.java | 114 +++++++
opendj-sdk/opendj3/opendj-rest2ldap-servlet/src/main/java/org/forgerock/opendj/rest2ldap/servlet/Rest2LDAPConnectionFactoryProvider.java | 48 ++
9 files changed, 709 insertions(+), 176 deletions(-)
diff --git a/opendj-sdk/opendj3/opendj-rest2ldap-servlet/src/main/java/org/forgerock/opendj/rest2ldap/servlet/Rest2LDAPConnectionFactoryProvider.java b/opendj-sdk/opendj3/opendj-rest2ldap-servlet/src/main/java/org/forgerock/opendj/rest2ldap/servlet/Rest2LDAPConnectionFactoryProvider.java
index 48c3419..ba36044 100644
--- a/opendj-sdk/opendj3/opendj-rest2ldap-servlet/src/main/java/org/forgerock/opendj/rest2ldap/servlet/Rest2LDAPConnectionFactoryProvider.java
+++ b/opendj-sdk/opendj3/opendj-rest2ldap-servlet/src/main/java/org/forgerock/opendj/rest2ldap/servlet/Rest2LDAPConnectionFactoryProvider.java
@@ -36,7 +36,6 @@
*/
public final class Rest2LDAPConnectionFactoryProvider {
private static final String INIT_PARAM_CONFIG_FILE = "config-file";
-
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
/**
@@ -47,15 +46,26 @@
*
* <pre>
* {
- * // The LDAP server configuration - see Rest2LDAP.configureConnectionFactory(JsonValue).
- * "primaryLDAPServers" : [
- * {
- * "hostname" : "host1.example.com",
- * "port" : 389
+ * // LDAP connection factory configurations.
+ * "ldapConnectionFactories" : {
+ * "default" : {
+ * // See Rest2LDAP.configureConnectionFactory(JsonValue, String)
+ * },
+ * "root" : {
+ * ...
* }
- * ],
+ * },
*
- * ...
+ * // This is optional.
+ * "authorization" : {
+ * // The LDAP connection factory which should be used for LDAP operations, or
+ * // re-use cached connection from authentication filter if not present.
+ * "ldapConnectionFactory" : "root",
+ *
+ * // The optional authorization ID template to use if proxied authorization is
+ * // to be performed.
+ * "proxyAuthzIdTemplate" : "dn:uid={uid},ou=people,dc=example,dc=com"
+ * },
*
* // The LDAP mappings
* "mappings" : {
@@ -80,7 +90,7 @@
* @return The configured JSON resource connection factory.
* @throws ServletException
* If the connection factory could not be initialized.
- * @see Rest2LDAP#configureConnectionFactory(JsonValue)
+ * @see Rest2LDAP#configureConnectionFactory(JsonValue, String)
* @see Builder#configureMapping(JsonValue)
*/
public static ConnectionFactory getConnectionFactory(final ServletConfig config)
@@ -105,9 +115,19 @@
}
final JsonValue configuration = new JsonValue(content);
- // Parse the LDAP connection configuration.
- final org.forgerock.opendj.ldap.ConnectionFactory ldapFactory =
- Rest2LDAP.configureConnectionFactory(configuration);
+ // Parse the authorization configuration.
+ final String proxyAuthzTemplate =
+ configuration.get("authorization").get("proxyAuthzIdTemplate").asString();
+ final String ldapFactoryName =
+ configuration.get("authorization").get("ldapConnectionFactory").asString();
+ final org.forgerock.opendj.ldap.ConnectionFactory ldapFactory;
+ if (ldapFactoryName != null) {
+ ldapFactory =
+ Rest2LDAP.configureConnectionFactory(configuration.get(
+ "ldapConnectionFactories").required(), ldapFactoryName);
+ } else {
+ ldapFactory = null;
+ }
// Create the router.
final Router router = new Router();
@@ -115,8 +135,8 @@
for (final String mappingUrl : mappings.keys()) {
final JsonValue mapping = mappings.get(mappingUrl);
final CollectionResourceProvider provider =
- Rest2LDAP.builder().connectionFactory(ldapFactory)
- .configureMapping(mapping).build();
+ Rest2LDAP.builder().connectionFactory(ldapFactory).useProxiedAuthorization(
+ proxyAuthzTemplate).configureMapping(mapping).build();
router.addRoute(mappingUrl, provider);
}
return Resources.newInternalConnectionFactory(router);
diff --git a/opendj-sdk/opendj3/opendj-rest2ldap-servlet/src/main/webapp/opendj-rest2ldap-servlet.json b/opendj-sdk/opendj3/opendj-rest2ldap-servlet/src/main/webapp/opendj-rest2ldap-servlet.json
index 54ad6af..cae4009 100644
--- a/opendj-sdk/opendj3/opendj-rest2ldap-servlet/src/main/webapp/opendj-rest2ldap-servlet.json
+++ b/opendj-sdk/opendj3/opendj-rest2ldap-servlet/src/main/webapp/opendj-rest2ldap-servlet.json
@@ -1,18 +1,29 @@
{
- "primaryLDAPServers" : [
- {
- "hostname" : "localhost",
- "port" : 1389
+ "ldapConnectionFactories" : {
+ "default" : {
+ "primaryLDAPServers" : [
+ {
+ "hostname" : "localhost",
+ "port" : 1389
+ }
+ ],
+ "connectionPoolSize" : 10,
+ "heartBeatIntervalSeconds" : 30
+ },
+ "root" : {
+ "inheritFrom" : "default",
+ "authentication" : {
+ "simple" : {
+ "bindDN" : "cn=directory manager",
+ "bindPassword" : "password"
+ }
+ }
}
- ],
-
- "authentication" : {
- "bindDN" : "cn=directory manager",
- "password" : "password"
},
- "connectionPoolSize" : 10,
- "heartBeatIntervalSeconds" : 30,
+ "authorization" : {
+ "ldapConnectionFactory" : "root"
+ },
"mappings" : {
"/users" : {
diff --git a/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthenticatedConnectionContext.java b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthenticatedConnectionContext.java
new file mode 100644
index 0000000..78b8d96
--- /dev/null
+++ b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthenticatedConnectionContext.java
@@ -0,0 +1,114 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2013 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static org.forgerock.opendj.rest2ldap.Utils.ensureNotNull;
+
+import org.forgerock.json.fluent.JsonValue;
+import org.forgerock.json.resource.Context;
+import org.forgerock.json.resource.InternalServerErrorException;
+import org.forgerock.json.resource.PersistenceConfig;
+import org.forgerock.json.resource.ResourceException;
+import org.forgerock.opendj.ldap.Connection;
+
+/**
+ * A {@link Context} containing a cached pre-authenticated LDAP connection which
+ * should be re-used for performing subsequent LDAP operations. The LDAP
+ * connection is typically acquired while perform authentication in an HTTP
+ * servlet filter. It is the responsibility of the component which acquired the
+ * connection to release once processing has completed.
+ */
+public final class AuthenticatedConnectionContext extends Context {
+ /*
+ * TODO: this context does not support persistence because there is no
+ * obvious way to restore the connection. We could just persist the context
+ * and restore it as null, and let rest2ldap switch to using the factory +
+ * proxied authz.
+ */
+ private final Connection connection;
+
+ /**
+ * Creates a new pre-authenticated cached LDAP connection context having the
+ * provided parent and an ID automatically generated using
+ * {@code UUID.randomUUID()}.
+ *
+ * @param parent
+ * The parent context.
+ * @param connection
+ * The cached pre-authenticated LDAP connection which should be
+ * re-used for subsequent LDAP operations.
+ */
+ public AuthenticatedConnectionContext(final Context parent, final Connection connection) {
+ super(ensureNotNull(parent));
+ this.connection = connection;
+ }
+
+ /**
+ * Creates a new pre-authenticated cached LDAP connection context having the
+ * provided ID and parent.
+ *
+ * @param id
+ * The context ID.
+ * @param parent
+ * The parent context.
+ * @param connection
+ * The cached pre-authenticated LDAP connection which should be
+ * re-used for subsequent LDAP operations.
+ */
+ public AuthenticatedConnectionContext(final String id, final Context parent,
+ final Connection connection) {
+ super(id, ensureNotNull(parent));
+ this.connection = connection;
+ }
+
+ /**
+ * Restore from JSON representation.
+ *
+ * @param savedContext
+ * The JSON representation from which this context's attributes
+ * should be parsed.
+ * @param config
+ * The persistence configuration.
+ * @throws ResourceException
+ * If the JSON representation could not be parsed.
+ */
+ AuthenticatedConnectionContext(final JsonValue savedContext, final PersistenceConfig config)
+ throws ResourceException {
+ super(savedContext, config);
+ throw new InternalServerErrorException("Cached LDAP connections cannot be restored");
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected void saveToJson(final JsonValue savedContext, final PersistenceConfig config)
+ throws ResourceException {
+ super.saveToJson(savedContext, config);
+ throw new InternalServerErrorException("Cached LDAP connections cannot be persisted");
+ }
+
+ /**
+ * Returns the cached pre-authenticated LDAP connection which should be
+ * re-used for subsequent LDAP operations.
+ *
+ * @return The cached pre-authenticated LDAP connection which should be
+ * re-used for subsequent LDAP operations.
+ */
+ Connection getConnection() {
+ return connection;
+ }
+}
diff --git a/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplate.java b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplate.java
new file mode 100644
index 0000000..07fccaf
--- /dev/null
+++ b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplate.java
@@ -0,0 +1,120 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2013 ForgeRock AS.
+ */
+
+package org.forgerock.opendj.rest2ldap;
+
+import static org.forgerock.opendj.rest2ldap.Utils.isJSONPrimitive;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.forgerock.json.resource.ForbiddenException;
+import org.forgerock.json.resource.ResourceException;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.schema.Schema;
+
+/**
+ * An authorization ID template used for mapping security context principals to
+ * AuthzID templates of the form
+ * <code>dn:uid={uid},ou={realm},dc=example,dc=com</code>, or
+ * <code>u:{uid}@{realm}.example.com</code>.
+ */
+final class AuthzIdTemplate {
+ private static final Pattern KEY_RE = Pattern.compile("\\{([^}]+)\\}");
+
+ private final String dnFormatString;
+ private final String formatString;
+ private final List<String> keys = new ArrayList<String>();
+ private final String template;
+
+ AuthzIdTemplate(final String template) {
+ if (!template.startsWith("u:") && !template.startsWith("dn:")) {
+ throw new IllegalArgumentException("Invalid authorization ID template: " + template);
+ }
+
+ // Parse the template keys and replace them with %s for formatting.
+ final Matcher matcher = KEY_RE.matcher(template);
+ final StringBuffer buffer = new StringBuffer(template.length());
+ while (matcher.find()) {
+ matcher.appendReplacement(buffer, "%s");
+ keys.add(matcher.group(1));
+ }
+ matcher.appendTail(buffer);
+
+ this.template = template;
+ this.formatString = buffer.toString();
+ this.dnFormatString = template.startsWith("dn:") ? formatString.substring(3) : null;
+ }
+
+ @Override
+ public String toString() {
+ return template;
+ }
+
+ String formatAsAuthzId(final Map<String, Object> principals, final Schema schema)
+ throws ResourceException {
+ if (isDNTemplate()) {
+ final String dn = formatAsDN(principals, schema).toString();
+ final StringBuilder builder = new StringBuilder(dn.length() + 3);
+ builder.append("dn:");
+ builder.append(dn);
+ return builder.toString();
+ } else {
+ final String[] values = getPrincipalsForFormatting(principals);
+ return String.format(Locale.ENGLISH, formatString, (Object[]) values);
+ }
+ }
+
+ DN formatAsDN(final Map<String, Object> principals, final Schema schema)
+ throws ResourceException {
+ if (!isDNTemplate()) {
+ throw new IllegalStateException();
+ }
+ final String[] values = getPrincipalsForFormatting(principals);
+ return DN.format(dnFormatString, schema, (Object[]) values);
+ }
+
+ boolean isDNTemplate() {
+ return dnFormatString != null;
+ }
+
+ private String[] getPrincipalsForFormatting(final Map<String, Object> principals)
+ throws ForbiddenException {
+ final String[] values = new String[keys.size()];
+ for (int i = 0; i < values.length; i++) {
+ final String key = keys.get(i);
+ final Object value = principals.get(key);
+ if (isJSONPrimitive(value)) {
+ values[i] = String.valueOf(value);
+ } else if (value == null) {
+ // FIXME: i18n.
+ throw new ForbiddenException(
+ "The request could not be authorized because the required security principal "
+ + key + " could not be determined");
+ } else {
+ // FIXME: i18n.
+ throw new ForbiddenException(
+ "The request could not be authorized because the required security principal "
+ + key + " had an invalid data type");
+ }
+ }
+ return values;
+ }
+}
diff --git a/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java
index ca0e939..f8f2c54 100644
--- a/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java
+++ b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java
@@ -15,6 +15,7 @@
*/
package org.forgerock.opendj.rest2ldap;
+import org.forgerock.opendj.ldap.ConnectionFactory;
import org.forgerock.opendj.ldap.DecodeOptions;
import org.forgerock.opendj.ldap.schema.Schema;
@@ -22,35 +23,63 @@
* Common configuration options.
*/
final class Config {
+ private final ConnectionFactory factory;
private final DecodeOptions options;
+ private final AuthzIdTemplate proxiedAuthzTemplate;
private final ReadOnUpdatePolicy readOnUpdatePolicy;
private final Schema schema;
- Config(final ReadOnUpdatePolicy readOnUpdatePolicy, final Schema schema) {
+ Config(final ConnectionFactory factory, final ReadOnUpdatePolicy readOnUpdatePolicy,
+ final AuthzIdTemplate proxiedAuthzTemplate, final Schema schema) {
+ this.factory = factory;
this.readOnUpdatePolicy = readOnUpdatePolicy;
+ this.proxiedAuthzTemplate = proxiedAuthzTemplate;
this.schema = schema;
this.options = new DecodeOptions().setSchema(schema);
}
/**
+ * Returns the LDAP SDK connection factory which should be used when
+ * performing LDAP operations.
+ *
+ * @return The LDAP SDK connection factory which should be used when
+ * performing LDAP operations.
+ */
+ ConnectionFactory connectionFactory() {
+ return factory;
+ }
+
+ /**
* Returns the decoding options which should be used when decoding controls
* in responses.
*
* @return The decoding options which should be used when decoding controls
* in responses.
*/
- public DecodeOptions decodeOptions() {
+ DecodeOptions decodeOptions() {
return options;
}
/**
+ * Returns the authorization ID template which should be used when proxied
+ * authorization is enabled.
+ *
+ * @return The authorization ID template which should be used when proxied
+ * authorization is enabled, or {@code null} if proxied
+ * authorization is disabled.
+ */
+ AuthzIdTemplate getProxiedAuthorizationTemplate() {
+ return proxiedAuthzTemplate;
+ }
+
+ /**
* Returns the policy which should be used in order to read an entry before
* it is deleted, or after it is added or modified.
*
* @return The policy which should be used in order to read an entry before
* it is deleted, or after it is added or modified.
*/
- public ReadOnUpdatePolicy readOnUpdatePolicy() {
+ ReadOnUpdatePolicy readOnUpdatePolicy() {
return readOnUpdatePolicy;
}
@@ -61,7 +90,18 @@
* @return The schema which should be used when attribute types and
* controls.
*/
- public Schema schema() {
+ Schema schema() {
return schema;
}
+
+ /**
+ * Returns {@code true} if the proxied authorization should be used for
+ * authorizing LDAP requests.
+ *
+ * @return {@code true} if the proxied authorization should be used for
+ * authorizing LDAP requests.
+ */
+ boolean useProxiedAuthorization() {
+ return proxiedAuthzTemplate != null;
+ }
}
diff --git a/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Context.java b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Context.java
index 0a65a76..4136956 100644
--- a/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Context.java
+++ b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Context.java
@@ -16,6 +16,7 @@
package org.forgerock.opendj.rest2ldap;
import static org.forgerock.opendj.ldap.ErrorResultException.newErrorResult;
+import static org.forgerock.opendj.rest2ldap.Utils.adapt;
import java.io.Closeable;
import java.util.LinkedHashMap;
@@ -24,6 +25,9 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;
+import org.forgerock.json.resource.InternalServerErrorException;
+import org.forgerock.json.resource.ResourceException;
+import org.forgerock.json.resource.SecurityContext;
import org.forgerock.json.resource.ServerContext;
import org.forgerock.opendj.ldap.AbstractAsynchronousConnection;
import org.forgerock.opendj.ldap.Connection;
@@ -35,6 +39,8 @@
import org.forgerock.opendj.ldap.ResultHandler;
import org.forgerock.opendj.ldap.SearchResultHandler;
import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.ldap.controls.Control;
+import org.forgerock.opendj.ldap.controls.ProxiedAuthV2RequestControl;
import org.forgerock.opendj.ldap.requests.AbandonRequest;
import org.forgerock.opendj.ldap.requests.AddRequest;
import org.forgerock.opendj.ldap.requests.BindRequest;
@@ -43,6 +49,7 @@
import org.forgerock.opendj.ldap.requests.ExtendedRequest;
import org.forgerock.opendj.ldap.requests.ModifyDNRequest;
import org.forgerock.opendj.ldap.requests.ModifyRequest;
+import org.forgerock.opendj.ldap.requests.Request;
import org.forgerock.opendj.ldap.requests.SearchRequest;
import org.forgerock.opendj.ldap.requests.UnbindRequest;
import org.forgerock.opendj.ldap.responses.BindResult;
@@ -190,14 +197,21 @@
};
private final Config config;
-
private final AtomicReference<Connection> connection = new AtomicReference<Connection>();
-
private final ServerContext context;
+ private final Connection preAuthenticatedConnection;
+ private Control proxiedAuthzControl = null;
Context(final Config config, final ServerContext context) {
this.config = config;
this.context = context;
+ if (context.containsContext(AuthenticatedConnectionContext.class)) {
+ final Connection connection =
+ context.asContext(AuthenticatedConnectionContext.class).getConnection();
+ this.preAuthenticatedConnection = connection != null ? wrap(connection) : null;
+ } else {
+ this.preAuthenticatedConnection = null;
+ }
}
/**
@@ -205,6 +219,11 @@
*/
@Override
public void close() {
+ /*
+ * Only release the connection that we acquired. Don't release the
+ * cached connection since that is the responsibility of the component
+ * which acquired it.
+ */
final Connection c = connection.getAndSet(null);
if (c != null) {
c.close();
@@ -216,28 +235,98 @@
}
Connection getConnection() {
- return connection.get();
+ return preAuthenticatedConnection != null ? preAuthenticatedConnection : connection.get();
}
ServerContext getServerContext() {
return context;
}
- void setConnection(final Connection connection) {
- if (!this.connection.compareAndSet(null, withCache(connection))) {
- throw new IllegalStateException("LDAP connection obtained multiple times");
+ /**
+ * Performs common processing required before handling an HTTP request,
+ * including calculating the proxied authorization request control, and
+ * obtaining an LDAP connection.
+ * <p>
+ * This method should be called at most once per request.
+ *
+ * @param handler
+ * The result handler which should be invoked if an error is
+ * detected.
+ * @param runnable
+ * The runnable which will be invoked once the common processing
+ * has completed. Implementations will be able to call
+ * {@link #getConnection()} to get the LDAP connection for use
+ * with subsequent LDAP requests.
+ */
+ void run(final org.forgerock.json.resource.ResultHandler<?> handler, final Runnable runnable) {
+ /*
+ * Compute the proxied authorization control from the content of the
+ * security context if present. Only do this if we are not using a
+ * cached connection since cached connections are supposed to have been
+ * pre-authenticated and therefore do not require proxied authorization.
+ */
+ if (preAuthenticatedConnection == null && config.useProxiedAuthorization()) {
+ if (context.containsContext(SecurityContext.class)) {
+ try {
+ final SecurityContext securityContext =
+ context.asContext(SecurityContext.class);
+ final String authzId =
+ config.getProxiedAuthorizationTemplate().formatAsAuthzId(
+ securityContext.getAuthorizationId(), config.schema());
+ proxiedAuthzControl = ProxiedAuthV2RequestControl.newControl(authzId);
+ } catch (final ResourceException e) {
+ handler.handleError(e);
+ return;
+ }
+ } else {
+ // FIXME: i18n.
+ handler.handleError(new InternalServerErrorException(
+ "The request could not be authorized because it did not contain a security context"));
+ return;
+ }
+ }
+
+ /*
+ * Now get the LDAP connection to use for processing subsequent LDAP
+ * requests. A null factory indicates that Rest2LDAP has been configured
+ * to re-use the LDAP connection which was used for authentication.
+ */
+ if (preAuthenticatedConnection != null) {
+ // Invoke the handler immediately since a connection is available.
+ runnable.run();
+ } else if (config.connectionFactory() != null) {
+ config.connectionFactory().getConnectionAsync(new ResultHandler<Connection>() {
+ @Override
+ public final void handleErrorResult(final ErrorResultException error) {
+ handler.handleError(adapt(error));
+ }
+
+ @Override
+ public final void handleResult(final Connection result) {
+ if (!connection.compareAndSet(null, wrap(result))) {
+ // This should never happen.
+ throw new IllegalStateException("LDAP connection obtained multiple times");
+ }
+ runnable.run();
+ }
+ });
+ } else {
+ // FIXME: i18n
+ handler.handleError(new InternalServerErrorException(
+ "The request could not be processed because there was no LDAP connection available for use"));
}
}
/*
- * Adds read caching support to the provided connection.
+ * Adds read caching support to the provided connection as well
+ * functionality which automatically adds the proxied authorization control
+ * if needed.
*/
- private Connection withCache(final Connection connection) {
+ private Connection wrap(final Connection connection) {
/*
* We only use async methods so no need to wrap sync methods.
*/
return new AbstractAsynchronousConnection() {
-
@Override
public FutureResult<Void> abandonAsync(final AbandonRequest request) {
return connection.abandonAsync(request);
@@ -247,7 +336,8 @@
public FutureResult<Result> addAsync(final AddRequest request,
final IntermediateResponseHandler intermediateResponseHandler,
final ResultHandler<? super Result> resultHandler) {
- return connection.addAsync(request, intermediateResponseHandler, resultHandler);
+ return connection.addAsync(withControls(request), intermediateResponseHandler,
+ resultHandler);
}
@Override
@@ -276,7 +366,8 @@
public FutureResult<CompareResult> compareAsync(final CompareRequest request,
final IntermediateResponseHandler intermediateResponseHandler,
final ResultHandler<? super CompareResult> resultHandler) {
- return connection.compareAsync(request, intermediateResponseHandler, resultHandler);
+ return connection.compareAsync(withControls(request), intermediateResponseHandler,
+ resultHandler);
}
@Override
@@ -284,7 +375,8 @@
final IntermediateResponseHandler intermediateResponseHandler,
final ResultHandler<? super Result> resultHandler) {
evict(request.getName());
- return connection.deleteAsync(request, intermediateResponseHandler, resultHandler);
+ return connection.deleteAsync(withControls(request), intermediateResponseHandler,
+ resultHandler);
}
@Override
@@ -297,8 +389,8 @@
* operation modifies an entry: clear the cachedReads.
*/
evictAll();
- return connection.extendedRequestAsync(request, intermediateResponseHandler,
- resultHandler);
+ return connection.extendedRequestAsync(withControls(request),
+ intermediateResponseHandler, resultHandler);
}
@Override
@@ -316,7 +408,8 @@
final IntermediateResponseHandler intermediateResponseHandler,
final ResultHandler<? super Result> resultHandler) {
evict(request.getName());
- return connection.modifyAsync(request, intermediateResponseHandler, resultHandler);
+ return connection.modifyAsync(withControls(request), intermediateResponseHandler,
+ resultHandler);
}
@Override
@@ -325,8 +418,8 @@
final ResultHandler<? super Result> resultHandler) {
// Simple brute force implementation: clear the cachedReads.
evictAll();
- return connection
- .modifyDNAsync(request, intermediateResponseHandler, resultHandler);
+ return connection.modifyDNAsync(withControls(request), intermediateResponseHandler,
+ resultHandler);
}
@Override
@@ -341,7 +434,6 @@
public FutureResult<Result> searchAsync(final SearchRequest request,
final IntermediateResponseHandler intermediateResponseHandler,
final SearchResultHandler resultHandler) {
-
/*
* Don't attempt caching if this search is not a read (base
* object), or if the search request passed in an intermediate
@@ -349,8 +441,8 @@
*/
if (!request.getScope().equals(SearchScope.BASE_OBJECT)
|| intermediateResponseHandler != null) {
- return connection.searchAsync(request, intermediateResponseHandler,
- resultHandler);
+ return connection.searchAsync(withControls(request),
+ intermediateResponseHandler, resultHandler);
}
// This is a read request and a candidate for caching.
@@ -369,8 +461,8 @@
cachedReads.put(request.getName(), pendingCachedRead);
}
final FutureResult<Result> future =
- connection.searchAsync(request, intermediateResponseHandler,
- pendingCachedRead);
+ connection.searchAsync(withControls(request),
+ intermediateResponseHandler, pendingCachedRead);
pendingCachedRead.setFuture(future);
return future;
}
@@ -392,6 +484,13 @@
cachedReads.clear();
}
}
+
+ private <R extends Request> R withControls(final R request) {
+ if (proxiedAuthzControl != null) {
+ request.addControl(proxiedAuthzControl);
+ }
+ return request;
+ }
};
}
diff --git a/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java
index d2aa3fb..b4eda51 100644
--- a/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java
+++ b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java
@@ -54,8 +54,6 @@
import org.forgerock.json.resource.UncategorizedException;
import org.forgerock.json.resource.UpdateRequest;
import org.forgerock.opendj.ldap.Attribute;
-import org.forgerock.opendj.ldap.Connection;
-import org.forgerock.opendj.ldap.ConnectionFactory;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.DecodeException;
import org.forgerock.opendj.ldap.Entry;
@@ -79,50 +77,21 @@
* resource collection to LDAP entries beneath a base DN.
*/
final class LDAPCollectionResourceProvider implements CollectionResourceProvider {
- private abstract class ConnectionCompletionHandler implements
- org.forgerock.opendj.ldap.ResultHandler<Connection> {
- private final Context c;
- private final ResultHandler<?> handler;
-
- ConnectionCompletionHandler(final Context c, final ResultHandler<?> handler) {
- this.c = c;
- this.handler = handler;
- }
-
- @Override
- public final void handleErrorResult(final ErrorResultException error) {
- handler.handleError(adapt(error));
- }
-
- @Override
- public final void handleResult(final Connection connection) {
- c.setConnection(connection);
- chain();
- }
-
- abstract void chain();
- }
-
// Dummy exception used for signalling search success.
private static final ResourceException SUCCESS = new UncategorizedException(0, null, null);
private final List<Attribute> additionalLDAPAttributes;
-
private final AttributeMapper attributeMapper;
-
private final DN baseDN; // TODO: support template variables.
private final Config config;
- private final ConnectionFactory factory;
private final MVCCStrategy mvccStrategy;
private final NameStrategy nameStrategy;
LDAPCollectionResourceProvider(final DN baseDN, final AttributeMapper mapper,
- final ConnectionFactory factory, final NameStrategy nameStrategy,
- final MVCCStrategy mvccStrategy, final Config config,
+ final NameStrategy nameStrategy, final MVCCStrategy mvccStrategy, final Config config,
final List<Attribute> additionalLDAPAttributes) {
this.baseDN = baseDN;
this.attributeMapper = mapper;
- this.factory = factory;
this.config = config;
this.nameStrategy = nameStrategy;
this.mvccStrategy = mvccStrategy;
@@ -148,9 +117,9 @@
final ResultHandler<Resource> h = wrap(c, handler);
// Get the connection, then determine entry content, then perform add.
- factory.getConnectionAsync(new ConnectionCompletionHandler(c, h) {
+ c.run(h, new Runnable() {
@Override
- void chain() {
+ public void run() {
// Calculate entry content.
attributeMapper.toLDAP(c, request.getContent(),
new ResultHandler<List<Attribute>>() {
@@ -209,9 +178,9 @@
final QueryResultHandler h = wrap(c, handler);
// Get the connection, then calculate the search filter, then perform the search.
- factory.getConnectionAsync(new ConnectionCompletionHandler(c, h) {
+ c.run(h, new Runnable() {
@Override
- void chain() {
+ public void run() {
// Calculate the filter (this may require the connection).
getLDAPFilter(c, request.getQueryFilter(), new ResultHandler<Filter>() {
@Override
@@ -331,9 +300,9 @@
final ResultHandler<Resource> h = wrap(c, handler);
// Get connection then perform the search.
- factory.getConnectionAsync(new ConnectionCompletionHandler(c, h) {
+ c.run(h, new Runnable() {
@Override
- void chain() {
+ public void run() {
// Do the search.
final String[] attributes = getLDAPAttributes(c, request.getFieldFilters());
final SearchRequest request =
diff --git a/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAP.java b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAP.java
index ef84e2f..11f43c4 100644
--- a/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAP.java
+++ b/opendj-sdk/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAP.java
@@ -23,8 +23,10 @@
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -69,6 +71,7 @@
private ConnectionFactory factory;
private MVCCStrategy mvccStrategy;
private NameStrategy nameStrategy;
+ private AuthzIdTemplate proxiedAuthzTemplate;
private ReadOnUpdatePolicy readOnUpdatePolicy = CONTROLS;
private AttributeMapper rootMapper;
private Schema schema = Schema.getDefaultSchema();
@@ -98,30 +101,17 @@
}
public CollectionResourceProvider build() {
- ensureNotNull(factory);
ensureNotNull(baseDN);
if (rootMapper == null) {
throw new IllegalStateException("No mappings provided");
}
- return new LDAPCollectionResourceProvider(baseDN, rootMapper, factory, nameStrategy,
- mvccStrategy, new Config(readOnUpdatePolicy, schema), additionalLDAPAttributes);
- }
-
- /**
- * Configures the connection factory using the provided JSON
- * configuration. See
- * {@link Rest2LDAP#configureConnectionFactory(JsonValue)} for a
- * detailed specification of the JSON configuration.
- *
- * @param configuration
- * The JSON configuration.
- * @return A reference to this builder.
- * @throws IllegalArgumentException
- * If the configuration is invalid.
- */
- public Builder configureConnectionFactory(final JsonValue configuration) {
- connectionFactory(Rest2LDAP.configureConnectionFactory(configuration));
- return this;
+ if (proxiedAuthzTemplate != null && factory == null) {
+ throw new IllegalStateException(
+ "No connection factory specified for use with proxied authorization");
+ }
+ return new LDAPCollectionResourceProvider(baseDN, rootMapper, nameStrategy,
+ mvccStrategy, new Config(factory, readOnUpdatePolicy, proxiedAuthzTemplate,
+ schema), additionalLDAPAttributes);
}
/**
@@ -243,7 +233,6 @@
}
public Builder connectionFactory(final ConnectionFactory factory) {
- ensureNotNull(factory);
this.factory = factory;
return this;
}
@@ -313,6 +302,11 @@
return useEtagAttribute(ad(attribute));
}
+ public Builder useProxiedAuthorization(final String template) {
+ this.proxiedAuthzTemplate = template != null ? new AuthzIdTemplate(template) : null;
+ return this;
+ }
+
public Builder useServerEntryUUIDNaming(final AttributeType dnAttribute) {
return useServerNaming(dnAttribute, AttributeDescription
.create(getEntryUUIDAttributeType()));
@@ -539,61 +533,99 @@
}
/**
- * Creates a new connection factory using the provided JSON configuration.
- * The configuration should look like this, excluding the C-like comments:
+ * Creates a new connection factory using the named configuration in the
+ * provided JSON list of factory configurations. Excluding the C-like
+ * comments, the configuration should look like this:
*
* <pre>
* {
- * // The primary data center, must contain at least one LDAP server.
- * "primaryLDAPServers" : [
- * {
- * "hostname" : "host1.example.com",
- * "port" : 389
- * },
- * {
- * "hostname" : "host2.example.com",
- * "port" : 389
- * },
- * ],
+ * // A default pool of servers using no authentication.
+ * "default" : {
+ * // The primary data center, must contain at least one LDAP server.
+ * "primaryLDAPServers" : [
+ * {
+ * "hostname" : "host1.example.com",
+ * "port" : 389
+ * },
+ * {
+ * "hostname" : "host2.example.com",
+ * "port" : 389
+ * },
+ * ],
*
- * // The optional secondary (fail-over) data center.
- * "secondaryLDAPServers" : [
- * {
- * "hostname" : "host3.example.com",
- * "port" : 389
- * },
- * {
- * "hostname" : "host4.example.com",
- * "port" : 389
- * },
- * ],
+ * // The optional secondary (fail-over) data center.
+ * "secondaryLDAPServers" : [
+ * {
+ * "hostname" : "host3.example.com",
+ * "port" : 389
+ * },
+ * {
+ * "hostname" : "host4.example.com",
+ * "port" : 389
+ * },
+ * ],
*
- * // Connection pool configuration.
- * "connectionPoolSize" : 10,
- * "heartBeatIntervalSeconds" : 30,
- *
- * // SSL/TLS configuration (optional and TBD).
- * "useSSL" : {
- * // Elect to use StartTLS instead of SSL.
- * "useStartTLS" : true,
- * ...
+ * // Connection pool configuration.
+ * "connectionPoolSize" : 10,
+ * "heartBeatIntervalSeconds" : 30
* },
*
- * // Authentication configuration (optional and TBD).
- * "authentication" : {
- * "bindDN" : "cn=directory manager",
- * "password" : "password"
- * },
+ * // The same pool of servers except authenticated as cn=directory manager.
+ * "root" : {
+ * "inheritFrom" : "default",
+ * "authentication" : {
+ * "simple" : {
+ * "bindDN" : "cn=directory manager",
+ * "bindPassword" : "password"
+ * }
+ * }
+ * }
* }
* </pre>
*
* @param configuration
* The JSON configuration.
+ * @param name
+ * The name of the connection factory configuration to be parsed.
* @return A new connection factory using the provided JSON configuration.
* @throws IllegalArgumentException
* If the configuration is invalid.
*/
- public static ConnectionFactory configureConnectionFactory(final JsonValue configuration) {
+ public static ConnectionFactory configureConnectionFactory(final JsonValue configuration,
+ final String name) {
+ final JsonValue normalizedConfiguration =
+ normalizeConnectionFactory(configuration, name, 0);
+ return configureConnectionFactory(normalizedConfiguration);
+ }
+
+ public static AttributeMapper constant(final Object value) {
+ return new JSONConstantAttributeMapper(value);
+ }
+
+ public static ObjectAttributeMapper object() {
+ return new ObjectAttributeMapper();
+ }
+
+ public static ReferenceAttributeMapper reference(final AttributeDescription attribute,
+ final DN baseDN, final AttributeDescription primaryKey, final AttributeMapper mapper) {
+ return new ReferenceAttributeMapper(attribute, baseDN, primaryKey, mapper);
+ }
+
+ public static ReferenceAttributeMapper reference(final String attribute, final String baseDN,
+ final String primaryKey, final AttributeMapper mapper) {
+ return reference(AttributeDescription.valueOf(attribute), DN.valueOf(baseDN),
+ AttributeDescription.valueOf(primaryKey), mapper);
+ }
+
+ public static SimpleAttributeMapper simple(final AttributeDescription attribute) {
+ return new SimpleAttributeMapper(attribute);
+ }
+
+ public static SimpleAttributeMapper simple(final String attribute) {
+ return simple(AttributeDescription.valueOf(attribute));
+ }
+
+ private static ConnectionFactory configureConnectionFactory(final JsonValue configuration) {
// Parse pool parameters,
final int connectionPoolSize =
Math.max(configuration.get("connectionPoolSize").defaultTo(10).asInteger(), 1);
@@ -604,9 +636,14 @@
final BindRequest bindRequest;
if (configuration.isDefined("authentication")) {
final JsonValue authn = configuration.get("authentication");
- bindRequest =
- Requests.newSimpleBindRequest(authn.get("bindDN").required().asString(), authn
- .get("password").required().asString().toCharArray());
+ if (authn.isDefined("simple")) {
+ final JsonValue simple = authn.get("simple");
+ bindRequest =
+ Requests.newSimpleBindRequest(simple.get("bindDN").required().asString(),
+ simple.get("bindPassword").required().asString().toCharArray());
+ } else {
+ throw new IllegalArgumentException("Only simple authentication is supported");
+ }
} else {
bindRequest = null;
}
@@ -642,31 +679,31 @@
}
}
- public static AttributeMapper constant(final Object value) {
- return new JSONConstantAttributeMapper(value);
- }
+ private static JsonValue normalizeConnectionFactory(final JsonValue configuration,
+ final String name, final int depth) {
+ // Protect against infinite recursion in the configuration.
+ if (depth > 100) {
+ throw new IllegalArgumentException(
+ "The LDAP server configuration '"
+ + name
+ + "' could not be parsed because of potential circular inheritance dependencies");
+ }
- public static ObjectAttributeMapper object() {
- return new ObjectAttributeMapper();
- }
-
- public static ReferenceAttributeMapper reference(final AttributeDescription attribute,
- final DN baseDN, final AttributeDescription primaryKey, final AttributeMapper mapper) {
- return new ReferenceAttributeMapper(attribute, baseDN, primaryKey, mapper);
- }
-
- public static ReferenceAttributeMapper reference(final String attribute, final String baseDN,
- final String primaryKey, final AttributeMapper mapper) {
- return reference(AttributeDescription.valueOf(attribute), DN.valueOf(baseDN),
- AttributeDescription.valueOf(primaryKey), mapper);
- }
-
- public static SimpleAttributeMapper simple(final AttributeDescription attribute) {
- return new SimpleAttributeMapper(attribute);
- }
-
- public static SimpleAttributeMapper simple(final String attribute) {
- return simple(AttributeDescription.valueOf(attribute));
+ final JsonValue current = configuration.get(name).required();
+ if (current.isDefined("inheritFrom")) {
+ // Inherit missing fields from inherited configuration.
+ final JsonValue parent =
+ normalizeConnectionFactory(configuration,
+ current.get("inheritFrom").asString(), depth + 1);
+ final Map<String, Object> normalized =
+ new LinkedHashMap<String, Object>(parent.asMap());
+ normalized.putAll(current.asMap());
+ normalized.remove("inheritFrom");
+ return new JsonValue(normalized);
+ } else {
+ // No normalization required.
+ return current;
+ }
}
private static ConnectionFactory parseLDAPServers(final JsonValue config,
diff --git a/opendj-sdk/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplateTest.java b/opendj-sdk/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplateTest.java
new file mode 100644
index 0000000..7376fce
--- /dev/null
+++ b/opendj-sdk/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplateTest.java
@@ -0,0 +1,123 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2013 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static org.fest.assertions.Assertions.assertThat;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.forgerock.json.resource.ForbiddenException;
+import org.forgerock.opendj.ldap.schema.Schema;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Tests the AuthzIdTemplate class.
+ */
+@SuppressWarnings({ "javadoc" })
+@Test
+public final class AuthzIdTemplateTest extends ForgeRockTestCase {
+
+ @DataProvider
+ public Object[][] templateData() {
+ // @formatter:off
+ return new Object[][] {
+ {
+ "dn:uid={uid},ou={realm},dc=example,dc=com",
+ "dn:uid=test.user,ou=acme,dc=example,dc=com",
+ map("uid", "test.user", "realm", "acme")
+ },
+ {
+ // Should perform DN quoting.
+ "dn:uid={uid},ou={realm},dc=example,dc=com",
+ "dn:uid=test.user,ou=test\\+cn\\=quoting,dc=example,dc=com",
+ map("uid", "test.user", "realm", "test+cn=quoting")
+ },
+ {
+ "u:{uid}@{realm}.example.com",
+ "u:test.user@acme.example.com",
+ map("uid", "test.user", "realm", "acme")
+ },
+ {
+ // Should not perform any DN quoting.
+ "u:{uid}@{realm}.example.com",
+ "u:test.user@test+cn=quoting.example.com",
+ map("uid", "test.user", "realm", "test+cn=quoting")
+ },
+ };
+ // @formatter:on
+
+ }
+
+ @Test(dataProvider = "templateData")
+ public void testTemplates(final String template, final String expected,
+ Map<String, Object> principals) throws Exception {
+ assertThat(
+ new AuthzIdTemplate(template)
+ .formatAsAuthzId(principals, Schema.getDefaultSchema()))
+ .isEqualTo(expected);
+ }
+
+ @DataProvider
+ public Object[][] invalidTemplateData() {
+ // @formatter:off
+ return new Object[][] {
+ {
+ "dn:uid={uid},ou={realm},dc=example,dc=com",
+ map("uid", "test.user")
+ },
+ {
+ "u:{uid}@{realm}.example.com",
+ map("uid", "test.user")
+ },
+ };
+ // @formatter:on
+
+ }
+
+ @Test(dataProvider = "invalidTemplateData", expectedExceptions = ForbiddenException.class)
+ public void testInvalidTemplateData(final String template, Map<String, Object> principals)
+ throws Exception {
+ new AuthzIdTemplate(template).formatAsAuthzId(principals, Schema.getDefaultSchema());
+ }
+
+ @DataProvider
+ public Object[][] invalidTemplates() {
+ // @formatter:off
+ return new Object[][] {
+ {
+ "x:uid={uid},ou={realm},dc=example,dc=com"
+ },
+ };
+ // @formatter:on
+
+ }
+
+ @Test(dataProvider = "invalidTemplates", expectedExceptions = IllegalArgumentException.class)
+ public void testInvalidTemplates(final String template) throws Exception {
+ new AuthzIdTemplate(template);
+ }
+
+ private Map<String, Object> map(String... keyValues) {
+ Map<String, Object> map = new LinkedHashMap<String, Object>();
+ for (int i = 0; i < keyValues.length; i += 2) {
+ map.put(keyValues[i], keyValues[i + 1]);
+ }
+ return map;
+ }
+}
--
Gitblit v1.10.0