From 807d48435d0cf1e2e840a0814edd7efe9a273ced Mon Sep 17 00:00:00 2001
From: Bear Giles
Date: Mon, 16 Sep 2024 00:58:19 -0600
Subject: [PATCH] Initial commit
Minimal viable product - can successfully connect to LDAP server
as both anonymous user and authenticated admin user.
This also includes java classes corresponding to several standard
LDAP schemas. These classes do not contain the Active Directory or
Kerberos schemas.
---
modules/openldap/README.md | 183 ++++
modules/openldap/build.gradle | 7 +
.../containers/ObjectClassInformation.java | 184 +++++
.../containers/OpenLdapConfiguration.java | 36 +
.../containers/OpenLdapContainer.java | 782 ++++++++++++++++++
.../containers/schema/ApplicationEntity.java | 40 +
.../containers/schema/ApplicationProcess.java | 28 +
.../schema/CertificationAuthority.java | 30 +
.../containers/schema/Country.java | 26 +
.../testcontainers/containers/schema/DSA.java | 18 +
.../containers/schema/Device.java | 40 +
.../containers/schema/GroupOfNames.java | 41 +
.../containers/schema/GroupOfUniqueNames.java | 41 +
.../containers/schema/InetOrgPerson.java | 72 ++
.../containers/schema/Locality.java | 34 +
.../containers/schema/Organization.java | 63 ++
.../schema/OrganizationalPerson.java | 53 ++
.../containers/schema/OrganizationalRole.java | 61 ++
.../containers/schema/OrganizationalUnit.java | 63 ++
.../containers/schema/Person.java | 35 +
.../containers/schema/PkiCA.java | 28 +
.../containers/schema/PkiUser.java | 18 +
.../containers/schema/ResidentialPerson.java | 52 ++
.../schema/StrongAuthenticationUser.java | 18 +
.../testcontainers/containers/schema/Top.java | 13 +
.../schema/UserSecurityInformation.java | 18 +
.../containers/schema/annotations/May.java | 15 +
.../containers/schema/annotations/Must.java | 17 +
.../containers/schema/annotations/Oid.java | 16 +
.../containers/schema/annotations/Rfc.java | 15 +
.../schema/annotations/RfcValue.java | 7 +
.../ObjectClassInformationTest.java | 47 ++
.../containers/OpenLdapContainerTest.java | 81 ++
.../src/test/resources/logback-test.xml | 16 +
.../test/resources/objectClassDefinitions.txt | 73 ++
35 files changed, 2271 insertions(+)
create mode 100644 modules/openldap/README.md
create mode 100644 modules/openldap/build.gradle
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/ObjectClassInformation.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/OpenLdapConfiguration.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/OpenLdapContainer.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/ApplicationEntity.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/ApplicationProcess.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/CertificationAuthority.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/Country.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/DSA.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/Device.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/GroupOfNames.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/GroupOfUniqueNames.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/InetOrgPerson.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/Locality.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/Organization.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/OrganizationalPerson.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/OrganizationalRole.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/OrganizationalUnit.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/Person.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/PkiCA.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/PkiUser.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/ResidentialPerson.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/StrongAuthenticationUser.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/Top.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/UserSecurityInformation.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/annotations/May.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/annotations/Must.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/annotations/Oid.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/annotations/Rfc.java
create mode 100644 modules/openldap/src/main/java/org/testcontainers/containers/schema/annotations/RfcValue.java
create mode 100644 modules/openldap/src/test/java/org/testcontainers/containers/ObjectClassInformationTest.java
create mode 100644 modules/openldap/src/test/java/org/testcontainers/containers/OpenLdapContainerTest.java
create mode 100644 modules/openldap/src/test/resources/logback-test.xml
create mode 100644 modules/openldap/src/test/resources/objectClassDefinitions.txt
diff --git a/modules/openldap/README.md b/modules/openldap/README.md
new file mode 100644
index 00000000000..220e3b9438c
--- /dev/null
+++ b/modules/openldap/README.md
@@ -0,0 +1,183 @@
+# IdP - OpenLDAP Module
+
+## Identity Providers (IdP)
+
+Identity Providers provide externalized user authentication (AuthN) and
+authorization (AuthZ), and are increasingly available as authentication
+mechanisms used by the type of hosted services available as TestContainers.
+
+### Providers
+
+- Microsoft Active Directory
+- IPA/[FreeIPA](https://freeipa.org/)
+- Samba (limited)
+- All cloud providers
+- All OAuth providers such as Google, Facebook, GitHub, etc. (typically limited to AuthN)
+- [Okta](https://okta.com/)
+- [Authentik](https://goauthentik.io/)
+
+Individual services are often available:
+
+- Kerberos: [MIT KDC](https://web.mit.edu/kerberos/), [Heimdal](https://github.com/heimdal/heimdal), [Apache Kerby](https://directory.apache.org/kerby/)
+- LDAP: [OpenLDAP](https://openldap.org/) ([bitnami/openldap](https://hub.docker.com/r/bitnami/openldap)), [389 Directory Service](https://www.port389.org/), [Apache Directory](https://directory.apache.org/)
+- CA/PKI [DogTag](https://www.dogtagpki.org/)
+- CA/PKI (ACME protocol): Let's Encrypt ([letsencrypt/pebble](https://hub.docker.com/r/letsencrypt/pebble))
+- CA/PKI (EJBCA protocol): ([bitnami/ejbca](https://hub.docker.com/r/bitnami/ejbca)), ([keyfactor/ejbca-ce](https://hub.docker.com/r/keyfactor/ejbca-ce))
+
+(Reminder: the 'bitnami' docker images are published by VMware.)
+
+### Services
+
+A good _de facto_ definition of an Enterprise IdP provider is
+the services provided by Microsoft's Active Directory. It is not
+limited to this - we'll often see additional support for OAuth,
+J, and more.
+
+The minimal services are
+
+- DNS (for service discovery)
+- Kerberos (for Authentication)
+- LDAP (for storage of AuthN and AuthZ data)
+- A Certificate Authority (for managing digital certificates used by servers)
+- A Key Manager (for managing encryption keys)
+
+In modern terms LDAP can be viewed as a column database with a few
+unusual constraints. The biggest limiting factor is that you should
+only use properly registered OIDs. This is not a huge burden - there's
+a well-documented process to get a new OID root and you're free to
+extend however you like - think of the requirement to register a domain
+name and then having freedom to manage your subdomains however you like -
+but this is step that's not required by any other database.
+
+### Active Directory
+
+Active Directory makes extensive use of service discovery and requires
+the LDAP server include an additional schema.
+
+In addition Active Directory requires the use of DNS
+[SRV records](https://en.wikipedia.org/wiki/SRV_record) in order to find the
+required services. This is not difficult to do when you manage your own
+DNS servers and it can solve a lot of problems. However it does require
+a bit of prep work in both setting up the DNS server and modifying applications
+to be able to use SRV records.
+
+### Practical Note
+
+It's now common to use a relational database as the backing store for both the
+LDAP server and the Kerberos KDC. In these cases the latter two services are
+still available for the applications that support (or require) them but many
+applications will directly access the database.
+
+### Java DNS lookup code using JNDI
+
+This code retrieves and SRV records.
+
+__Note: I've successfully used similar code in the past but haven't verified this specific implementation yet.__
+
+```java
+import javax.naming.Context;
+
+public class ResourceLocator {
+ // This assumes "DNS_SERVER" system property.
+ private static final String DEFAULT_DNS_SERVER = "dns://1.1.1.1/";
+ private static final String DnsContextFactoryName = "com.sun.jndi.ldap.LdapCtxFactory";
+
+ enum Protocol {
+ TCP,
+ UDP
+ }
+
+ /**
+ * Retrieve SRV records
+ *
+ * @param service service, e.g., "ldap", "imap", "postgres"
+ * @param protocol ("tcp", "udp")
+ * @param domainName domain name to search
+ * @return list of matching SRV records
+ */
+ List findSrvRecord(String service, Protocol protocol, String domainName) throws NamingException, IOException {
+ final List srvRecords = new ArrayList<>();
+ final String hostname = String.format("_%s._%s.%s.", service, protocol.name().toLowerCase(), domainName);
+
+ // Find JNDI DirContext
+ final Hashtable env = new Hashtable();
+ env.put(Context.PROVIDER_URL, System.getProperty(DNS_SERVER, DEFAULT_DNS_SERVER));
+ env.put(Context.INITIAL_CONTEXT_FACTORY, DNS_CONTEXT_FACTORY_NAME);
+ final DirContext ctx = new InitialDirContext(env);
+
+ try {
+ final NamingEnumeration searchResultsEnumeration = ctx.search(hostname, new String[]{ "SRV" });
+ while (searchResultsEnumertion.hasNext()) {
+ // check - we may get this as string
+ srvRecords.add(SrvRecord.parse(searchResultsEnumeration.next()));
+ }
+ } catch (NameNotFoundException e) {
+ // leave srvRecords collection empty
+ }
+
+ return srvRecords;
+ }
+}
+
+@Data
+public class SrvRecord {
+ private String service; // the symbolic name of the desired service.
+ private String proto; // the transport protocol of the desired service; this is usually either TCP or UDP.
+ private String name; // the domain name for which this record is valid, ending in a dot.
+ private int ttl; // standard DNS time to live field.
+ private int priority; // the priority of the target host, lower value means more preferred.
+ private int weight; // A relative weight for records with the same priority, higher value means higher chance of getting picked.
+ private int port; // the TCP or UDP port on which the service is to be found.
+ private String target; // the canonical hostname of the machine providing the service, ending in a dot.
+
+ public SrvRecord parse(SearchResult searchResult) {
+ final SrvRecord record = new SrvRecord();
+ // format is '_service._proto.name. ttl IN SRV priority weight port target.'
+ // extract attributes
+ return record;
+ }
+}
+
+```
+
+
+## OpenLDAP Module
+
+This module wraps the bitnami/openldap docker image.
+
+## Tasks
+
+- [x] Anonymous access
+- [x] Admin access (simple authentication)
+- [ ] Add users (simple authentication)
+- [ ] Enable or Require TLS
+ - [ ] Provide server cert
+ - [ ] Provide third-party certs (/opt/bitnami/openldap/certs/)
+- [ ] Add additional schemas
+ - [ ] Active Directory schema
+ - [ ] Kerberos
+- [ ] Advanced authentication
+- [ ] Run initialization scripts (/docker-entrypoint-initdb.d/)
+
+## Server Functionality Tests
+
+- [x] Anonymous access
+- [x] Admin access (simple authentication)
+- [x] List ObjectClasses ('proof of life', etc)
+- [ ] Access using TLS
+- [ ] Access using stronger authentication
+
+## Additional Tests
+
+(Hamcrest matchers...)
+
+- [ ] User tasks
+ - [ ] List
+ - [ ] Add
+ - [ ] Remove
+- [ ] Group tasks
+ - [ ] List
+ - [ ] Add
+ - [ ] Remove
+ - [ ] Add user to group
+ - [ ] Remove user from group
diff --git a/modules/openldap/build.gradle b/modules/openldap/build.gradle
new file mode 100644
index 00000000000..cc63bf31f3c
--- /dev/null
+++ b/modules/openldap/build.gradle
@@ -0,0 +1,7 @@
+description = "Testcontainers :: OpenLdap"
+
+dependencies {
+ api project(':testcontainers')
+
+ testImplementation 'org.assertj:assertj-core:3.26.3'
+}
diff --git a/modules/openldap/src/main/java/org/testcontainers/containers/ObjectClassInformation.java b/modules/openldap/src/main/java/org/testcontainers/containers/ObjectClassInformation.java
new file mode 100644
index 00000000000..fb645cd1e4f
--- /dev/null
+++ b/modules/openldap/src/main/java/org/testcontainers/containers/ObjectClassInformation.java
@@ -0,0 +1,184 @@
+package org.testcontainers.containers;
+
+import lombok.Data;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Information about the LDAP Schema's ObjectClasses
+ *
+ * Implementation Note
+ *
+ *
+ * The parser current fails when the definition includes multiple names or multiple superior object classes.
+ * The known examples are:
+ *
+ *
( 1.3.6.1.4.1.4203.1.4.1 NAME ( 'OpenLDAProotDSE' 'LDAProotDSE' ) DESC 'OpenLDAP Root DSE object' SUP top STRUCTURAL MAY cn )
+ * This class provides additional functionality intended to simply writing tests.
+ * The first is a collection of methods to acquire a connection to the server:
+ *
+ *
connectAnonymously()
+ *
connectAsAdmin()
+ *
connectAsConfig()
+ *
connectAsUser(String username, String password)
+ *
+ *
+ *
+ * The second is a collection of methods to retrieve standard information from
+ * the server:
+ *
+ *
listObjectClasses()
+ *
+ *
+ *
+ * Analogous methods are provided for any LDAP connection, although it's important
+ * to remember that servers have different implementations. E.g., 'schemas' vs
+ * 'subschemas'.
+ *
+ *
listObjectClasses(DirContext ctx)
+ *
listUsers(DirContext ctx, String query)
+ *
getUserDetails(DirContext ctx, String query)
+ *
+ *
+ *
+ *
Limitations
+ *
+ * This implementation does not support the following features of the
+ * underlying Bitnami docker container:
+ *
+ *
AccessLog Module
+ *
Syncrepl Module
+ *
Proxy Protocol Support
+ *
+ *
+ *
+ *
Extensions
+ *
+ *
+ *
Additional certs should be added to /opt/bitnami/openldap/certs'
+ *
Custom initialization scripts can be added to '/docker-entrypoint-initdb.d/'
+ *
Persistence can be supported with '/bitnami/openldap/'
+ *
The json log driver is used by default. It can be modified with '--log-driver'
+ *
+ *
+ */
+public class OpenLdapContainer extends GenericContainer {
+ private static final Logger LOG = LoggerFactory.getLogger(OpenLdapContainer.class);
+
+ private static final String LDAP_CONTEXT_FACTORY = "com.sun.jndi.ldap.LdapCtxFactory";
+
+ private static final String LDAP_PROTOCOL_DEFAULT = "ldap";
+
+ private static final String LDAP_PROTOCOL_SECURED = "ldaps";
+
+ private static final String LDAP_AUTHENTICATION_ANONYMOUS = "none";
+
+ // provide username but no password - not sure if this is the correct term
+ // private static final String LDAP_AUTHENTICATION_UNAUTHENTICATED = "unauthenticated";
+
+ private static final String LDAP_AUTHENTICATION_SIMPLE = "simple";
+
+ // advanced techniques. May need to specify something more precise, e.g., "kerberos"
+ // private static final String LDAP_AUTHENTICATION_SASL = "sasl";
+
+ // default values used by Bitnami docker image
+ private static final Boolean DEFAULT_ALLOW_ANON_BINDING = Boolean.FALSE;
+
+ private static final Boolean DEFAULT_ALLOW_EMPTY_PASSWORD = Boolean.FALSE;
+
+ private static final Boolean DEFAULT_BITNAMI_DEBUG = Boolean.FALSE;
+
+ private static final String DEFAULT_BASE_DN = "dc=example,dc=org";
+
+ private static final String DEFAULT_ADMIN_USERNAME = "admin";
+
+ private static final String DEFAULT_ADMIN_PASSWORD = "adminpassword";
+
+ private static final Boolean DEFAULT_CONFIG_ADMIN_ENABLED = Boolean.FALSE;
+
+ private static final String DEFAULT_CONFIG_USERNAME = "admin";
+
+ private static final String DEFAULT_CONFIG_PASSWORD = "configpassword";
+
+ private static final String DEFAULT_USER_DC = "users";
+
+ private static final String DEFAULT_GROUP = "readers";
+
+ private static final Boolean DEFAULT_ENABLE_TLS = Boolean.FALSE;
+
+ private static final Boolean DEFAULT_REQUIRE_TLS = Boolean.FALSE;
+
+ // default image name
+ private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("bitnami/openldap");
+
+ // default tag (current as this module is written)
+ private static final String DEFAULT_TAG = "2.5.18";
+
+ // default value used by the Bitnami docker container
+ private static final int LDAP_PORT = 1389;
+
+ // default value used by the Bitnami docker container
+ private static final int LDAPS_PORT = 1636;
+
+ // default schameas: cosine,inetorgperson,nis
+
+ // https://hub.docker.com/r/bitnami/openldap
+
+ // LDAP_USERS: Comma separated list of LDAP users to create in the default LDAP tree. Default: user01,user02
+ // LDAP_PASSWORDS: Comma separated list of passwords to use for LDAP users. Default: bitnami1,bitnami2
+ //
+ // LDAP_ADD_SCHEMAS: Whether to add the schemas specified in LDAP_EXTRA_SCHEMAS. Default: yes
+ // LDAP_EXTRA_SCHEMAS: Extra schemas to add, among OpenLDAP's distributed schemas. Default: cosine, inetorgperson, nis
+ // LDAP_SKIP_DEFAULT_TREE: Whether to skip creating the default LDAP tree based on LDAP_USERS, LDAP_PASSWORDS, LDAP_USER_DC and LDAP_GROUP. Please note that this will not skip the addition of schemas or importing of LDIF files. Default: no
+ // LDAP_CUSTOM_LDIF_DIR: Location of a directory that contains LDIF files that should be used to bootstrap the database. Only files ending in .ldif will be used. Default LDAP tree based on the LDAP_USERS, LDAP_PASSWORDS, LDAP_USER_DC and LDAP_GROUP will be skipped when LDAP_CUSTOM_LDIF_DIR is used. When using this it will override the usage of LDAP_USERS, LDAP_PASSWORDS, LDAP_USER_DC and LDAP_GROUP. You should set LDAP_ROOT to your base to make sure the olcSuffix configured on the database matches the contents imported from the LDIF files. Default: /ldifs
+ // LDAP_CUSTOM_SCHEMA_FILE: Location of a custom internal schema file that could not be added as custom ldif file (i.e. containing some structuralObjectClass). Default is /schema/custom.ldif"
+ // LDAP_CUSTOM_SCHEMA_DIR: Location of a directory containing custom internal schema files that could not be added as custom ldif files (i.e. containing some structuralObjectClass). This can be used in addition to or instead of LDAP_CUSTOM_SCHEMA_FILE (above) to add multiple schema files. Default: /schemas
+
+ // LDAP_TLS_CERT_FILE=/opt/bitnami/openldap/certs/openldap.crt
+ // LDAP_TLS_KEY_FILE=/opt/bitnami/openldap/certs/openldap.key
+ // LDAP_TLS_CA_FILE=/opt/bitnami/openldap/certs/openldapCA.crt
+
+ private final OpenLdapConfiguration configuration;
+
+ /**
+ * Default constructor
+ */
+ public OpenLdapContainer() {
+ this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG));
+ }
+
+ /**
+ * Standard constructor
+ *
+ * @param dockerImageName Docker image compatible with 'bitnami:openldap'
+ */
+ public OpenLdapContainer(final DockerImageName dockerImageName) {
+ super(dockerImageName);
+ dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME);
+
+ this.waitStrategy =
+ new LogMessageWaitStrategy()
+ .withRegEx(".*[0-9a-f]{8}.+slapd starting.*\\s")
+ .withStartupTimeout(Duration.of(10, ChronoUnit.SECONDS));
+ this.configuration = new OpenLdapConfiguration();
+ // - in case we need something like this...
+ // super.withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withCapAdd(Capability.IPC_LOCK));
+ }
+
+ /**
+ * Specify whether to allow anonymous binding
+ *
+ * @param allowAnonBinding true if anonymous binding should be allowed. (Default: true)
+ * @return self
+ */
+ public OpenLdapContainer withAllowAnonBinding(Boolean allowAnonBinding) {
+ configuration.setAllowAnonBinding(allowAnonBinding);
+ return self();
+ }
+
+ /**
+ * Specify whether empty passwords are allowed
+ *
+ * @param allowEmptyPassword true if empty passwords are allowed. (Default: false)
+ * @return self
+ */
+ public OpenLdapContainer withAllowEmptyPassword(Boolean allowEmptyPassword) {
+ configuration.setAllowEmptyPassword(allowEmptyPassword);
+ return self();
+ }
+
+ /**
+ * Specify whether container-based debugging should be enabled
+ *
+ * @param containerDebuggingEnabled true if container-based debugging should be enabled. (Default: false)
+ * @return self
+ */
+ public OpenLdapContainer withContainerDebuggingEnabled(Boolean containerDebuggingEnabled) {
+ configuration.setContainerDebuggingEnabled(containerDebuggingEnabled);
+ return self();
+ }
+
+ /**
+ * Specify Base Distinguished Name
+ *
+ * @param baseDN Base Distinguished Name (No default value)
+ * @return self
+ */
+ public OpenLdapContainer withBaseDN(String baseDN) {
+ configuration.setBaseDN(baseDN);
+ return self();
+ }
+
+ /**
+ * Specify administrative username and password
+ *
+ * @param username administrative user name (default: 'admin')
+ * @param password administrative user password (default: 'adminpassword')
+ * @return self
+ */
+ public OpenLdapContainer withAdminUsername(String username, String password) {
+ configuration.setAdminUsername(username);
+ configuration.setAdminPassword(password);
+ return self();
+ }
+
+ /**
+ * Specify configuration administrative username and password
+ *
+ * @param username configuration administrative user name (default: 'admin')
+ * @param password configuration administrative user password (default: 'adminpassword')
+ * @return self
+ */
+ public OpenLdapContainer withConfigAdminUsername(String username, String password) {
+ configuration.setConfigAdminEnabled(Boolean.TRUE);
+ configuration.setConfigAdminUsername(username);
+ configuration.setConfigAdminPassword(password);
+ return self();
+ }
+
+ /**
+ * Specify whether to enable TLS
+ *
+ * @param enableTls true to enable TLS (default: false)
+ * @return self
+ */
+ public OpenLdapContainer withEnableTls(Boolean enableTls) {
+ configuration.setEnableTls(enableTls);
+ return self();
+ }
+
+ /**
+ * Specify whether to require TLS
+ *
+ * @param requireTls true to require TLS (default: false)
+ * @return self
+ */
+ public OpenLdapContainer withRequireTls(Boolean requireTls) {
+ configuration.setEnableTls(requireTls);
+ return self();
+ }
+
+ /**
+ * Specify user domain component
+ *
+ * @param userDc user domain component (default: 'users')
+ * @return self
+ */
+ public OpenLdapContainer withUserDc(String userDc) {
+ configuration.setUserDC(userDc);
+ return self();
+ }
+
+ /**
+ * Specify name of group attribute
+ *
+ * @param group name of group attribute (default: 'readers')
+ * @return self
+ */
+ public OpenLdapContainer withGroup(String group) {
+ configuration.setGroup(group);
+ return self();
+ }
+
+ /**
+ * Is anonymous binding permitted?
+ *
+ * @return true if anonymous binding is permitted
+ */
+ public Boolean getAllowAnonBinding() {
+ if (configuration.getAllowAnonBinding() != null) {
+ return configuration.getAllowAnonBinding();
+ }
+ return DEFAULT_ALLOW_ANON_BINDING;
+ }
+
+ /**
+ * Are empty passwords allowed?
+ *
+ * @return true if empty passwords are allowed
+ */
+ public Boolean getAllowEmptyPassword() {
+ if (configuration.getAllowEmptyPassword() != null) {
+ return configuration.getAllowEmptyPassword();
+ }
+ return DEFAULT_ALLOW_EMPTY_PASSWORD;
+ }
+
+ /**
+ * Is container debugging enabled?
+ *
+ * @return true if container debugging is enabled
+ */
+ public Boolean getContainerDebuggingEnabled() {
+ if (configuration.getContainerDebuggingEnabled()) {
+ return configuration.getContainerDebuggingEnabled();
+ }
+ return DEFAULT_BITNAMI_DEBUG;
+ }
+
+ /**
+ * Get BaseDN
+ *
+ * @return baseDN. May be null.
+ */
+ public String getBaseDN() {
+ if (StringUtils.isNotBlank(configuration.getBaseDN())) {
+ return configuration.getBaseDN();
+ }
+ return DEFAULT_BASE_DN;
+ }
+
+ /**
+ * Get administrative user name
+ *
+ * @return administrative user name
+ */
+ public String getAdminUsername() {
+ if (StringUtils.isNotBlank(configuration.getAdminUsername())) {
+ return configuration.getAdminUsername();
+ }
+ return DEFAULT_ADMIN_USERNAME;
+ }
+
+ /**
+ * Get administrative user password
+ *
+ * @return administrative user password
+ */
+ public String getAdminPassword() {
+ if (StringUtils.isNotBlank(configuration.getAdminPassword())) {
+ return configuration.getAdminPassword();
+ }
+ return DEFAULT_ADMIN_PASSWORD;
+ }
+
+ /**
+ * Is configuration administrator enabled?
+ *
+ * @return true if configuration administration is enabled
+ */
+ public Boolean getConfigAdminEnabled() {
+ if (configuration.getConfigAdminEnabled() != null) {
+ return configuration.getConfigAdminEnabled();
+ }
+ return DEFAULT_CONFIG_ADMIN_ENABLED;
+ }
+
+ /**
+ * Get configuration administrative user name
+ *
+ * @return configuration administrative user name
+ */
+ public String getConfigUsername() {
+ if (StringUtils.isNotBlank(configuration.getConfigAdminUsername())) {
+ return configuration.getConfigAdminUsername();
+ }
+ return DEFAULT_CONFIG_USERNAME;
+ }
+
+ /**
+ * Get configuration administrative user password
+ *
+ * @return configuration administrative user password
+ */
+ public String getConfigPassword() {
+ if (StringUtils.isNotBlank(configuration.getConfigAdminPassword())) {
+ return configuration.getConfigAdminPassword();
+ }
+ return DEFAULT_CONFIG_PASSWORD;
+ }
+
+ /**
+ * Get user domain component
+ *
+ * @return user domain component
+ */
+ public String getUserDC() {
+ if (StringUtils.isNotBlank(configuration.getUserDC())) {
+ return configuration.getUserDC();
+ }
+ return DEFAULT_USER_DC;
+ }
+
+ /**
+ * Get name of group attribute
+ * @return name of group attribute
+ */
+ public String getGroup() {
+ if (StringUtils.isNotBlank(configuration.getGroup())) {
+ return configuration.getGroup();
+ }
+ return DEFAULT_GROUP;
+ }
+
+ /**
+ * Get remapped LDAP (389) port
+ *
+ * @return remapped LDAP port, if available
+ */
+ public int getLdapPort() {
+ return getMappedPort(LDAP_PORT);
+ }
+
+ /**
+ * Get remapped LDAPS (636) port
+ *
+ * @return remapped LDAPS port, if available
+ */
+ public int getLdapsPort() {
+ return getMappedPort(LDAPS_PORT);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @SneakyThrows
+ protected void configure() {
+
+ if (configuration.getAllowEmptyPassword() != null) {
+ addEnv("ALLOW_EMPTY_PASSWORD", configuration.getAllowEmptyPassword().toString());
+ }
+
+ if (configuration.getContainerDebuggingEnabled() != null) {
+ addEnv("BITNAMI_DEBUG", configuration.getContainerDebuggingEnabled().toString());
+ }
+
+ if (configuration.getAllowAnonBinding() != null) {
+ addEnv("LDAP_ALLOW_ANON_BINDING", configuration.getAllowAnonBinding().toString());
+ }
+
+ if (StringUtils.isNotBlank(configuration.getBaseDN())) {
+ addEnv("LDAP_ROOT", configuration.getBaseDN());
+ }
+
+ if (StringUtils.isNotBlank(configuration.getAdminUsername())) {
+ addEnv("LDAP_ADMIN_USERNAME", configuration.getAdminUsername());
+ }
+
+ if (StringUtils.isNotBlank(configuration.getAdminPassword())) {
+ addEnv("LDAP_ADMIN_PASSWORD", configuration.getAdminPassword());
+ }
+
+ if (Boolean.TRUE.equals(configuration.getConfigAdminEnabled())) {
+ addEnv("LDAP_CONFIG_ADMIN_ENABLED", configuration.getConfigAdminEnabled().toString());
+ if (StringUtils.isNotBlank(configuration.getConfigAdminUsername())) {
+ addEnv("LDAP_CONFIG_ADMIN_USERNAME", configuration.getConfigAdminUsername());
+ }
+
+ if (StringUtils.isNotBlank(configuration.getConfigAdminPassword())) {
+ addEnv("LDAP_CONFIG_ADMIN_PASSWORD", configuration.getConfigAdminPassword());
+ }
+ }
+
+ if (StringUtils.isNotBlank(configuration.getUserDC())) {
+ addEnv("LDAP_USER_DC", configuration.getUserDC());
+ }
+
+ if (StringUtils.isNotBlank(configuration.getGroup())) {
+ addEnv("LDAP_GROUP", configuration.getGroup());
+ }
+
+ // TODO - add users, passwords
+
+ if (configuration.getRequireTls() != null) {}
+
+ // TODO - add schemas
+
+ // Add Default Ports
+ // note: it's usually one or the other!
+ this.addExposedPort(LDAP_PORT);
+ this.addExposedPort(LDAPS_PORT);
+ }
+
+ @Override
+ public Set getLivenessCheckPortNumbers() {
+ return new HashSet<>(getLdapPort());
+ }
+
+ @Override
+ protected void waitUntilContainerStarted() {
+ getWaitStrategy().waitUntilReady(this);
+ }
+
+ // @Override
+ // @SneakyThrows
+ // private void containerIsStarted(InspectContainerResponse containerInfo) {
+ // }
+
+ /**
+ * Get the server's URL
+ *
+ * Implementation note: we can't use java.net.URL/URI since 'LDAP' does not have
+ * a registered URL handler.
+ *
+ * @return server's URL
+ */
+ public String getUrl() {
+ // TODO: know whether to use LDAP/LDAPS, and which port
+ return String.format("%s://%s:%d/", "ldap", getHost(), getLdapPort());
+ }
+
+ /**
+ * Get anonymous connection to the server
+ *
+ * @return LDAP connection
+ * @throws NamingException
+ */
+ public DirContext connectAnonymously() throws NamingException {
+ return connectAsUser(null, null, LDAP_AUTHENTICATION_ANONYMOUS);
+ }
+
+
+ /**
+ * Get authenticated connection to the server as the admin user
+ *
+ * @return LDAP connection
+ * @throws NamingException
+ */
+ public DirContext connectAsAdmin() throws NamingException {
+ return connectAsUser(getAdminUsername(), getAdminPassword(), LDAP_AUTHENTICATION_SIMPLE);
+ }
+
+ /**
+ * Get authenticated connection to the server as the configuration admin user
+ *
+ * @return LDAP connection
+ * @throws NamingException
+ */
+ public DirContext connectAsConfigAdmin() throws NamingException {
+ return connectAsUser(getConfigUsername(), getConfigPassword(), LDAP_AUTHENTICATION_SIMPLE);
+ }
+
+ /**
+ * Get authenticated connection to the server as any user
+ *
+ * @param username username
+ * @param password password
+ * @return LDAP connection
+ * @throws NamingException
+ */
+ public DirContext connectAsUser(String username, String password) throws NamingException {
+ return connectAsUser(username, password, LDAP_AUTHENTICATION_SIMPLE);
+ }
+
+ /**
+ * Get JNDI DirContext that points to specified LDAP server
+ *
+ * @param username username
+ * @param password password
+ * @param authentication authentication mechanism
+ * @return LDAP connection
+ * @throws NamingException
+ */
+ private DirContext connectAsUser(String username, String password,
+ String authentication) throws NamingException {
+ final Hashtable env = new Hashtable();
+ env.put(Context.INITIAL_CONTEXT_FACTORY, LDAP_CONTEXT_FACTORY);
+ env.put(Context.PROVIDER_URL, getUrl());
+ env.put(Context.SECURITY_AUTHENTICATION, authentication);
+
+ if (StringUtils.isNotBlank(username)) {
+ // this may not work with inetOrgPerson users - only tested with admin
+ env.put(Context.SECURITY_PRINCIPAL, "cn=" + username + "," + getBaseDN());
+ }
+
+ if (StringUtils.isNotBlank(password)) {
+ env.put(Context.SECURITY_CREDENTIALS, password);
+ }
+
+ return new InitialDirContext(env);
+ }
+
+ /**
+ * List object classes.
+ *
+ * @return list of objectClasses
+ * @throws NamingException error communicating with LDAP server
+ * @throws NameNotFoundException if 'subschema' isn't found (may be schema)
+ */
+ public List listObjectClasses() throws NamingException {
+ DirContext context = null;
+ try {
+ context = connectAsAdmin();
+ return listObjectClasses(context);
+ } finally {
+ if (context != null) {
+ context.close();
+ }
+ }
+ }
+
+ /**
+ * List object classes.
+ *
+ * @param context LDAP connection
+ * @return list of objectClasses
+ * @throws NamingException error communicating with LDAP server
+ * @throws NameNotFoundException if 'subschema' isn't found (may be schema)
+ */
+ public List listObjectClasses(DirContext context) throws NamingException {
+ final List objectClasses = new ArrayList<>();
+
+ final SearchControls searchControls = new SearchControls();
+ searchControls.setSearchScope(SearchControls.OBJECT_SCOPE);
+ searchControls.setReturningAttributes(new String[]{"objectClasses"});
+
+ final NamingEnumeration objectClassesSearchResults =
+ context.search("cn=subschema", "(ObjectClass=*)", searchControls);
+
+ while (objectClassesSearchResults.hasMoreElements()) {
+ final SearchResult result = objectClassesSearchResults.next();
+
+ final Attributes attributes = result.getAttributes();
+ final Attribute attr = attributes.get("objectClasses");
+
+ final NamingEnumeration> objectClassesEnumeration = attr.getAll();
+ while (objectClassesEnumeration.hasMoreElements()) {
+ final String objectClassesRecord = (String) objectClassesEnumeration.next();
+ try {
+ objectClasses.add(ObjectClassInformation.parse(objectClassesRecord));
+ } catch (IllegalArgumentException e) {
+ // sadly not unexpected...
+ LOG.warn(e.getMessage());
+ }
+ }
+ }
+
+ return objectClasses;
+ }
+
+ /**
+ * List all users. This method returns a Map with a key of (unordered) user CN
+ * and a value of all IDs.
+ *
+ * @param context
+ * @param query query (e.g., "ou=people," + String.join("," dc))
+ * @return Map of user 'cn' and respective 'ids'
+ * @throws NamingException
+ */
+ public Map> listUsers(DirContext context, String query) throws NamingException {
+ final Map> users = new LinkedHashMap<>();
+
+ // connect to server.
+ final SearchControls searchControls = new SearchControls();
+ searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
+ searchControls.setReturningAttributes(new String[]{"cn"});
+
+ try {
+ final NamingEnumeration userSearchResultEnumeration =
+ context.search(query, "(ObjectClass=inetOrgPerson)", searchControls);
+ while (userSearchResultEnumeration.hasMoreElements()) {
+ final List ids = new ArrayList<>();
+
+ final SearchResult userSearchResult = userSearchResultEnumeration.next();
+ final Attributes entry = userSearchResult.getAttributes();
+ final NamingEnumeration idEnumeration = (NamingEnumeration) entry.getIDs();
+ while (idEnumeration.hasMoreElements()) {
+ ids.add(idEnumeration.nextElement());
+ }
+ users.put(entry.get("cn").toString(), ids);
+ }
+ } catch (NameNotFoundException e) {
+ // do nothing - return empty map
+ }
+
+ return users;
+ }
+
+ /**
+ * List details a user.
+ *
+ * @param context
+ * @param dn
+ * @return optional matching user
+ * @throws NamingException
+ */
+ public Optional getUserDetails(DirContext context, String dn) throws NamingException {
+ final List users = new ArrayList<>();
+
+ final SearchControls searchControls = new SearchControls();
+ searchControls.setSearchScope(SearchControls.OBJECT_SCOPE);
+
+ try {
+ final NamingEnumeration inetOrgPersonSearchResultEnumeration =
+ context.search(dn, "(ObjectClass=inetOrgPerson)", searchControls);
+ while (inetOrgPersonSearchResultEnumeration.hasMoreElements()) {
+ final SearchResult inetOrgPersonSearchResult = inetOrgPersonSearchResultEnumeration.next();
+ final InetOrgPerson person = parseInetOrgPerson(inetOrgPersonSearchResult);
+ users.add(person);
+ }
+ } catch (NameNotFoundException e) {
+ return Optional.empty();
+ }
+
+ // there should be, at most, a single match
+ if (users.size() > 1) {
+ LOG.warn("multiple matches found! dn = '{}'", dn);
+ }
+
+ return Optional.of(users.get(0));
+ }
+
+ /**
+ * Parse search results for individual person
+ * @param personSearchResult results of query
+ * @return user information
+ */
+ InetOrgPerson parseInetOrgPerson(SearchResult personSearchResult) {
+ final InetOrgPerson person = new InetOrgPerson();
+
+ final Attributes entry = personSearchResult.getAttributes();
+
+ // these are required attributes
+ person.setCn(entry.get("cn").toString());
+ person.setSn(entry.get("sn").toString());
+
+ /*
+ final NamingEnumeration attrs = (NamingEnumeration) entry.getAll();
+ while (attrs.hasMoreElements()) {
+ final List