Apache Directory Java LDAP API

Il y a 15 ans, le projet Apache Directory a décidé de mettre ses efforts en commun avec Sun pour développer un remplaçant à la librairie JNDI pour tout ce qui concerne LDAP.

logo apache directory

JNDI nous paraissait inadapté, sémantiquement incorrect, et surtout compliqué à utiliser pour toute communication avec un serveur LDAP.

JNDI

Pour la petite histoire, l’interface JNDI a été créée en mars 1997, il y a presque 30 ans ! Et cette interface n’était pas prévue pour supporter uniquement le protocole LDAP, mais également DNS, NIS, CORBA et le système de fichier. Avec un tel spectre d’usage il est normal qu’une telle interface ait dû faire des concessions et s’éloigner de la sémantique LDAP.

Néanmoins, elle offre une API utilisable (et utilisée) qui ne nécessite pas l’inclusion de dépendance externe.

Les alternatives

Rapidement sont apparues des alternatives beaucoup plus proche de LDAP, comme jLDAP (développée par NOVELL) et surtout Netscape Directory SDK for Java. Ces API ont été spécifiquement conçues pour accéder à un serveur LDAP, dont elles respectent la sémantique.

Mais elles sont également anciennes:

  • jLDAP a été développé à partir de novembre 2000, et la dernière version à jour date d’octobre 2007
  • NSDK a été développé en 1998, et n’est plus maintenu depuis la version 4.17 qui date de 2005

Le besoin

En 2006, le projet Apache Directory Server avait 3 ans, venait de sortir de l’incubation, et la version 1.0 venait de sortir. Lors de la conférence Apache à Austin, nous avions rencontré les développeurs de Sun Microsystems (avant que Sun ne soit racheté par Oracle), et nous avions appris qu’une équipe avait démarré le développement d’un successeur à Sun DS, en Java (produit qui deviendra OpenDJ, mais c’est une autre histoire…).

Lors d’un meeting, nous avions discuté du besoin d’une API pour remplacer JNDI, et notre souhait qu’elle soit plus moderne, avec une sémantique claire, et si possible qu’elle devienne l’implémentation Java par défaut. Et nous avons commencé les travaux.

Le résultat de plusieurs mois de travail a été présenté en Septembre 2009 lors de la conférence LDAP à Portland, en collaboration avec Ludovic Poitou (qui avait alors quitté Sun).

Ce qui avait guidé nos choix avait été le développement des deux serveurs ApacheDS et OpenDJ, le développement en cours de Apache Directory Studio, qui utilisaient tous d’une manière ou d’une autre une API LDAP (en ce qui concerne ApacheDS et Apache Directory Studio, nous nous basions sur JNDI et sur Netscape SDK pour les tests). Nous avons donc été les premiers ‘testeurs’ de l’API LDAP que nous développions, ce qui nous a permis de sortir une version 1.0 fonctionnelle et testée en 2011.

Il s’est quand même déroulé près de 6 ans entre la toute première version (Apache LDAP API 1.0.0-M1, publiée en février 2011) et la première version 1.0 officielle (Apache LDAP API 1.0.0, publiée en juin 2017). Et 33 versions intermédiaires, plus 2 releases candidates ! La raison ? 276 tickets à traiter, l’ajout de nombreuses extensions (Extended operations, controls, etc) et une recherche de performance et d’une qualité minimale.

Les spécificités

Les fonctionnalités attendues étaient les suivantes :

  • avoir une API facile à utiliser
  • s’appuyant sur des mécanismes synchrones et asynchrones
  • qui soit capable de manipuler les données avec la connaissance du schéma des serveurs
  • bénéficiant des nouvelles fonctionnalités de Java 5 (génériques, NIO, etc.)
  • proposant une manipulation des formats LDIF et DSML
  • extensible (ajout de nouveaux contrôles et opérations étendues)
  • et performante !

Pourquoi JNDI ne convient pas ?

Comme il est précisé plus haut, JNDI n’est pas une API dédiée à LDAP. La première chose qui saute au yeux, c’est l’inadéquation de sa sémantique avec celle de LDAP. Par exemple, pour créer une entrée dans un serveur LDAP, il faut appeler la fonction bind()! De même pour en supprimer, il faut appeler la fonction unbind(). Mais il est aussi possible de créer ou supprimer une entrée en utilisant les fonctions createSubcontext() et destroySubcontext()… L’opération LDAP Compare n’existe pas en JNDI, il faut utiliser la fonction Search(). L’opération ModDn n’existe pas non plus, il faut utiliser la fonction rename().

La création d’entrées avec plusieurs attributs est rendue fastidieuse par l’impossibilité d’utiliser la construction Java elipsis (‘…’), ce qui oblige à appeler autant de fois que nécessaire les méthodes Attribute.add() et Attributes.add() qu’il y a d’attributs et de valeurs, ce qui est très contraignant et verbeux.

