/*
* CDDL HEADER START
*
* The contents of this file are subject to the terms of the
* Common Development and Distribution License, Version 1.0 only
* (the "License"). You may not use this file except in compliance
* with the License.
*
* You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
* or http://forgerock.org/license/CDDLv1.0.html.
* See the License for the specific language governing permissions
* and limitations under the License.
*
* When distributing Covered Code, include this CDDL HEADER in each
* file and include the License file at legal-notices/CDDLv1_0.txt.
* If applicable, add the following below this CDDL HEADER, with the
* fields enclosed by brackets "[]" replaced with your own identifying
* information:
* Portions Copyright [yyyy] [name of copyright owner]
*
* CDDL HEADER END
*
*
* Copyright 2010 Sun Microsystems, Inc.
* Portions copyright 2012 ForgeRock AS.
*/
package org.forgerock.opendj.ldap;
import static org.forgerock.opendj.ldap.CoreMessages.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.StringTokenizer;
import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.i18n.LocalizedIllegalArgumentException;
import org.forgerock.opendj.ldap.requests.Requests;
import org.forgerock.opendj.ldap.requests.SearchRequest;
import org.forgerock.opendj.ldap.schema.Schema;
import com.forgerock.opendj.util.StaticUtils;
import com.forgerock.opendj.util.Validator;
/**
* An LDAP URL as defined in RFC 4516. In addition, the secure ldap (ldaps://)
* is also supported. LDAP URLs have the following format:
*
*
* "ldap[s]://" [ hostName [":" portNumber] ]
* "/" distinguishedName
* ["?" attributeList
* ["?" scope "?" filterString ] ]
*
*
* Where:
*
* - all text within double-quotes are literal
*
hostName and portNumber identify
* the location of the LDAP server.
* distinguishedName is the name of an entry within the
* given directory (the entry represents the starting point of the search).
* attributeList contains a list of attributes to
* retrieve (if null, fetch all attributes). This is a comma-delimited list of
* attribute names.
* scope is one of the following:
*
* base indicates that this is a search only for the specified
* entry
* one indicates that this is a search for matching entries one
* level under the specified entry (and not including the entry itself)
* sub indicates that this is a search for matching entries at
* all levels under the specified entry (including the entry itself)
* subordinates indicates that this is a search for matching
* entries all levels under the specified entry (excluding the entry itself)
*
* If not specified, scope is base by default.
* filterString is a human-readable representation of
* the search criteria. If no filter is provided, then a default of "
* {@code (objectClass=*)}" should be assumed.
*
* The same encoding rules for other URLs (e.g. HTTP) apply for LDAP URLs.
* Specifically, any "illegal" characters are escaped with
* %HH, where HH represent the two hex
* digits which correspond to the ASCII value of the character. This encoding is
* only legal (or necessary) on the DN and filter portions of the URL.
*
* Note that this class does not implement extensions.
*
* @see RFC 4516 - Lightweight
* Directory Access Protocol (LDAP): Uniform Resource Locator
*/
public final class LDAPUrl {
/**
* The scheme corresponding to an LDAP URL. RFC 4516 mandates only ldap
* scheme but we support "ldaps" too.
*/
private final boolean isSecured;
/**
* The host name corresponding to an LDAP URL.
*/
private final String host;
/**
* The port number corresponding to an LDAP URL.
*/
private final int port;
/**
* The distinguished name corresponding to an LDAP URL.
*/
private final DN name;
/**
* The search scope corresponding to an LDAP URL.
*/
private final SearchScope scope;
/**
* The search filter corresponding to an LDAP URL.
*/
private final Filter filter;
/**
* The attributes that need to be searched.
*/
private final List attributes;
/**
* The String value of LDAP URL.
*/
private final String urlString;
/**
* Normalized ldap URL.
*/
private String normalizedURL;
/**
* The default scheme to be used with LDAP URL.
*/
private static final String DEFAULT_URL_SCHEME = "ldap";
/**
* The SSL-based scheme allowed to be used with LDAP URL.
*/
private static final String SSL_URL_SCHEME = "ldaps";
/**
* The default host.
*/
private static final String DEFAULT_HOST = "localhost";
/**
* The default non-SSL port.
*/
private static final int DEFAULT_PORT = 389;
/**
* The default SSL port.
*/
private static final int DEFAULT_SSL_PORT = 636;
/**
* The default filter.
*/
private static final Filter DEFAULT_FILTER = Filter.objectClassPresent();
/**
* The default search scope.
*/
private static final SearchScope DEFAULT_SCOPE = SearchScope.BASE_OBJECT;
/**
* The default distinguished name.
*/
private static final DN DEFAULT_DN = DN.rootDN();
/**
* The % encoding character.
*/
private static final char PERCENT_ENCODING_CHAR = '%';
/**
* The ? character.
*/
private static final char QUESTION_CHAR = '?';
/**
* The slash (/) character.
*/
private static final char SLASH_CHAR = '/';
/**
* The comma (,) character.
*/
private static final char COMMA_CHAR = ',';
/**
* The colon (:) character.
*/
private static final char COLON_CHAR = ':';
/**
* Set containing characters that do not need to be encoded.
*/
private static final Set VALID_CHARS = new HashSet();
static {
// Refer to RFC 3986 for more details.
final char[] delims = {
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', '.', '-', '_', '~'
};
for (final char c : delims) {
VALID_CHARS.add(c);
}
for (char c = 'a'; c <= 'z'; c++) {
VALID_CHARS.add(c);
}
for (char c = 'A'; c <= 'Z'; c++) {
VALID_CHARS.add(c);
}
for (char c = '0'; c <= '9'; c++) {
VALID_CHARS.add(c);
}
}
/**
* Parses the provided LDAP string representation of an LDAP URL using the
* default schema.
*
* @param url
* The LDAP string representation of an LDAP URL.
* @return The parsed LDAP URL.
* @throws LocalizedIllegalArgumentException
* If {@code url} is not a valid LDAP string representation of
* an LDAP URL.
* @throws NullPointerException
* If {@code url} was {@code null}.
*/
public static LDAPUrl valueOf(final String url) {
return valueOf(url, Schema.getDefaultSchema());
}
/**
* Parses the provided LDAP string representation of an LDAP URL using the
* provided schema.
*
* @param url
* The LDAP string representation of an LDAP URL.
* @param schema
* The schema to use when parsing the LDAP URL.
* @return The parsed LDAP URL.
* @throws LocalizedIllegalArgumentException
* If {@code url} is not a valid LDAP string representation of
* an LDAP URL.
* @throws NullPointerException
* If {@code url} or {@code schema} was {@code null}.
*/
public static LDAPUrl valueOf(final String url, final Schema schema) {
Validator.ensureNotNull(url, schema);
return new LDAPUrl(url, schema);
}
private static int decodeHex(final String url, final int index, final char hexChar) {
if (hexChar >= '0' && hexChar <= '9') {
return hexChar - '0';
} else if (hexChar >= 'A' && hexChar <= 'F') {
return hexChar - 'A' + 10;
} else if (hexChar >= 'a' && hexChar <= 'f') {
return hexChar - 'a' + 10;
}
final LocalizableMessage msg = ERR_LDAPURL_INVALID_HEX_BYTE.get(url, index);
throw new LocalizedIllegalArgumentException(msg);
}
private static void percentDecoder(final String urlString, final int index, final String s,
final StringBuilder decoded) {
Validator.ensureNotNull(s, decoded);
decoded.append(s);
int srcPos = 0, dstPos = 0;
while (srcPos < decoded.length()) {
if (decoded.charAt(srcPos) != '%') {
if (srcPos != dstPos) {
decoded.setCharAt(dstPos, decoded.charAt(srcPos));
}
srcPos++;
dstPos++;
continue;
}
decoded.setCharAt(dstPos, (char) ((decodeHex(urlString, index + srcPos + 1, decoded
.charAt(srcPos + 1)) << 4) | (decodeHex(urlString, index + srcPos + 2, decoded
.charAt(srcPos + 2)))));
dstPos++;
srcPos += 3;
}
decoded.setLength(dstPos);
}
/**
* This method performs the percent-encoding as defined in section 2.1 of
* RFC 3986.
*
* @param urlElement
* The element of the URL that needs to be percent encoded.
* @param encodedBuffer
* The buffer that contains the final percent encoded value.
*/
private static void percentEncoder(final String urlElement, final StringBuilder encodedBuffer) {
Validator.ensureNotNull(urlElement);
for (int count = 0; count < urlElement.length(); count++) {
final char c = urlElement.charAt(count);
if (VALID_CHARS.contains(c)) {
encodedBuffer.append(c);
} else {
encodedBuffer.append(PERCENT_ENCODING_CHAR);
encodedBuffer.append(Integer.toHexString(c));
}
}
}
/**
* Creates a new LDAP URL referring to a single entry on the specified
* server. The LDAP URL with have base object scope and the filter
* {@code (objectClass=*)}.
*
* @param isSecured
* {@code true} if this LDAP URL should use LDAPS or
* {@code false} if it should use LDAP.
* @param host
* The name or IP address in dotted format of the LDAP server.
* For example, {@code ldap.server1.com} or
* {@code 192.202.185.90}. Use {@code null} for the local host.
* @param port
* The port number of the LDAP server, or {@code null} to use the
* default port (389 for LDAP and 636 for LDAPS).
* @param name
* The distinguished name of the base entry relative to which the
* search is to be performed, or {@code null} to specify the root
* DSE.
* @throws LocalizedIllegalArgumentException
* If {@code port} was less than 1 or greater than 65535.
*/
public LDAPUrl(final boolean isSecured, final String host, final Integer port, final DN name) {
this(isSecured, host, port, name, DEFAULT_SCOPE, DEFAULT_FILTER);
}
/**
* Creates a new LDAP URL including the full set of parameters for a search
* request.
*
* @param isSecured
* {@code true} if this LDAP URL should use LDAPS or
* {@code false} if it should use LDAP.
* @param host
* The name or IP address in dotted format of the LDAP server.
* For example, {@code ldap.server1.com} or
* {@code 192.202.185.90}. Use {@code null} for the local host.
* @param port
* The port number of the LDAP server, or {@code null} to use the
* default port (389 for LDAP and 636 for LDAPS).
* @param name
* The distinguished name of the base entry relative to which the
* search is to be performed, or {@code null} to specify the root
* DSE.
* @param scope
* The search scope, or {@code null} to specify base scope.
* @param filter
* The search filter, or {@code null} to specify the filter
* {@code (objectClass=*)}.
* @param attributes
* The list of attributes to be included in the search results.
* @throws LocalizedIllegalArgumentException
* If {@code port} was less than 1 or greater than 65535.
*/
public LDAPUrl(final boolean isSecured, final String host, final Integer port, final DN name,
final SearchScope scope, final Filter filter, final String... attributes) {
// The buffer storing the encoded url.
final StringBuilder urlBuffer = new StringBuilder();
// build the scheme.
this.isSecured = isSecured;
if (this.isSecured) {
urlBuffer.append(SSL_URL_SCHEME);
} else {
urlBuffer.append(DEFAULT_URL_SCHEME);
}
urlBuffer.append("://");
if (host == null) {
this.host = DEFAULT_HOST;
} else {
this.host = host;
urlBuffer.append(this.host);
}
int listenPort = DEFAULT_PORT;
if (port == null) {
listenPort = isSecured ? DEFAULT_SSL_PORT : DEFAULT_PORT;
} else {
listenPort = port.intValue();
if (listenPort < 1 || listenPort > 65535) {
final LocalizableMessage msg = ERR_LDAPURL_BAD_PORT.get(listenPort);
throw new LocalizedIllegalArgumentException(msg);
}
urlBuffer.append(COLON_CHAR);
urlBuffer.append(listenPort);
}
this.port = listenPort;
// We need a slash irrespective of dn is defined or not.
urlBuffer.append(SLASH_CHAR);
if (name != null) {
this.name = name;
percentEncoder(name.toString(), urlBuffer);
} else {
this.name = DEFAULT_DN;
}
// Add attributes.
urlBuffer.append(QUESTION_CHAR);
switch (attributes.length) {
case 0:
this.attributes = Collections.emptyList();
break;
case 1:
this.attributes = Collections.singletonList(attributes[0]);
urlBuffer.append(attributes[0]);
break;
default:
this.attributes = Collections.unmodifiableList(Arrays.asList(attributes));
urlBuffer.append(attributes[0]);
for (int i = 1; i < attributes.length; i++) {
urlBuffer.append(COMMA_CHAR);
urlBuffer.append(attributes[i]);
}
break;
}
// Add the scope.
urlBuffer.append(QUESTION_CHAR);
if (scope != null) {
this.scope = scope;
urlBuffer.append(scope);
} else {
this.scope = DEFAULT_SCOPE;
}
// Add the search filter.
urlBuffer.append(QUESTION_CHAR);
if (filter != null) {
this.filter = filter;
urlBuffer.append(this.filter);
} else {
this.filter = DEFAULT_FILTER;
}
urlString = urlBuffer.toString();
}
private LDAPUrl(final String urlString, final Schema schema) {
this.urlString = urlString;
// Parse the url and build the LDAP URL.
final int schemeIdx = urlString.indexOf("://");
if (schemeIdx < 0) {
final LocalizableMessage msg = ERR_LDAPURL_NO_SCHEME.get(urlString);
throw new LocalizedIllegalArgumentException(msg);
}
final String scheme = StaticUtils.toLowerCase(urlString.substring(0, schemeIdx));
if (scheme.equalsIgnoreCase(DEFAULT_URL_SCHEME)) {
// Default ldap scheme.
isSecured = false;
} else if (scheme.equalsIgnoreCase(SSL_URL_SCHEME)) {
isSecured = true;
} else {
final LocalizableMessage msg = ERR_LDAPURL_BAD_SCHEME.get(urlString, scheme);
throw new LocalizedIllegalArgumentException(msg);
}
final int urlLength = urlString.length();
final int hostPortIdx = urlString.indexOf(SLASH_CHAR, schemeIdx + 3);
final StringBuilder builder = new StringBuilder();
if (hostPortIdx < 0) {
// We got anything here like the host and port?
if (urlLength > (schemeIdx + 3)) {
final String hostAndPort = urlString.substring(schemeIdx + 3, urlLength);
port = parseHostPort(urlString, hostAndPort, builder);
host = builder.toString();
builder.setLength(0);
} else {
// Nothing else is specified apart from the scheme.
// Use the default settings and return from here.
host = DEFAULT_HOST;
port = isSecured ? DEFAULT_SSL_PORT : DEFAULT_PORT;
}
name = DEFAULT_DN;
scope = DEFAULT_SCOPE;
filter = DEFAULT_FILTER;
attributes = Collections.emptyList();
return;
}
final String hostAndPort = urlString.substring(schemeIdx + 3, hostPortIdx);
// assign the host and port.
port = parseHostPort(urlString, hostAndPort, builder);
host = builder.toString();
builder.setLength(0);
// Parse the dn.
DN parsedDN = null;
final int dnIdx = urlString.indexOf(QUESTION_CHAR, hostPortIdx + 1);
if (dnIdx < 0) {
// Whatever we have here is the dn.
final String dnStr = urlString.substring(hostPortIdx + 1, urlLength);
percentDecoder(urlString, hostPortIdx + 1, dnStr, builder);
try {
parsedDN = DN.valueOf(builder.toString(), schema);
} catch (final LocalizedIllegalArgumentException e) {
final LocalizableMessage msg =
ERR_LDAPURL_INVALID_DN.get(urlString, e.getMessageObject());
throw new LocalizedIllegalArgumentException(msg);
}
builder.setLength(0);
name = parsedDN;
scope = DEFAULT_SCOPE;
filter = DEFAULT_FILTER;
attributes = Collections.emptyList();
return;
}
final String dnStr = urlString.substring(hostPortIdx + 1, dnIdx);
if (dnStr.length() == 0) {
parsedDN = DEFAULT_DN;
} else {
percentDecoder(urlString, hostPortIdx + 1, dnStr, builder);
try {
parsedDN = DN.valueOf(builder.toString(), schema);
} catch (final LocalizedIllegalArgumentException e) {
final LocalizableMessage msg =
ERR_LDAPURL_INVALID_DN.get(urlString, e.getMessageObject());
throw new LocalizedIllegalArgumentException(msg);
}
builder.setLength(0);
}
name = parsedDN;
// Find out the attributes.
final int attrIdx = urlString.indexOf(QUESTION_CHAR, dnIdx + 1);
if (attrIdx < 0) {
attributes = Collections.emptyList();
scope = DEFAULT_SCOPE;
filter = DEFAULT_FILTER;
return;
} else {
final String attrDesc = urlString.substring(dnIdx + 1, attrIdx);
final StringTokenizer token = new StringTokenizer(attrDesc, String.valueOf(COMMA_CHAR));
final List parsedAttrs = new ArrayList(token.countTokens());
while (token.hasMoreElements()) {
parsedAttrs.add(token.nextToken());
}
attributes = Collections.unmodifiableList(parsedAttrs);
}
// Find the scope.
final int scopeIdx = urlString.indexOf(QUESTION_CHAR, attrIdx + 1);
SearchScope parsedScope = SearchScope.BASE_OBJECT;
if (scopeIdx < 0) {
scope = DEFAULT_SCOPE;
filter = DEFAULT_FILTER;
return;
} else {
String scopeDef = urlString.substring(attrIdx + 1, scopeIdx);
scopeDef = StaticUtils.toLowerCase(scopeDef);
for (final SearchScope sscope : SearchScope.values()) {
if (sscope.toString().equals(scopeDef)) {
parsedScope = sscope;
break;
}
}
}
scope = parsedScope;
// Last one is filter.
final String parsedFilter = urlString.substring(scopeIdx + 1, urlLength);
if (parsedFilter.length() > 0) {
// Clear what we already have.
builder.setLength(0);
percentDecoder(urlString, scopeIdx + 1, parsedFilter, builder);
try {
this.filter = Filter.valueOf(builder.toString());
} catch (final LocalizedIllegalArgumentException e) {
final LocalizableMessage msg =
ERR_LDAPURL_INVALID_FILTER.get(urlString, e.getMessageObject());
throw new LocalizedIllegalArgumentException(msg);
}
} else {
this.filter = DEFAULT_FILTER;
}
}
/**
* Creates a new search request containing the parameters of this LDAP URL.
*
* @return A new search request containing the parameters of this LDAP URL.
*/
public SearchRequest asSearchRequest() {
final SearchRequest request = Requests.newSearchRequest(name, scope, filter);
for (final String a : attributes) {
request.addAttribute(a);
}
return request;
}
/**
* {@inheritDoc}
*/
@Override()
public boolean equals(final Object o) {
if (o == this) {
return true;
} else if (o instanceof LDAPUrl) {
final String s1 = toNormalizedString();
final String s2 = ((LDAPUrl) o).toNormalizedString();
return s1.equals(s2);
} else {
return false;
}
}
/**
* Returns an unmodifiable list containing the attributes to be included
* with each entry that matches the search criteria. Attributes that are
* sub-types of listed attributes are implicitly included. If the returned
* list is empty then all user attributes will be included by default.
*
* @return An unmodifiable list containing the attributes to be included
* with each entry that matches the search criteria.
*/
public List getAttributes() {
return attributes;
}
/**
* Returns the search filter associated with this LDAP URL.
*
* @return The search filter associated with this LDAP URL.
*/
public Filter getFilter() {
return filter;
}
/**
* Returns the name or IP address in dotted format of the LDAP server
* referenced by this LDAP URL. For example, {@code ldap.server1.com} or
* {@code 192.202.185.90}. Use {@code null} for the local host.
*
* @return A name or IP address in dotted format of the LDAP server
* referenced by this LDAP URL.
*/
public String getHost() {
return host;
}
/**
* Returns the distinguished name of the base entry relative to which the
* search is to be performed.
*
* @return The distinguished name of the base entry relative to which the
* search is to be performed.
*/
public DN getName() {
return name;
}
/**
* Returns the port number of the LDAP server referenced by this LDAP URL.
*
* @return The port number of the LDAP server referenced by this LDAP URL.
*/
public int getPort() {
return port;
}
/**
* Returns the search scope associated with this LDAP URL.
*
* @return The search scope associated with this LDAP URL.
*/
public SearchScope getScope() {
return scope;
}
/**
* {@inheritDoc}
*/
@Override
public int hashCode() {
final String s = toNormalizedString();
return s.hashCode();
}
/**
* Returns {@code true} if this LDAP URL should use LDAPS or {@code false}
* if it should use LDAP.
*
* @return {@code true} if this LDAP URL should use LDAPS or {@code false}
* if it should use LDAP.
*/
public boolean isSecure() {
return isSecured;
}
/**
* {@inheritDoc}
*/
@Override
public String toString() {
return urlString;
}
private int parseHostPort(final String urlString, final String hostAndPort,
final StringBuilder host) {
Validator.ensureNotNull(hostAndPort, port, host);
int urlPort = isSecured ? DEFAULT_SSL_PORT : DEFAULT_PORT;
if (hostAndPort.length() == 0) {
host.append(DEFAULT_HOST);
return urlPort;
}
final int colonIdx = hostAndPort.indexOf(':');
if (colonIdx < 0) {
// port is not specified.
host.append(hostAndPort);
return urlPort;
} else {
String s = hostAndPort.substring(0, colonIdx);
if (s.length() == 0) {
// Use the default host as we allow only the port to be
// specified.
host.append(DEFAULT_HOST);
} else {
host.append(s);
}
s = hostAndPort.substring(colonIdx + 1, hostAndPort.length());
try {
urlPort = Integer.parseInt(s);
} catch (final NumberFormatException e) {
final LocalizableMessage msg = ERR_LDAPURL_CANNOT_DECODE_PORT.get(urlString, s);
throw new LocalizedIllegalArgumentException(msg);
}
// Check the validity of the port.
if (urlPort < 1 || urlPort > 65535) {
final LocalizableMessage msg = ERR_LDAPURL_INVALID_PORT.get(urlString, urlPort);
throw new LocalizedIllegalArgumentException(msg);
}
}
return urlPort;
}
private String toNormalizedString() {
if (normalizedURL == null) {
final StringBuilder builder = new StringBuilder();
if (this.isSecured) {
builder.append(SSL_URL_SCHEME);
} else {
builder.append(DEFAULT_URL_SCHEME);
}
builder.append("://");
builder.append(host);
builder.append(COLON_CHAR);
builder.append(port);
builder.append(SLASH_CHAR);
percentEncoder(name.toString(), builder);
builder.append(QUESTION_CHAR);
final int sz = attributes.size();
for (int i = 0; i < sz; i++) {
if (i > 0) {
builder.append(COMMA_CHAR);
}
builder.append(attributes.get(i));
}
builder.append(QUESTION_CHAR);
builder.append(scope);
builder.append(QUESTION_CHAR);
percentEncoder(filter.toString(), builder);
normalizedURL = builder.toString();
}
return normalizedURL;
}
}