Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions modules/openldap/README.md
Original file line number Diff line number Diff line change
@@ -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<SrvRecord> findSrvRecord(String service, Protocol protocol, String domainName) throws NamingException, IOException {
final List<SrvRecord> 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<SearchResult> 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
7 changes: 7 additions & 0 deletions modules/openldap/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
description = "Testcontainers :: OpenLdap"

dependencies {
api project(':testcontainers')

testImplementation 'org.assertj:assertj-core:3.26.3'
}
Original file line number Diff line number Diff line change
@@ -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
* <p>
* Implementation Note
* </p>
* <p>
* The parser current fails when the definition includes multiple names or multiple superior object classes.
* The known examples are:
* <ul>
* <li>( 1.3.6.1.4.1.4203.1.4.1 NAME ( 'OpenLDAProotDSE' 'LDAProotDSE' ) DESC 'OpenLDAP Root DSE object' SUP top STRUCTURAL MAY cn )</li>
* <li>( 0.9.2342.19200300.100.4.4 NAME ( 'pilotPerson' 'newPilotPerson' ) SUP person STRUCTURAL MAY ( userid $ textEncodedORAddress $ rfc822Mailbox $ favouriteDrink $ roomNumber $ userClass $ homeTelephoneNumber $ homePostalAddress $ secretary $ personalTitle $ preferredDeliveryMethod $ businessCategory $ janetMailbox $ otherMailbox $ mobileTelephoneNumber $ pagerTelephoneNumber $ organizationalStatus $ mailPreferenceOption $ personalSignature ) )</li>
* <li>( 0.9.2342.19200300.100.4.20 NAME 'pilotOrganization' SUP ( organization $ organizationalUnit ) STRUCTURAL MAY buildingName )</li>
* </ul>
*/

@Data
public class ObjectClassInformation {
private static final Logger LOG = LoggerFactory.getLogger(ObjectClassInformation.class);

private static final String KEYWORD_DESCRIPTION = "DESC";
private static final String KEYWORD_MAY = "MAY";
private static final String KEYWORD_MUST = "MUST";
private static final String KEYWORD_NAME = "NAME";
private static final String KEYWORD_SUP = "SUP";

public enum Type {
ABSTRACT,
STRUCTURAL,
AUXILIARY
};

// this does not match ALL of the objectClass definitions!
private static final Pattern PATTERN = Pattern.compile("^\\( " +
"([0-9\\.]+) " + // required
KEYWORD_NAME + " '([^']+)'" + // required
"( " + KEYWORD_DESCRIPTION + " '([^']+)')?" +
"( " + KEYWORD_SUP + " ([^ ]+))?" +
// " (" + String.join("|", Type.values()) + ")" +
" (ABSTRACT|STRUCTURAL|AUXILIARY)" +
"( " + KEYWORD_MUST + " (([^ (]+)|(\\( ([^)]+)\\))))?" +
"( " + KEYWORD_MAY + " (([^ (]+)|(\\( ([^)]+)\\))))?" +
"(.*)" +
" \\)$");

// remember that Spring#split uses a regex!
private static final String DELIMITER = " \\$ ";

private String oid;

private String name;

private String description;

private String parent;

private Type type;

private List<String> mustValues = new ArrayList<>();

private List<String> mayValues = new ArrayList<>();

public void setType(String type) {
this.type = Type.valueOf(type);
}

/**
* Parse definition from ObjectClass attribute
*
* @param definition
* @return objectClass information
* @throws IllegalArgumentException unsupported definition
*/
public static ObjectClassInformation parse(String definition) {
final ObjectClassInformation info = new ObjectClassInformation();

final Matcher m = PATTERN.matcher(definition);
if (!m.matches()) {
// LOG.info(PATTERN.pattern());
throw new IllegalArgumentException("Unsupported definition: " + definition);
}

info.oid = m.group(1);
info.name = m.group(2);
info.description = m.group(4);
info.parent = m.group(6);
info.setType(m.group(7));

// singleton
if (StringUtils.isNotBlank(m.group(10))) {
info.mustValues.add(m.group(10));
}

// list
if (StringUtils.isNotBlank(m.group(12))) {
for (String element : m.group(12).trim().split(DELIMITER)) {
info.mustValues.add(element.trim());
}
}

// singleton
if (StringUtils.isNotBlank(m.group(15))) {
info.mayValues.add(m.group(15));
}

// list
if (StringUtils.isNotBlank(m.group(17))) {
for (String element : m.group(17).trim().split(DELIMITER)) {
info.mayValues.add(element.trim());
}
}

return info;
}

/**
* Regenerate definition
* @return
*/
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("( ");
sb.append(oid);
sb.append(" ");
sb.append(KEYWORD_NAME);
sb.append(" '");
sb.append(name);
sb.append("' ");
if (StringUtils.isNotBlank(description)) {
sb.append(KEYWORD_DESCRIPTION);
sb.append(" '");
sb.append(description);
sb.append("' ");
}
if (StringUtils.isNotBlank(parent)) {
sb.append(KEYWORD_SUP);
sb.append(" ");
sb.append(parent);
sb.append(" ");
}
sb.append(type);
sb.append(" ");

if (!mustValues.isEmpty()) {
sb.append(KEYWORD_MUST);
sb.append(" ");
if (mustValues.size() == 1) {
sb.append(mustValues.get(0));
} else {
sb.append("( ");
sb.append(String.join(" $ ", mustValues));
sb.append(" )");
}
sb.append(" ");
}

if (!mayValues.isEmpty()) {
sb.append(KEYWORD_MAY);
sb.append(" ");
if (mayValues.size() == 1) {
sb.append(mayValues.get(0));
} else {
sb.append("( ");
sb.append(String.join(" $ ", mayValues));
sb.append(" )");
}
sb.append(" ");
}

sb.append(")");
return sb.toString();
}
}
Loading