Bien sûr, les éléments manipulés (entrées, noms) ne sont pas contraints par un schéma LDAP, donc il est possible d’effectuer des opérations invalides. Par exemple, si un attribut est déclaré comme non sensible à la casse, vous pourriez vous trouver à faire des comparaisons invalides si la valeur récupérée n’est pas dans la même casse que la valeur testée: cn=test et cn=TEST sont équivalent en LDAP, comparer les valeurs avec “test”.equals( “TEST” ) en java ne donne pas une égalité. Et cela va plus loin: cn, CN, CommonName et 2.5.4.3 représentent le même attribut en LDAP…

Mais une des limitations les plus pénibles de JNDI, c’est la façon dont sont gérées les connexions. Typiquement, la fermeture d’une connexion ne va pas entraîner la fermeture de toutes les NamingEnumeration déclarées lors des recherches : la connexion restera à l’état ouverte, en consommant les ressources associées, tant que toutes les énumérations et les contextes n’ont pas été explicitement fermés. Cela entraîne de fâcheux effets de bord avec saturation de la mémoire si ce nettoyage n’est pas effectué précautionneusement.

En clair, cette librairie est fonctionnelle, mais n’est pas pratique.

Vers une API purement LDAP

En ayant connaissance de ces points, il est assez clair qu’il est intéressant de construire une API qui soit réellement dédiée LDAP, avec une sémantique claire, des manipulation d’objets simplifiés, une richesse fonctionnelle élevée, des points d’extensions, un support des schémas LDAP et s’appuyant sur les évolutions du langage Java.

apache ldap api

Mais ce n’est pas simple pour autant!

La structure de l’API

Il y a différents blocs fonctionnels à développer :

  • Les objets de base (Entry, Attribute, value, Modification) qui sont utilisés pour représenter les données stockées dans la base LDAP
  • Les noms (DN, RDN, AVA, AttributeType) qui permettent de manipuler les DNs (Distinguished Name, “dc=worteks,dc=com” par exemple), les RDNs (Reduced DN, “dc=worteks” dans le DN précédent), les AVAs (AttributeType and Value)
  • Les opérations, au nombre de 11 (Abandon, Add, Bind, Compare, Delete, Extended, Intermediate, Modify, ModDN, Search et Unbind) classées en deux catégories, requêtes et réponses, ainsi que les éléments associés (Ldapresult, Referral)
  • Les points d’extension (Control et ExtendedOperation)
  • Le schéma et les objets associés : AttributeType(AT), Comparator(C), DitContentRule(DCR), DitStructureRule(DSR), MatchingRule(MR), MatchingRuleUse(MRU), NameForm(NF), Normalizer(N), ObjectClass(OC), Syntax(S), SyntaxChecker(SC). Le schéma suivant représente les relations entre chacun de ces éléments :

illustration LDAP Schema

  • Les filtres de recherche (And, Approximate, Assertion, Equality, Extensible, GreaterEq, LessEq, Not, Or, Presence, Substring)
  • Les opérations de parcours de résultat de recherche, les Cursors
  • La manipulation du format LDIF
  • La manipulation du format DSML
  • Les connections, les opérations asynchrones, la sécurité (TLS, SASL, startTLS)
  • La gestion du protocole LDAP et notamment le codage ASN/1
  • Et enfin la couche réseau…

Au final, l’API est assez chargée, et cela représente beaucoup de travail. Pour donner un ordre d’idée, l’API LDAP d’Apache représente 214 000 lignes de code (commentaires et lignes vide exclus) et plus de 4000 tests unitaires !

Quelques exemples

Dans les exemples suivant, nous mettrons en parallèle le code JNDI et le code de l’API LDAP Apache, pour montrer la différence entre ces deux bibliothèques.

Connexion

Commençons par la création d’une connexion. Cela se fait en une ligne :

    LdapConnection connection = new LdapNetworkConnection( "localhost", 10389 );

L’équivalent en JNDI :

    Hashtable<String, String> environment = new Hashtable<String, String>();

    environment.put(Context.INITIAL_CONTEXT_FACTORY, 
        "com.sun.jndi.ldap.LdapCtxFactory");
    environment.put(Context.PROVIDER_URL, "ldap://localhost:10389")
    DirContext context = new InitialDirContext(environment);

Ici, on ouvre une connexion sur un serveur qui tourne en local sur le port 10389. Il suffit d’une ligne avec l’API Apache, quand 4 lignes sont nécessaires en JNDI. À ce stade, on a juste ouvert la connexion, sans s’authentifier. Voici maintenant un exemple avec un Simple Bind (authentification avec user/password, en clair):

    LdapConnection connection = new LdapNetworkConnection( "localhost", 10389 );
    connection.bind( "uid=admin,ou=system", "secret" );

En JNDI, on doit rajouter les informations d’authentification dans l’environnement de création de contexte:

    Hashtable<String, String> environment = new Hashtable<String, String>();

    environment.put(Context.INITIAL_CONTEXT_FACTORY, 
        "com.sun.jndi.ldap.LdapCtxFactory");
    environment.put(Context.PROVIDER_URL, "ldap://localhost:10389")
    environment.put(Context.SECURITY_AUTHENTICATION, "simple");
    environment.put(Context.SECURITY_PRINCIPAL, "uid=admin,ou=system");
    environment.put(Context.SECURITY_CREDENTIALS, "secret");

    DirContext context = new InitialDirContext(environment);

