diff --git a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-rest2ldap.adoc b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-rest2ldap.adoc index 2adc96b029..d56c4ceeb0 100644 --- a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-rest2ldap.adoc +++ b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-rest2ldap.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024-2025 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -415,6 +415,10 @@ A single occurrence of the string `\{username\}` is replaced in the template wit + For example, if the user name is also the UID of the LDAP entry, use `uid=\{username\},ou=People,dc=example,dc=com`. +The user name is then escaped as an attribute value. + ++ +A template which is just `\{username\}` takes the user name as the bind DN. + Default: `\{username\}` @@ -445,6 +449,11 @@ If the user name is also the authorization ID, use `u:\{username\}`. + If the user name is the LDAP bind DN, use `dn:\{username\}`. +After `dn:`, the template is a bind DN template like `bindDnTemplate` of the `simple` bind, +for example `dn:uid=\{username\},ou=People,dc=example,dc=com`. + ++ +Default: `u:\{username\}` ======== @@ -579,6 +588,8 @@ A JSON pointer value in braces is replaced in the template with a field value fr + This template must start with `u:` or `dn:`. +After `dn:`, a field value is escaped as an attribute value, unless the rest of the template is just that one field: +then the value is taken as the DN. + For example, if token resolution returns a JSON document where the value of the `uid` field is the UID of the user entry in the directory, you might use `u:\{uid\}` or `dn:\{uid\},ou=People,dc=example,dc=com`. @@ -629,6 +640,8 @@ A JSON pointer value in braces is replaced in the template with a field value fr + This template must start with `u:` or `dn:`. +After `dn:`, a field value is escaped as an attribute value, unless the rest of the template is just that one field: +then the value is taken as the DN. + For example, if token resolution returns a JSON document where the value of the `username` field is the UID of the user entry in the directory, you might use `u:\{username\}` or `dn:\{username\},ou=People,dc=example,dc=com`. @@ -666,6 +679,8 @@ A JSON pointer value in braces is replaced in the template with a field value fr + This template must start with `u:` or `dn:`. +After `dn:`, a field value is escaped as an attribute value, unless the rest of the template is just that one field: +then the value is taken as the DN. + In OpenAM CTS, the user name field is an array. For example, if the user name is the UID of the user entry, the use `u:{userName/0}` or `dn:{userName/0},ou=People,dc=example,dc=com`. diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/DnTemplate.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/DnTemplate.java index 4433bda8f3..d6d568826f 100644 --- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/DnTemplate.java +++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/DnTemplate.java @@ -84,10 +84,10 @@ private static DnTemplate compile(String template, boolean isRelative) { if (template.equals("..")) { trimmedTemplate = ""; relativeOffset = 1; - } else if (template.endsWith(",..")) { + } else if (endsWithParentRdn(template)) { relativeOffset = 0; for (trimmedTemplate = template; - trimmedTemplate.endsWith(",.."); + endsWithParentRdn(trimmedTemplate); trimmedTemplate = trimmedTemplate.substring(0, trimmedTemplate.length() - 3)) { relativeOffset++; } @@ -99,17 +99,33 @@ private static DnTemplate compile(String template, boolean isRelative) { relativeOffset = -1; } + // Replace the variables with %s, and escape any '%' around them, which String.format() would read as a + // format specifier. final List templateVariables = new ArrayList<>(); final Matcher matcher = TEMPLATE_VARIABLE_RE.matcher(trimmedTemplate); - final StringBuffer buffer = new StringBuffer(trimmedTemplate.length()); + final StringBuilder buffer = new StringBuilder(trimmedTemplate.length()); + int fixedPartStart = 0; while (matcher.find()) { - matcher.appendReplacement(buffer, "%s"); + buffer.append(trimmedTemplate.substring(fixedPartStart, matcher.start()).replace("%", "%%")).append("%s"); templateVariables.add(matcher.group(1)); + fixedPartStart = matcher.end(); } - matcher.appendTail(buffer); + buffer.append(trimmedTemplate.substring(fixedPartStart).replace("%", "%%")); return new DnTemplate(trimmedTemplate, buffer.toString(), templateVariables, relativeOffset); } + /** Returns whether the template ends with a ".." RDN, that is ",.." whose comma is not escaped. */ + private static boolean endsWithParentRdn(final String template) { + if (!template.endsWith(",..")) { + return false; + } + int backslashes = 0; + for (int i = template.length() - 4; i >= 0 && template.charAt(i) == '\\'; i--) { + backslashes++; + } + return backslashes % 2 == 0; + } + private DnTemplate(String template, String formatString, List variables, int relativeOffset) { this.template = template; this.formatString = formatString; diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java index 1cacf95405..684eb0712f 100644 --- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java +++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java @@ -447,7 +447,7 @@ protected ConnectionFactory getConnectionFactory(final String name) { return connectionFactories.get(name); } - private ConditionalFilter buildBasicFilter(final JsonValue config) { + ConditionalFilter buildBasicFilter(final JsonValue config) { final String bind = config.get("bind").required().asString(); final BindStrategy strategy = BindStrategy.valueOf(bind.toUpperCase().replace('-', '_')); return newBasicAuthenticationFilter(buildBindStrategy(strategy, config.get(bind).required()), @@ -494,14 +494,14 @@ private AuthenticationStrategy buildBindStrategy(final BindStrategy strategy, fi private AuthenticationStrategy buildSimpleBindStrategy(final JsonValue config) { return newSimpleBindStrategy(getConnectionFactory(config.get("ldapConnectionFactory") .defaultTo(DEFAULT_BIND_FACTORY).asString()), - parseUserNameTemplate(config.get("bindDnTemplate").defaultTo("%s")), + parseUserNameTemplate(config.get("bindDnTemplate").defaultTo("{username}")), schema); } private AuthenticationStrategy buildSaslBindStrategy(JsonValue config) { return newSaslPlainStrategy( getConnectionFactory(config.get("ldapConnectionFactory").defaultTo(DEFAULT_BIND_FACTORY).asString()), - schema, parseUserNameTemplate(config.get(AUTHZID_TEMPLATE).defaultTo("u:%s"))); + schema, parseUserNameTemplate(config.get(AUTHZID_TEMPLATE).defaultTo("u:{username}"))); } private AuthenticationStrategy buildSearchThenBindStrategy(JsonValue config) { @@ -516,6 +516,7 @@ private AuthenticationStrategy buildSearchThenBindStrategy(JsonValue config) { } private String parseUserNameTemplate(final JsonValue template) { - return template.asString().replace("{username}", "%s"); + // The strategies format the template with String.format(): keep any other '%' literal. + return template.asString().replace("%", "%%").replace("{username}", "%s"); } } diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthenticationStrategies.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthenticationStrategies.java index 35b40e6944..5b0655e76b 100644 --- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthenticationStrategies.java +++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthenticationStrategies.java @@ -38,7 +38,8 @@ private AuthenticationStrategies() { * {@link ConnectionFactory} to the LDAP server used to perform the bind operation. * @param bindDNTemplate * Tempalte of the DN to use for the bind operation. The first %s will be replaced by the provided - * authentication-id (i.e: uid=%s,dc=example,dc=com) + * authentication-id (i.e: uid=%s,dc=example,dc=com). A template which is just %s takes the + * authentication-id as the bind DN. * @param schema * {@link Schema} used to validate the DN format.* * @return a new simple bind {@link AuthenticationStrategy} @@ -85,7 +86,8 @@ public static AuthenticationStrategy newSearchThenBindStrategy(ConnectionFactory * {@link ConnectionFactory} to the LDAP server to authenticate with. * @param authcIdTemplate * Authentication identity template containing a single %s which will be replaced by the authenticating - * user's name. (i.e: (u:%s) + * user's name. (i.e: (u:%s). After a "dn:" prefix the template is a bind DN template: just %s takes the + * user name as the DN, otherwise the user name is escaped as an attribute value. * @param schema * Schema used to perform DN validation. * @return a new SASL plain bind {@link AuthenticationStrategy} diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java index 096ad78f00..dc5b6530fe 100644 --- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java +++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2013-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.rest2ldap.authz; @@ -48,6 +49,10 @@ private interface Impl { public String formatAsAuthzId(final AuthzIdTemplate t, final Object[] templateVariables) { // We're not interested in matching and place-holder attribute types can be tolerated, // so we can just use the core schema. + // A template which is just one placeholder takes the principal as the DN, rather than as one RDN value. + if ("%s".equals(t.formatString)) { + return DN.valueOf(String.valueOf(templateVariables[0]), Schema.getCoreSchema()).toString(); + } return DN.format(t.formatString, Schema.getCoreSchema(), templateVariables).toString(); } }; @@ -126,14 +131,17 @@ private String removeTemplateKey(final String formattedString) { } private String formatTemplate(final String template) { - // Parse the template keys and replace them with %s for formatting. + // Parse the template keys and replace them with %s for formatting. Escape any '%' around them, which + // String.format() would read as a format specifier. final Matcher matcher = TEMPLATE_KEY_RE.matcher(template); - final StringBuffer buffer = new StringBuffer(template.length()); + final StringBuilder buffer = new StringBuilder(template.length()); + int fixedPartStart = 0; while (matcher.find()) { - matcher.appendReplacement(buffer, "%s"); + buffer.append(template.substring(fixedPartStart, matcher.start()).replace("%", "%%")).append("%s"); keys.add(matcher.group(1)); + fixedPartStart = matcher.end(); } - matcher.appendTail(buffer); + buffer.append(template.substring(fixedPartStart).replace("%", "%%")); return type.removeTemplateKey(buffer.toString()); } diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java index 50d5e9a6fe..e5766731d6 100644 --- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java +++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractors.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.rest2ldap.authz; @@ -98,6 +99,9 @@ private static final class HttpBasicExtractor /** Reference to the HttpBasicExtractor Singleton. */ public static final HttpBasicExtractor INSTANCE = new HttpBasicExtractor(); + /** The authentication scheme and the space which separates it from the credentials. */ + private static final String BASIC_SCHEME = "basic "; + private HttpBasicExtractor() { } @Override @@ -113,17 +117,23 @@ public Pair apply(Headers headers) { } private Pair parseUsernamePassword(String authHeader) { - if (authHeader != null && (authHeader.toLowerCase().startsWith("basic"))) { + if (authHeader != null && authHeader.regionMatches(true, 0, BASIC_SCHEME, 0, BASIC_SCHEME.length())) { // We received authentication info // Example received header: // "Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" - final String base64UserCredentials = authHeader.substring("basic".length() + 1); + final String base64UserCredentials = authHeader.substring(BASIC_SCHEME.length()); // Example usage of base64: // Base64("Aladdin:open sesame") = "QWxhZGRpbjpvcGVuIHNlc2FtZQ==" - final String userCredentials = new String(Base64.decode(base64UserCredentials)); - String[] split = userCredentials.split(":"); - if (split.length == 2) { - return Pair.of(split[0], split[1]); + final byte[] decoded = Base64.decode(base64UserCredentials); + if (decoded == null) { + // Not a multiple of 4 characters long once the characters outside base64 are dropped. + return null; + } + final String userCredentials = new String(decoded); + // RFC 7617 section 2: the user-id cannot contain a colon, the password can. + final int colon = userCredentials.indexOf(':'); + if (colon >= 0) { + return Pair.of(userCredentials.substring(0, colon), userCredentials.substring(colon + 1)); } } return null; diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilter.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilter.java index 76dd90ab2d..a95a8f3ff3 100644 --- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilter.java +++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilter.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.rest2ldap.authz; @@ -63,7 +64,9 @@ public HttpBasicAuthenticationFilter(AuthenticationStrategy authenticationStrate public Promise filter(final Context context, final Request request, final Handler next) { final Pair credentials = credentialsExtractor.apply(request.getHeaders()); - if (credentials == null) { + // A simple bind with a DN and an empty password is an unauthenticated bind (RFC 4513 section 5.1.2): a server + // which accepts it would let the request run as the named user without checking any password. + if (credentials == null || credentials.getSecond().isEmpty()) { return asErrorResponse(LdapException.newLdapException(ResultCode.INVALID_CREDENTIALS)); } return authenticationStrategy diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java index 58b7b09810..a88384454a 100644 --- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java +++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategy.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.rest2ldap.authz; @@ -20,19 +21,17 @@ import static org.forgerock.services.context.SecurityContext.AUTHZID_ID; import static org.forgerock.util.Reject.checkNotNull; import static org.forgerock.opendj.rest2ldap.authz.Utils.close; +import static org.forgerock.opendj.rest2ldap.authz.Utils.formatBindDn; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; -import org.forgerock.i18n.LocalizedIllegalArgumentException; 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.DecodeOptions; import org.forgerock.opendj.ldap.LdapException; -import org.forgerock.opendj.ldap.ResultCode; import org.forgerock.opendj.ldap.controls.AuthorizationIdentityRequestControl; import org.forgerock.opendj.ldap.controls.AuthorizationIdentityResponseControl; import org.forgerock.opendj.ldap.responses.BindResult; @@ -42,6 +41,7 @@ import org.forgerock.util.AsyncFunction; import org.forgerock.util.Function; import org.forgerock.util.promise.Promise; +import org.forgerock.util.promise.Promises; /** Bind using a computed DN from a template and the current request/context. */ final class SaslPlainStrategy implements AuthenticationStrategy { @@ -56,7 +56,8 @@ final class SaslPlainStrategy implements AuthenticationStrategy { * Factory used to get {@link Connection} receiving the sasl-bind requests * @param authcIdTemplate * Authentication identity template containing a single %s which will be replaced by the authenticating - * user's name. (i.e: (u:%s) + * user's name. (i.e: (u:%s). After a "dn:" prefix the template is a bind DN template: just %s takes the + * user name as the DN, otherwise the user name is escaped as an attribute value. * @param schema * Schema used to perform DN validation. * @throws NullPointerException @@ -68,14 +69,12 @@ public SaslPlainStrategy(final ConnectionFactory connectionFactory, final Schema checkNotNull(schema, "schema cannot be null"); checkNotNull(authcIdTemplate, "authcIdTemplate cannot be null"); if (authcIdTemplate.startsWith("dn:")) { + // As AuthzIdTemplate does, ignore spaces after the key: "dn: {username}" is "dn:{username}". + final String dnTemplate = authcIdTemplate.substring("dn:".length()).trim(); formatter = new Function() { @Override public String apply(String value) throws LdapException { - try { - return DN.format(authcIdTemplate, schema, value).toString(); - } catch (LocalizedIllegalArgumentException e) { - throw LdapException.newLdapException(ResultCode.INVALID_DN_SYNTAX, e.getMessageObject(), e); - } + return "dn:" + formatBindDn(dnTemplate, schema, value); } }; } else { @@ -91,6 +90,12 @@ public String apply(String value) throws LdapException { @Override public Promise authenticate(final String username, final String password, final Context parentContext) { + final String authcId; + try { + authcId = formatter.apply(username); + } catch (final LdapException e) { + return Promises.newExceptionPromise(e); + } final AtomicReference connectionHolder = new AtomicReference(); return connectionFactory .getConnectionAsync() @@ -98,15 +103,14 @@ public Promise authenticate(final String usernam @Override public Promise apply(Connection connection) throws LdapException { connectionHolder.set(connection); - return doSaslPlainBind(connection, parentContext, username, password); + return doSaslPlainBind(connection, parentContext, username, authcId, password); } }).thenFinally(close(connectionHolder)); } private Promise doSaslPlainBind(final Connection connection, final Context parentContext, final String authzId, - final String password) throws LdapException { - final String authcId = formatter.apply(authzId); + final String authcId, final String password) { return connection .bindAsync(newPlainSASLBindRequest(authcId, password.toCharArray()) .addControl(AuthorizationIdentityRequestControl.newControl(true))) diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategy.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategy.java index dc4dcd3c5d..ab5a5d4ec4 100644 --- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategy.java +++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategy.java @@ -12,11 +12,13 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.rest2ldap.authz; import static org.forgerock.opendj.ldap.requests.Requests.newSimpleBindRequest; import static org.forgerock.opendj.rest2ldap.authz.Utils.close; +import static org.forgerock.opendj.rest2ldap.authz.Utils.formatBindDn; import static org.forgerock.services.context.SecurityContext.AUTHZID_DN; import static org.forgerock.services.context.SecurityContext.AUTHZID_ID; import static org.forgerock.util.Reject.checkNotNull; @@ -36,6 +38,7 @@ import org.forgerock.util.AsyncFunction; import org.forgerock.util.Function; import org.forgerock.util.promise.Promise; +import org.forgerock.util.promise.Promises; /** Bind using a computed DN from a template and the current request/context. */ final class SimpleBindStrategy implements AuthenticationStrategy { @@ -53,7 +56,7 @@ final class SimpleBindStrategy implements AuthenticationStrategy { * Schema used to validate DN * @param bindDNTemplate * The template which will be replaced by the authenticating user (i.e: - * uid=%s,ou=People,dc=example,dc=com) + * uid=%s,ou=People,dc=example,dc=com). A template which is just %s takes the user name as the bind DN. * @throws NullPointerException * If a parameter is null */ @@ -66,11 +69,16 @@ public SimpleBindStrategy(ConnectionFactory connectionFactory, String bindDNTemp @Override public Promise authenticate(final String username, final String password, final Context parentContext) { + final DN bindDN; + try { + bindDN = formatBindDn(bindDNTemplate, schema, username); + } catch (final LdapException e) { + return Promises.newExceptionPromise(e); + } final AtomicReference connectionHolder = new AtomicReference<>(); return connectionFactory .getConnectionAsync() - .thenAsync(doSimpleBind(connectionHolder, parentContext, username, - DN.format(bindDNTemplate, schema, username), password)) + .thenAsync(doSimpleBind(connectionHolder, parentContext, username, bindDN, password)) .thenFinally(close(connectionHolder)); } diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java index 92770cfd2f..d57b54aaf0 100644 --- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java +++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.rest2ldap.authz; @@ -25,8 +26,12 @@ import org.forgerock.http.protocol.Response; import org.forgerock.http.protocol.Status; import org.forgerock.i18n.LocalizableMessage; +import org.forgerock.i18n.LocalizedIllegalArgumentException; import org.forgerock.json.resource.ResourceException; +import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.LdapException; +import org.forgerock.opendj.ldap.ResultCode; +import org.forgerock.opendj.ldap.schema.Schema; import org.forgerock.util.AsyncFunction; import org.forgerock.util.promise.NeverThrowsException; import org.forgerock.util.promise.Promise; @@ -55,6 +60,31 @@ static AccessTokenException newAccessTokenException(final LocalizableMessage mes return new AccessTokenException(message.toString(), cause); } + /** + * Returns the DN which a bind DN template designates for a user name. + * + * @param dnTemplate + * The template, with {@code %s} in place of the user name. A template which is just {@code %s} takes the + * user name as the DN; otherwise the user name is escaped as an attribute value. + * @param schema + * The schema used to parse the DN. + * @param username + * The user name. + * @return The DN. + * @throws LdapException + * With {@link ResultCode#INVALID_CREDENTIALS} if the result is not a valid DN. + */ + static DN formatBindDn(final String dnTemplate, final Schema schema, final String username) + throws LdapException { + try { + return "%s".equals(dnTemplate) + ? DN.valueOf(username, schema) + : DN.format(dnTemplate, schema, username); + } catch (final LocalizedIllegalArgumentException e) { + throw LdapException.newLdapException(ResultCode.INVALID_CREDENTIALS, e.getMessageObject(), e); + } + } + static Runnable close(final AtomicReference holder) { return new Runnable() { @Override diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicJsonConfigurationTestCase.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicJsonConfigurationTestCase.java new file mode 100644 index 0000000000..6573fb4ebf --- /dev/null +++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicJsonConfigurationTestCase.java @@ -0,0 +1,135 @@ +/* + * 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 2026 3A Systems, LLC. + */ +package org.forgerock.opendj.rest2ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.forgerock.opendj.ldap.spi.LdapPromises.newSuccessfulLdapPromise; +import static org.forgerock.opendj.rest2ldap.TestUtils.parseJson; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.forgerock.http.protocol.Headers; +import org.forgerock.opendj.ldap.Connection; +import org.forgerock.opendj.ldap.ConnectionFactory; +import org.forgerock.opendj.ldap.LdapException; +import org.forgerock.opendj.ldap.ResultCode; +import org.forgerock.opendj.ldap.requests.BindRequest; +import org.forgerock.opendj.ldap.requests.PlainSASLBindRequest; +import org.forgerock.opendj.ldap.requests.SearchRequest; +import org.forgerock.opendj.ldap.requests.SimpleBindRequest; +import org.forgerock.opendj.ldap.responses.Responses; +import org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategy; +import org.forgerock.services.context.RootContext; +import org.forgerock.testng.ForgeRockTestCase; +import org.forgerock.util.Function; +import org.forgerock.util.Pair; +import org.forgerock.util.promise.NeverThrowsException; +import org.forgerock.util.promise.Promises; +import org.mockito.ArgumentCaptor; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +/** Tests how the "basic" authorization configuration turns the HTTP Basic user name into an LDAP name. */ +@Test +@SuppressWarnings("javadoc") +public final class BasicJsonConfigurationTestCase extends ForgeRockTestCase { + private static final String USER_DN = "uid=bjensen,ou=People,dc=example,dc=com"; + + private Rest2LdapHttpApplication fakeApp; + private Connection connection; + + @BeforeMethod + public void setUp() throws Exception { + connection = mock(Connection.class); + when(connection.bindAsync(any(BindRequest.class))) + .thenReturn(newSuccessfulLdapPromise(Responses.newBindResult(ResultCode.SUCCESS))); + when(connection.searchSingleEntryAsync(any(SearchRequest.class))) + .thenReturn(newSuccessfulLdapPromise(Responses.newSearchResultEntry(USER_DN))); + final ConnectionFactory factory = mock(ConnectionFactory.class); + when(factory.getConnectionAsync()) + .thenReturn(Promises. newResultPromise(connection)); + + fakeApp = spy(Rest2LdapHttpApplication.class); + doReturn(factory).when(fakeApp).getConnectionFactory(anyString()); + } + + @Test + public void testSimpleDefaultTemplateTakesTheUserNameAsTheBindDn() throws Exception { + authenticate("{'bind': 'simple', 'simple': {}}", USER_DN); + + assertThat(bindRequest(SimpleBindRequest.class).getName()).isEqualTo(USER_DN); + } + + @Test + public void testSimpleTemplateKeepsPercentLiterally() throws Exception { + authenticate("{'bind': 'simple', 'simple': {'bindDnTemplate': 'uid={username},o=100%,dc=example,dc=com'}}", + "bjensen"); + + assertThat(bindRequest(SimpleBindRequest.class).getName()).isEqualTo("uid=bjensen,o=100%,dc=example,dc=com"); + } + + @Test + public void testSaslPlainDefaultTemplateIsTheUserId() throws Exception { + authenticate("{'bind': 'sasl-plain', 'sasl-plain': {}}", "bjensen"); + + assertThat(bindRequest(PlainSASLBindRequest.class).getAuthenticationID()).isEqualTo("u:bjensen"); + } + + @Test + public void testSaslPlainDnTemplateTakesTheUserNameAsTheBindDn() throws Exception { + authenticate("{'bind': 'sasl-plain', 'sasl-plain': {'authzIdTemplate': 'dn:{username}'}}", USER_DN); + + assertThat(bindRequest(PlainSASLBindRequest.class).getAuthenticationID()).isEqualTo("dn:" + USER_DN); + } + + @Test + public void testSaslPlainTemplateKeepsPercentLiterally() throws Exception { + authenticate("{'bind': 'sasl-plain', 'sasl-plain': {'authzIdTemplate': 'u:{username}%example'}}", "bjensen"); + + assertThat(bindRequest(PlainSASLBindRequest.class).getAuthenticationID()).isEqualTo("u:bjensen%example"); + } + + @Test + public void testSearchFilterTemplateKeepsPercentLiterally() throws Exception { + authenticate("{'bind': 'search', 'search': {'baseDn': 'dc=example,dc=com', 'scope': 'sub'," + + " 'filterTemplate': '(&(uid={username})(description=100%))'}}", "bjensen"); + + final ArgumentCaptor request = ArgumentCaptor.forClass(SearchRequest.class); + verify(connection).searchSingleEntryAsync(request.capture()); + assertThat(request.getValue().getFilter().toString()).isEqualTo("(&(uid=bjensen)(description=100%))"); + assertThat(bindRequest(SimpleBindRequest.class).getName()).isEqualTo(USER_DN); + } + + @SuppressWarnings("unchecked") + private void authenticate(final String basicConfig, final String username) throws Exception { + final ArgumentCaptor strategy = ArgumentCaptor.forClass(AuthenticationStrategy.class); + doReturn(null).when(fakeApp).newBasicAuthenticationFilter(strategy.capture(), + (Function, NeverThrowsException>) any(Function.class)); + fakeApp.buildBasicFilter(parseJson(basicConfig)); + strategy.getValue().authenticate(username, "secret", new RootContext()).getOrThrow(); + } + + private T bindRequest(final Class type) { + final ArgumentCaptor request = ArgumentCaptor.forClass(BindRequest.class); + verify(connection).bindAsync(request.capture()); + return type.cast(request.getValue()); + } +} diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/DnTemplateTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/DnTemplateTest.java index cc847dc044..275257af4b 100644 --- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/DnTemplateTest.java +++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/DnTemplateTest.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.rest2ldap; @@ -51,6 +52,12 @@ Object[][] templateData() { { "dc={subdomain}", "dc=www", "dc=www,dc=example,dc=com" }, { "dc={subdomain},..", "dc=www,dc=com", "dc=www,dc=com" }, { "dc={subdomain},dc={tenant},..", "dc=www,dc=acme,dc=com", "dc=www,dc=acme,dc=com" }, + // A '%' in the fixed part is not a format specifier. + { "dc={subdomain},o=100%,dc=x", "dc=www,o=100%,dc=x", "dc=www,o=100%,dc=x,dc=example,dc=com" }, + // An escaped comma does not start a relative "..", an escaped backslash before the comma does not escape it. + { "cn=a\\,..", "cn=a\\,..", "cn=a\\,..,dc=example,dc=com" }, + { "cn=a\\\\,..", "cn=a\\\\,dc=com", "cn=a\\\\,dc=com" }, + { "cn={subdomain}\\,..", "cn=www\\,..", "cn=www\\,..,dc=example,dc=com" }, }; // @formatter:on } diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java index 0c99ca7b7e..bcd6156395 100644 --- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java +++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2013-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.rest2ldap.authz; @@ -50,6 +51,17 @@ public Object[][] templateData() { "uid=test.user,ou=test\\+cn\\=quoting,dc=example,dc=com", map("uid", "test.user", "realm", "test+cn=quoting") }, + { + // A template which is just one placeholder takes the principal as the DN. + "dn:{dn}", + "uid=test.user,ou=People,dc=example,dc=com", + map("dn", "uid=test.user,ou=People,dc=example,dc=com") + }, + { + "dn: {dn}", + "uid=test.user,ou=People,dc=example,dc=com", + map("dn", "uid=test.user,ou=People,dc=example,dc=com") + }, { "u:{uid}@{realm}.example.com", "test.user@acme.example.com", @@ -66,6 +78,17 @@ public Object[][] templateData() { "u:{uid}.{numericid}.{testboolean}@{realm}.example.com", "test.42.true@test.example.com", map("uid", "test", "numericid", 42, "testboolean", true, "realm", "test") + }, + { + // A '%' in the fixed part is not a format specifier. + "dn:uid={uid},o=100%,dc=example,dc=com", + "uid=test.user,o=100%,dc=example,dc=com", + map("uid", "test.user") + }, + { + "u:{uid}%{realm}", + "test.user%acme", + map("uid", "test.user", "realm", "acme") } }; // @formatter:on diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractorsTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractorsTest.java index 54e0209984..0e09e73231 100644 --- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractorsTest.java +++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CredentialExtractorsTest.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.rest2ldap.authz; @@ -24,6 +25,7 @@ import org.forgerock.testng.ForgeRockTestCase; import org.forgerock.util.Pair; import org.forgerock.util.encode.Base64; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @Test @@ -36,11 +38,48 @@ public void testBasicCanExtractValidCredentials() { assertThat(httpBasicExtractor().apply(headers)).isEqualTo(Pair.of("foo", "bar")); } - @Test - public void testBasicReturnNullOnInvalidCredentials() { + @DataProvider + public Object[][] validBasicCredentials() { + // @formatter:off + return new Object[][] { + // RFC 7617 section 2 forbids a colon only in the user-id: the password is everything after the first one. + { "bjensen:se:cret", "bjensen", "se:cret" }, + { "bjensen::", "bjensen", ":" }, + { "uid=bjensen,ou=People,dc=example,dc=com:secret", "uid=bjensen,ou=People,dc=example,dc=com", "secret" }, + // An empty password is a well-formed credential; the filter, not the parser, refuses it. + { "bjensen:", "bjensen", "" }, + }; + // @formatter:on + } + + @Test(dataProvider = "validBasicCredentials") + public void testBasicSplitsAtTheFirstColon(final String credentials, final String username, + final String password) { final Headers headers = new Headers(); - headers.put(HTTP_BASIC_AUTH_HEADER, "*invalid*"); - assertThat(httpBasicExtractor().apply(new Headers())).isNull(); + headers.put(HTTP_BASIC_AUTH_HEADER, "Basic " + Base64.encode(credentials.getBytes())); + assertThat(httpBasicExtractor().apply(headers)).isEqualTo(Pair.of(username, password)); + } + + @DataProvider + public Object[][] invalidBasicHeaders() { + // @formatter:off + return new Object[][] { + { "*invalid*" }, + { "Basic " + Base64.encode("bjensen".getBytes()) }, + { "Basic !!!" }, + // Base64 which is not a multiple of 4 characters long does not decode at all. + { "Basic abc" }, + { "Basic " + Base64.encode("foo:bar".getBytes()).replace("=", "") }, + { "Basic" }, + }; + // @formatter:on + } + + @Test(dataProvider = "invalidBasicHeaders") + public void testBasicReturnNullOnInvalidCredentials(final String header) { + final Headers headers = new Headers(); + headers.put(HTTP_BASIC_AUTH_HEADER, header); + assertThat(httpBasicExtractor().apply(headers)).isNull(); } @Test diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilterTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilterTest.java index ef44680c69..a864b2c2d6 100644 --- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilterTest.java +++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/HttpBasicAuthenticationFilterTest.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.rest2ldap.authz; @@ -21,6 +22,7 @@ import static org.mockito.Matchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; import java.io.IOException; @@ -78,6 +80,25 @@ public void testRespondUnauthorizedIfCredentialWrong() verifyUnauthorizedOutputMessage(response); } + /** + * An LDAP simple bind with a DN and an empty password is an unauthenticated bind (RFC 4513 section 5.1.2): a + * server which accepts it would let the request run as the named user without any password. + */ + @SuppressWarnings("unchecked") + @Test + public void testRespondUnauthorizedIfPasswordEmpty() + throws InterruptedException, ExecutionException, IOException { + final Function, NeverThrowsException> credentials = mock(Function.class); + when(credentials.apply(any(Headers.class))).thenReturn(Pair.of("user", "")); + final AuthenticationStrategy authStrategy = mock(AuthenticationStrategy.class); + + final Response response = new HttpBasicAuthenticationFilter(authStrategy, credentials) + .filter(mock(Context.class), new Request(), mock(Handler.class)).get(); + + verifyUnauthorizedOutputMessage(response); + verifyZeroInteractions(authStrategy); + } + @SuppressWarnings("unchecked") @Test public void testContinueProcessOnSuccessfullAuthentication() { diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategyTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategyTest.java new file mode 100644 index 0000000000..b19af59da8 --- /dev/null +++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SaslPlainStrategyTest.java @@ -0,0 +1,108 @@ +/* + * 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 2026 3A Systems, LLC. + */ +package org.forgerock.opendj.rest2ldap.authz; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.forgerock.opendj.ldap.spi.LdapPromises.newSuccessfulLdapPromise; +import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.newSaslPlainStrategy; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.forgerock.opendj.ldap.Connection; +import org.forgerock.opendj.ldap.ConnectionFactory; +import org.forgerock.opendj.ldap.LdapException; +import org.forgerock.opendj.ldap.ResultCode; +import org.forgerock.opendj.ldap.requests.BindRequest; +import org.forgerock.opendj.ldap.requests.PlainSASLBindRequest; +import org.forgerock.opendj.ldap.responses.Responses; +import org.forgerock.opendj.ldap.schema.Schema; +import org.forgerock.services.context.RootContext; +import org.forgerock.services.context.SecurityContext; +import org.forgerock.testng.ForgeRockTestCase; +import org.forgerock.util.promise.Promise; +import org.forgerock.util.promise.Promises; +import org.mockito.ArgumentCaptor; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +@Test +@SuppressWarnings("javadoc") +public final class SaslPlainStrategyTest extends ForgeRockTestCase { + private static final String USER_DN = "uid=bjensen,ou=People,dc=example,dc=com"; + + private ConnectionFactory factory; + private Connection connection; + + @BeforeMethod + public void setUp() throws Exception { + connection = mock(Connection.class); + when(connection.bindAsync(any(BindRequest.class))) + .thenReturn(newSuccessfulLdapPromise(Responses.newBindResult(ResultCode.SUCCESS))); + factory = mock(ConnectionFactory.class); + when(factory.getConnectionAsync()) + .thenReturn(Promises. newResultPromise(connection)); + } + + @DataProvider + public Object[][] authcIdTemplates() { + // @formatter:off + // [template, "{username}" already replaced with "%s"] [user name] [expected SASL authentication ID] + return new Object[][] { + // The user name is the bind DN. + { "dn:%s", USER_DN, "dn:" + USER_DN }, + // Spaces after "dn:" are not part of the template, as for the OAuth2 authzIdTemplate. + { "dn: %s", USER_DN, "dn:" + USER_DN }, + { "dn:uid=%s,ou=People,dc=example,dc=com", "bjensen", "dn:" + USER_DN }, + // A user name inside a DN template stays one attribute value. + { "dn:uid=%s,ou=People,dc=example,dc=com", "a,ou=x", "dn:uid=a\\,ou\\=x,ou=People,dc=example,dc=com" }, + { "u:%s", "bjensen", "u:bjensen" }, + }; + // @formatter:on + } + + @Test(dataProvider = "authcIdTemplates") + public void testAuthenticationIdSentToTheServer(final String template, final String username, + final String expectedAuthcId) throws Exception { + final SecurityContext context = newSaslPlainStrategy(factory, Schema.getDefaultSchema(), template) + .authenticate(username, "secret", new RootContext()).getOrThrow(); + + final ArgumentCaptor request = ArgumentCaptor.forClass(BindRequest.class); + verify(connection).bindAsync(request.capture()); + assertThat(((PlainSASLBindRequest) request.getValue()).getAuthenticationID()).isEqualTo(expectedAuthcId); + assertThat(context.getAuthenticationId()).isEqualTo(expectedAuthcId); + } + + /** A user name which is not a DN fails the returned promise and takes no connection. */ + @Test + public void testUserNameWhichIsNotADnFailsThePromise() throws Exception { + final Promise promise = + newSaslPlainStrategy(factory, Schema.getDefaultSchema(), "dn:%s") + .authenticate("bjensen", "secret", new RootContext()); + try { + promise.getOrThrow(); + fail("The authentication should have failed"); + } catch (final LdapException e) { + assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.INVALID_CREDENTIALS); + } + verify(factory, never()).getConnectionAsync(); + verify(connection, never()).bindAsync(any(BindRequest.class)); + } +} diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategyTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategyTest.java new file mode 100644 index 0000000000..6fbecdeb48 --- /dev/null +++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/SimpleBindStrategyTest.java @@ -0,0 +1,124 @@ +/* + * 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 2026 3A Systems, LLC. + */ +package org.forgerock.opendj.rest2ldap.authz; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.forgerock.opendj.ldap.Connections.newInternalConnectionFactory; +import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.newSimpleBindStrategy; +import static org.forgerock.services.context.SecurityContext.AUTHZID_DN; +import static org.forgerock.services.context.SecurityContext.AUTHZID_ID; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import org.forgerock.opendj.ldap.ConnectionFactory; +import org.forgerock.opendj.ldap.LdapException; +import org.forgerock.opendj.ldap.MemoryBackend; +import org.forgerock.opendj.ldap.ResultCode; +import org.forgerock.opendj.ldap.schema.Schema; +import org.forgerock.opendj.ldif.LDIFEntryReader; +import org.forgerock.services.context.RootContext; +import org.forgerock.services.context.SecurityContext; +import org.forgerock.testng.ForgeRockTestCase; +import org.forgerock.util.promise.Promise; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +@Test +@SuppressWarnings("javadoc") +public final class SimpleBindStrategyTest extends ForgeRockTestCase { + private static final String USER_DN = "uid=bjensen,ou=People,dc=example,dc=com"; + + private ConnectionFactory factory; + + @BeforeMethod + public void setUp() throws Exception { + factory = newInternalConnectionFactory(new MemoryBackend(new LDIFEntryReader( + "dn: dc=example,dc=com", + "objectClass: domain", + "dc: example", + "", + "dn: ou=People,dc=example,dc=com", + "objectClass: organizationalUnit", + "ou: People", + "", + "dn: " + USER_DN, + "objectClass: inetOrgPerson", + "uid: bjensen", + "cn: Barbara Jensen", + "sn: Jensen", + "userPassword: secret"))); + } + + /** The documented default template, {@code {username}}, takes the user name as the bind DN. */ + @Test + public void testDefaultTemplateTakesTheUserNameAsTheBindDn() throws Exception { + final SecurityContext context = newSimpleBindStrategy(factory, "%s", Schema.getDefaultSchema()) + .authenticate(USER_DN, "secret", new RootContext()).getOrThrow(); + + assertThat(context.getAuthorization().get(AUTHZID_DN)).isEqualTo(USER_DN); + assertThat(context.getAuthorization().get(AUTHZID_ID)).isEqualTo(USER_DN); + } + + @Test + public void testTemplateTakesTheUserNameAsAnAttributeValue() throws Exception { + final SecurityContext context = + newSimpleBindStrategy(factory, "uid=%s,ou=People,dc=example,dc=com", Schema.getDefaultSchema()) + .authenticate("bjensen", "secret", new RootContext()).getOrThrow(); + + assertThat(context.getAuthorization().get(AUTHZID_DN)).isEqualTo(USER_DN); + } + + /** A user name inside a template stays one attribute value: it cannot add RDNs of its own. */ + @Test + public void testTemplateEscapesTheUserName() throws Exception { + assertFailsWith(newSimpleBindStrategy(factory, "uid=%s,dc=example,dc=com", Schema.getDefaultSchema()) + .authenticate("bjensen,ou=People", "secret", new RootContext()), ResultCode.INVALID_CREDENTIALS); + } + + /** A user name which is not a DN fails the returned promise instead of being thrown by authenticate(). */ + @Test + public void testUserNameWhichIsNotADnFailsThePromise() throws Exception { + assertFailsWith(newSimpleBindStrategy(factory, "%s", Schema.getDefaultSchema()) + .authenticate("bjensen", "secret", new RootContext()), ResultCode.INVALID_CREDENTIALS); + } + + /** A user name which is not a DN is refused before a connection is taken, so none is left open. */ + @Test + public void testUserNameWhichIsNotADnTakesNoConnection() throws Exception { + final ConnectionFactory connections = mock(ConnectionFactory.class); + assertFailsWith(newSimpleBindStrategy(connections, "%s", Schema.getDefaultSchema()) + .authenticate("bjensen", "secret", new RootContext()), ResultCode.INVALID_CREDENTIALS); + verify(connections, never()).getConnectionAsync(); + } + + @Test + public void testWrongPasswordFails() throws Exception { + assertFailsWith(newSimpleBindStrategy(factory, "%s", Schema.getDefaultSchema()) + .authenticate(USER_DN, "wrong", new RootContext()), ResultCode.INVALID_CREDENTIALS); + } + + private static void assertFailsWith(final Promise promise, + final ResultCode expected) throws Exception { + try { + promise.getOrThrow(); + fail("The authentication should have failed"); + } catch (final LdapException e) { + assertThat(e.getResult().getResultCode()).isEqualTo(expected); + } + } +}