Recherche

On va se contenter de faire une recherche simple sur la base dans un premier temps.

Avec l’API LDAP Apache, le code ressemble à cela :

try (EntryCursor cursor = connection.search( "ou=system", 
        "(objectClass=organizationalUnit)", SearchScope.ONELEVEL, "*" )) {
    while ( cursor.next() ) {
        Entry entry = cursor.get();
        System.out.println(entry);
    }
}

À noter : le cursor étend l’interface Closable, ce qui permet d’utiliser la syntaxe try-with-resource: le cursor sera automatiquement fermé en sortant du try.

Le résultat sera :

Entry
dn[n]: ou=users,ou=system
objectClass: top
objectClass: organizationalUnit
ou: users

Entry
dn[n]: prefNodeName=sysPrefRoot,ou=system
objectClass: top
objectClass: organizationalUnit
objectClass: extensibleObject
prefNodeName: sysPrefRoot

Entry
dn[n]: ou=groups,ou=system
objectClass: top
objectClass: organizationalUnit
ou: groups

Entry
dn[n]: ou=configuration,ou=system
objectClass: top
objectClass: organizationalUnit
ou: configuration

On n’a récupéré que les attributs applicatifs (paramètre ‘*’) pour les entrées dont l’attribut ObjectClass est ’organizationalUnit’, parmi tous les fils de l’entrée ’ou=system’.

En JNDI, c’est un petit peu plus complexe :

SearchControls searchControls = new SearchControls();
searchControls.setSearchScope( SearchControls.ONELEVEL_SCOPE );
searchControls.setReturningAttributes(new String[] { "*" });

NamingEnumeration<SearchResult> searchResults = ctx.search( "ou=system", "(objectClass=organizationalUnit)", searchControls );

while ( searchResults.hasMore() ) {
    SearchResult result = searchResults.next();
    System.out.println(result);
}

searchResults.close();

On constate que l’on doit déjà créer un objet spécifique pour définir le niveau de recherche, puis donner la liste des attributs que l’on souhaite récupérer, avant de lancer la recherche. On récupère une énumération sur laquelle on doit itérer.

Petit détail: il nous faut fermer cette énumération pour libérer les resources associées, puisque la classe NamingEnumeration n’implémente pas l’interface Closeable.

Le résultat se présente sous cette forme :

ou=groups: null:null:{ou=ou: groups, objectclass=objectClass: top, organizationalUnit}
prefNodeName=sysPrefRoot: null:null:{objectclass=objectClass: top, organizationalUnit, extensibleObject, prefnodename=prefNodeName: sysPrefRoot}
ou=users: null:null:{ou=ou: users, objectclass=objectClass: top, organizationalUnit}
ou=configuration: null:null:{ou=ou: configuration, objectclass=objectClass: top, organizationalUnit}

C’est clairement moins lisible…

Ajout d’entrées

Avec l’API LDAP Apache, il est possible d’ajouter des entrées en injectant une chaîne de caractères au format LDIF :

connection.add(
    new DefaultEntry(
        "cn=testadd,ou=system",
        "ObjectClass : top",
        "ObjectClass : person",
        "cn: testadd_sn",
        "sn: testadd_sn"
) );

En Java 15 et supérieur, cela peut même s’écrire :

connection.add(
    new DefaultEntry( """
        cn=testadd,ou=system
        ObjectClass : top
        ObjectClass : person
        cn: testadd
        sn: testadd"""
) );

En JNDI, le code ressemble à cela :

    Attributes attrs = new BasicAttributes(true);
    
    attrs.put(new BasicAttribute("objectclass", "top"));
    attrs.put(new BasicAttribute("objectclass", "person"));
    attrs.put(new BasicAttribute("cn", "testadd"));
    attrs.put(new BasicAttribute("sn", "testadd"));

    dirContext.bind("cn=testadd", dirContext, attrs);

où la variable dirContext pointe sur l’entrée ‘ou=system’.

Autres fonctionnalités

Sans entrer dans le détail, une API LDAP en Java comme celle d’Apache Directory dispose des fonctionnalités suivantes :

  • Chaque opération peut être lancée en asynchrone. Un objet Future sera retourné, qu’il sera possible d’interroger plus tard pour en récupérer le résultat.
  • L’ensemble des objets LDAP manipulés peut être associé au schéma récupéré sur le serveur, ce qui permet des comparaisons qui tiennent compte des spécificités de chaque attribut.
  • De nombreuses fonctions de manipulation des noms LDAP (DN, RDN)
  • Support de nombreux contrôles et opérations étendues

Pour conclure

L’API LDAP développée par la fondation Apache est un travail collaboratif d’une vingtaine de personnes, qui s’étend sur bientôt 20 ans. Cette API est maintenue, avec plus de 50 releases (la dernière en août 2024) et stable.

Elle est assez largement utilisée, avec près de 300 000 downloads chaque mois sur le repository Maven.

Elle est également à la base de Apache Directory Server, et la librairie par défaut de Apache Directory Studio dans ses dernières versions (où elle a remplacé JNDI).