changeset 809:1d51c5e38faa

Add LDAP plugin suite
author rob@hoelz.ro
date Sun, 02 Sep 2012 15:35:50 +0200
parents ba2e207e1fb7
children 464ed063a8f2 1f45cef9e5c7
files mod_auth_ldap2/mod_auth_ldap.lua mod_lib_ldap/README.md mod_lib_ldap/dev/README.md mod_lib_ldap/dev/TODO.md mod_lib_ldap/dev/posix-users.ldif mod_lib_ldap/dev/prosody-posix-ldap.cfg.lua mod_lib_ldap/dev/slapd.conf mod_lib_ldap/dev/t/00-login.t mod_lib_ldap/dev/t/01-rosters.t mod_lib_ldap/dev/t/02-vcard.t mod_lib_ldap/dev/t/TestConnection.pm mod_lib_ldap/dev/t/XMPP/TestUtils.pm mod_lib_ldap/ldap.lib.lua mod_storage_ldap/ldap/vcard.lib.lua mod_storage_ldap/mod_storage_ldap.lua
diffstat 15 files changed, 1288 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_auth_ldap2/mod_auth_ldap.lua	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,84 @@
+-- vim:sts=4 sw=4
+
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2012 Rob Hoelz
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- http://code.google.com/p/prosody-modules/source/browse/mod_auth_ldap/mod_auth_ldap.lua
+-- adapted to use common LDAP store
+
+local ldap     = module:require 'ldap';
+local new_sasl = require 'util.sasl'.new;
+local nodeprep = require 'util.encodings'.stringprep.nodeprep;
+local jsplit   = require 'util.jid'.split;
+
+if not ldap then
+    return;
+end
+
+local provider = { name = 'ldap' }
+
+function provider.test_password(username, password)
+    return ldap.bind(username, password);
+end
+
+function provider.user_exists(username)
+    local params = ldap.getparams()
+
+    local filter = ldap.filter.combine_and(params.user.filter, params.user.usernamefield .. '=' .. username);
+
+    return ldap.singlematch {
+        base   = params.user.basedn,
+        filter = filter,
+    };
+end
+
+function provider.get_password(username)
+    return nil, "Passwords unavailable for LDAP.";
+end
+
+function provider.set_password(username, password)
+    return nil, "Passwords unavailable for LDAP.";
+end
+
+function provider.create_user(username, password)
+    return nil, "Account creation/modification not available with LDAP.";
+end
+
+function provider.get_sasl_handler()
+    local testpass_authentication_profile = {
+        plain_test = function(sasl, username, password, realm)
+            local prepped_username = nodeprep(username);
+            if not prepped_username then
+                module:log("debug", "NODEprep failed on username: %s", username);
+                return "", nil;
+            end
+            return provider.test_password(prepped_username, password), true;
+        end,
+        mechanisms = { PLAIN = true },
+    };
+    return new_sasl(module.host, testpass_authentication_profile);
+end
+
+function provider.is_admin(jid)
+    local admin_config = ldap.getparams().admin;
+
+    if not admin_config then
+        return;
+    end
+
+    local ld       = ldap:getconnection();
+    local username = jsplit(jid);
+    local filter   = ldap.filter.combine_and(admin_config.filter, admin_config.namefield .. '=' .. username);
+
+    return ldap.singlematch {
+        base   = admin_config.basedn,
+        filter = filter,
+    };
+end
+
+module:add_item("auth-provider", provider);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/README.md	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,151 @@
+# LDAP plugin suite for Prosody
+
+The LDAP plugin suite includes an authentication plugin (mod\_auth\_ldap2) and storage plugin
+(mod\_storage\_ldap) to query against an LDAP server.  It also provides a plugin library (mod\_lib\_ldap)
+for accessing an LDAP server to make writing other LDAP-based plugins easier in the future.
+
+# LDAP Authentication
+
+**NOTE**: LDAP authentication currently only works with plaintext auth!  If this isn't ok
+with you, don't use it! (Or better yet, fix it =) )
+
+With that note in mind, you need to set 'allow\_unencrypted\_plain\_auth' to true in your configuration if
+you want to use LDAP authentication.
+
+To enable LDAP authentication, set 'authentication' to 'ldap' in your configuration file.
+See also http://prosody.im/doc/authentication.
+
+# LDAP Storage
+
+LDAP storage is currently read-only, and it only supports rosters and vCards.
+
+To enable LDAP storage, set 'storage' to 'ldap' in your configuration file.
+See also http://prosody.im/doc/storage.
+
+# LDAP Configuration
+
+All of the LDAP-specific configuration for the plugin set goes into an 'ldap' section
+in the configuration.  You must set the 'hostname' field in the 'ldap' section to
+your LDAP server's location (a custom port is also accepted, so I guess it's not strictly
+a hostname).  The 'bind\_dn' and 'bind\_password' are optional if you want to bind as
+a specific DN.  There should be an example configuration included with this README, so
+feel free to consult that.
+
+## The user section
+
+The user section must contain the following keys:
+
+  * basedn - The base DN against which to base your LDAP queries for users.
+  * filter - An LDAP filter expression that matches users.
+  * usernamefield - The name of the attribute in an LDAP entry that contains the username.
+  * namefield - The name of the attribute in an LDAP entry that contains the user's real name.
+
+## The groups section
+
+The LDAP plugin suite has support for grouping (ala mod\_groups), which can be enabled via the groups
+section in the ldap section of the configuration file.  Currently, you must have at least one group.
+The groups section must contain the following keys:
+
+  * basedn - The base DN against which to base your LDAP queries for groups.
+  * memberfield - The name of the attribute in an LDAP entry that contains a list of a group's members. The contents of this field
+                  must match usernamefield in the user section.
+  * namefield - The name of the attribute in an LDAP entry that contains the group's name.
+
+The groups section must contain at least one entry in its array section.  Each entry must be a table, with the following keys:
+
+  * name - The name of the group that will be presented in the roster.
+  * $namefield (whatever namefield is set to is the name) - An attribute pair to match this group against.
+  * admin (optional) - whether or not this group's members are admins.
+
+## The vcard\_format section
+
+The vcard\_format section is used to generate a vCard given an LDAP entry.  See http://xmpp.org/extensions/xep-0054.html for
+more information.  The JABBERID field is automatically populated.
+
+The key/value pairs in this table fall into three categories:
+
+### Simple pairs
+
+Some values in the vcard\_format table are simple key-value pairs, where the key corresponds to a vCard
+entry, and the value corresponds to the attribute name in the LDAP entry for the user.  The fields that
+be configured this way are:
+
+  * displayname - corresponds to FN
+  * nickname - corresponds to NICKNAME
+  * birthday - corresponds to BDAY
+  * mailer - corresponds to MAILER
+  * timezone - corresponds to TZ
+  * title - corresponds to TITLE
+  * role - corresponds to ROLE
+  * note - corresponds to NOTE
+  * rev - corresponds to REV
+  * sortstring - corresponds to SORT-STRING
+  * uid - corresponds to UID
+  * url - corresponds to URL
+  * description - corresponds to DESC
+
+### Single-level fields
+
+These pairs have a table as their values, and the table itself has a series of key value pairs that are translated
+similarly to simple pairs.  The fields that are configured this way are:
+
+  * name - corresponds to N
+    * family - corresponds to FAMILY
+    * given - corresponds toGIVEN
+    * middle - corresponds toMIDDLE
+    * prefix - corresponds toPREFIX
+    * suffix - corresponds toSUFFIX
+  * photo - corresponds to PHOTO
+    * type - corresponds to TYPE
+    * binval - corresponds to BINVAL
+    * extval - corresponds to EXTVAL
+  * geo - corresponds to GEO
+    * lat - corresponds to LAT
+    * lon - corresponds to LON
+  * logo - corresponds to LOGO
+    * type - corresponds to TYPE
+    * binval - corresponds to BINVAL
+    * extval - corresponds to EXTVAL
+  * org - corresponds to ORG
+    * orgname - corresponds to ORGNAME
+    * orgunit - corresponds to ORGUNIT
+  * sound - corresponds to SOUND
+    * phonetic - corresponds to PHONETIC
+    * binval - corresponds to BINVAL
+    * extval - corresponds to EXTVAL
+  * key - corresponds to KEY
+    * type - corresponds to TYPE
+    * cred - corresponds to CRED
+
+### Multi-level fields
+
+These pairs have a table as their values, and each table itself has tables as its values.  The nested tables have
+the same key-value pairs you're used to, the only difference being that values may have a boolean as their type, which
+converts them into an empty XML tag.  I recommend looking at the example configuration for clarification.
+
+  * address - ADR
+  * telephone - TEL
+  * email - EMAIL
+
+### Unsupported vCard fields
+
+  * LABEL
+  * AGENT
+  * CATEGORIES
+  * PRODID
+  * CLASS
+
+### Example Configuration
+
+You can find an example configuration in the dev directory underneath the
+directory that this file is located in.
+
+# Missing Features
+
+This set of plugins is missing a few features, some of which are really just ideas:
+
+  * Implement non-plaintext authentication.
+  * Use proper LDAP binding (LuaLDAP must be patched with http://prosody.im/patches/lualdap.patch, though)
+  * Non-hardcoded LDAP groups (derive groups from LDAP queries)
+  * LDAP-based MUCs (like a private MUC per group, or something)
+  * This suite of plugins was developed with a POSIX-style setup in mind; YMMV. Patches to work with other setups are welcome!
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/dev/README.md	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,14 @@
+Developer Utilities/Tests
+=========================
+
+This directory exists for reasons of sanity checking.  If you wish
+to run the tests, set up Prosody as you normally would, and install the LDAP
+modules as normal as well.  Set up OpenLDAP using the configuration directory
+found in this directory (slapd.conf), and run the following command to import
+the test definitions into the LDAP server:
+
+    ldapadd -x -w prosody -D 'cn=Manager,dc=example,dc=com' -f posix-users.ldif
+
+Then just run prove (you will need perl and AnyEvent::XMPP installed):
+
+    prove t
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/dev/TODO.md	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,2 @@
+  * Make groups optional
+  * Make groups work with both posixGroup and groupOfNames
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/dev/posix-users.ldif	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,92 @@
+# This is an LDIF file containing simple user definitions for a POSIX-style LDAP
+# setup.
+
+dn: dc=example,dc=com
+objectclass: dcObject
+objectclass: organization
+o: Example
+dc: example
+
+dn: cn=Manager,dc=example,dc=com
+objectclass: organizationalRole
+cn: Manager
+
+dn: ou=Groups,dc=example,dc=com
+ou: Groups
+objectclass: organizationalUnit
+
+dn: ou=Users,dc=example,dc=com
+ou: Users
+objectclass: organizationalUnit
+
+dn: uid=one,ou=Users,dc=example,dc=com
+objectclass: posixAccount
+objectclass: person
+uid: one
+uidNumber: 1000
+gidNumber: 1000
+sn: Testerson
+cn: John Testerson
+userPassword: 12345
+homeDirectory: /home/one
+
+dn: uid=two,ou=Users,dc=example,dc=com
+objectclass: posixAccount
+objectclass: person
+uid: two
+uidNumber: 1001
+gidNumber: 1001
+sn: Testerson
+cn: Jane Testerson
+userPassword: 23451
+homeDirectory: /home/two
+
+dn: uid=three,ou=Users,dc=example,dc=com
+objectclass: posixAccount
+objectclass: person
+uid: three
+uidNumber: 1002
+gidNumber: 1002
+sn: Testerson
+cn: Jerry Testerson
+userPassword: 34512
+homeDirectory: /home/three
+
+dn: uid=four,ou=Users,dc=example,dc=com
+objectclass: posixAccount
+objectclass: person
+uid: four
+uidNumber: 1003
+gidNumber: 1003
+sn: Testerson
+cn: Jack Testerson
+userPassword: 45123
+homeDirectory: /home/four
+
+dn: uid=five,ou=Users,dc=example,dc=com
+objectclass: posixAccount
+objectclass: person
+uid: five
+uidNumber: 1004
+gidNumber: 1004
+sn: Testerson
+cn: Jimmy Testerson
+userPassword: 51234
+homeDirectory: /home/five
+
+dn: cn=Everyone,ou=Groups,dc=example,dc=com
+objectclass: posixGroup
+cn: Everyone
+gidNumber: 2000
+memberUid: one
+memberUid: two
+memberUid: three
+memberUid: four
+memberUid: five
+
+dn: cn=Admin,ou=Groups,dc=example,dc=com
+objectclass: posixGroup
+cn: Admin
+gidNumber: 2001
+memberUid: one
+memberUid: two
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/dev/prosody-posix-ldap.cfg.lua	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,38 @@
+-- Use Include 'prosody-posix-ldap.cfg.lua' from prosody.cfg.lua to include this file
+authentication = 'ldap' -- Indicate that we want to use LDAP for authentication
+storage        = 'ldap' -- Indicate that we want to use LDAP for roster/vcard storage
+
+ldap = {
+    hostname      = 'localhost',                    -- LDAP server location
+    bind_dn       = 'cn=Manager,dc=example,dc=com', -- Bind DN for LDAP authentication (optional if anonymous bind is supported)
+    bind_password = 'prosody',                      -- Bind password (optional if anonymous bind is supported)
+
+    user = {
+      basedn        = 'ou=Users,dc=example,dc=com', -- The base DN where user records can be found
+      filter        = 'objectClass=posixAccount',   -- Filter expression to find user records under basedn
+      usernamefield = 'uid',                        -- The field that contains the user's ID (this will be the username portion of the JID)
+      namefield     = 'cn',                         -- The field that contains the user's full name (this will be the alias found in the roster)
+    },
+
+    groups = {
+      basedn      = 'ou=Groups,dc=example,dc=com', -- The base DN where group records can be found
+      memberfield = 'memberUid',                   -- The field that contains user ID records for this group (each member must have a corresponding entry under the user basedn with the same value in usernamefield)
+      namefield   = 'cn',                          -- The field that contains the group's name (used for matching groups in LDAP to group definitions below)
+
+      {
+        name  = 'everyone', -- The group name that will be seen in users' rosters
+        cn    = 'Everyone', -- This field's key *must* match ldap.groups.namefield! It's the name of the LDAP group this definition represents
+        admin = false,      -- (Optional) A boolean flag that indicates whether members of this group should be considered administrators.
+      },
+      {
+        name  = 'admin',
+        cn    = 'Admin',
+        admin = true,
+      },
+    },
+
+    vcard_format = {
+      displayname = 'cn', -- Consult the vCard configuration section in the README
+      nickname    = 'uid',
+    },
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/dev/slapd.conf	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,14 @@
+include		/etc/openldap/schema/core.schema
+# I needed the following two schema definitions for posixGroup; if you don't
+# need it, don't include them
+include         /etc/openldap/schema/cosine.schema
+include         /etc/openldap/schema/nis.schema
+
+pidfile		/var/run/openldap/slapd.pid
+argsfile	/var/run/openldap/slapd.args
+database	bdb
+suffix		"dc=example,dc=com"
+rootdn		"cn=Manager,dc=example,dc=com"
+rootpw		prosody
+directory	/var/lib/openldap/openldap-data
+index	objectClass	eq
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/dev/t/00-login.t	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,49 @@
+use strict;
+use warnings;
+use lib 't';
+
+use TestConnection;
+use Test::More;
+
+my @users = (
+    'one',
+    'two',
+    'three',
+    'four',
+    'five',
+);
+
+plan tests => scalar(@users) + 2;
+
+foreach my $username (@users) {
+    my $conn = TestConnection->new($username);
+
+    $conn->reg_cb(session_ready => sub {
+        $conn->cond->send;
+    });
+
+    my $error = $conn->cond->recv;
+    ok(! $error) or diag($error);
+}
+
+do {
+    my $conn = TestConnection->new('one', password => '23451');
+
+    $conn->reg_cb(session_ready => sub {
+        $conn->cond->send;
+    });
+
+    my $error = $conn->cond->recv;
+    ok($error);
+};
+
+do {
+    my $conn = TestConnection->new('six', password => '12345');
+
+    $conn->reg_cb(session_ready => sub {
+        $conn->cond->send;
+    });
+
+    my $error = $conn->cond->recv;
+    ok($error);
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/dev/t/01-rosters.t	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,136 @@
+use strict;
+use warnings;
+use lib 't';
+
+use AnyEvent::XMPP::Util qw(split_jid);
+use TestConnection;
+use Test::More;
+
+sub test_roster {
+    my ( $username, $expected_contacts ) = @_;
+
+    local $Test::Builder::Level = $Test::Builder::Level + 1;
+    my @contacts;
+
+    my $conn = TestConnection->new($username);
+
+    $conn->reg_cb(roster_update => sub {
+        my ( undef, $roster ) = @_;
+
+        @contacts = sort { $a->{'username'} cmp $b->{'username'} } map {
+            +{
+                username     => (split_jid($_->jid))[0],
+                name         => $_->name,
+                groups       => [ sort $_->groups ],
+                subscription => $_->subscription,
+            }
+        } $roster->get_contacts;
+        $conn->cond->send;
+    });
+
+    my $error = $conn->cond->recv;
+
+    if($error) {
+        fail($error);
+        return;
+    }
+    @$expected_contacts = sort { $a->{'username'} cmp $b->{'username'} }
+        @$expected_contacts;
+    foreach my $contact (@$expected_contacts) {
+        $contact->{'subscription'} = 'both';
+        @{ $contact->{'groups'} } = sort @{ $contact->{'groups'} };
+    }
+    is_deeply(\@contacts, $expected_contacts);
+}
+
+plan tests => 5;
+
+test_roster(one => [{
+    username => 'two',
+    name     => 'Jane Testerson',
+    groups   => ['everyone', 'admin'],
+}, {
+    username => 'three',
+    name     => 'Jerry Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'four',
+    name     => 'Jack Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'five',
+    name     => 'Jimmy Testerson',
+    groups   => ['everyone'],
+}]);
+
+test_roster(two => [{
+    username => 'one',
+    name     => 'John Testerson',
+    groups   => ['everyone', 'admin'],
+}, {
+    username => 'three',
+    name     => 'Jerry Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'four',
+    name     => 'Jack Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'five',
+    name     => 'Jimmy Testerson',
+    groups   => ['everyone'],
+}]);
+
+test_roster(three => [{
+    username => 'one',
+    name     => 'John Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'two',
+    name     => 'Jane Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'four',
+    name     => 'Jack Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'five',
+    name     => 'Jimmy Testerson',
+    groups   => ['everyone'],
+}]);
+
+test_roster(four => [{
+    username => 'one',
+    name     => 'John Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'two',
+    name     => 'Jane Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'three',
+    name     => 'Jerry Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'five',
+    name     => 'Jimmy Testerson',
+    groups   => ['everyone'],
+}]);
+
+test_roster(five => [{
+    username => 'one',
+    name     => 'John Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'two',
+    name     => 'Jane Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'three',
+    name     => 'Jerry Testerson',
+    groups   => ['everyone'],
+}, {
+    username => 'four',
+    name     => 'Jack Testerson',
+    groups   => ['everyone'],
+}]);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/dev/t/02-vcard.t	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,87 @@
+use strict;
+use warnings;
+use lib 't';
+
+use TestConnection;
+use AnyEvent::XMPP::Ext::VCard;
+use Test::More;
+
+sub test_vcard {
+    my ( $username, $expected_fields ) = @_;
+
+    $expected_fields->{'JABBERID'} = $username . '@' . $TestConnection::HOST;
+    $expected_fields->{'VERSION'}  = '2.0';
+
+    my $conn  = TestConnection->new($username);
+    my $vcard = AnyEvent::XMPP::Ext::VCard->new;
+
+    local $Test::Builder::Level = $Test::Builder::Level + 1;
+
+    $conn->reg_cb(stream_ready => sub {
+        $vcard->hook_on($conn);
+    });
+
+    $conn->reg_cb(session_ready => sub {
+        $vcard->retrieve($conn, undef, sub {
+            my ( $jid, $vcard, $error ) = @_;
+
+            if(eval { $vcard->isa('AnyEvent::XMPP::Error') }) {
+                $error = $vcard;
+            }
+
+            if($error) {
+                $conn->cond->send($error->string);
+                return;
+            }
+
+            foreach my $key (keys %$vcard) {
+                my $value = $vcard->{$key};
+
+                $value = $value->[0];
+
+                if($value eq '') {
+                    delete $vcard->{$key};
+                } else {
+                    $vcard->{$key} = $value;
+                }
+            }
+
+            is_deeply $expected_fields, $vcard or diag(explain($vcard));
+            $conn->cond->send;
+        });
+    });
+
+    my $error = $conn->cond->recv;
+
+    if($error) {
+        fail($error);
+        return;
+    }
+}
+
+plan tests => 5;
+
+test_vcard(one => {
+    FN       => 'John Testerson',
+    NICKNAME => 'one',
+});
+
+test_vcard(two => {
+    FN       => 'Jane Testerson',
+    NICKNAME => 'two',
+});
+
+test_vcard(three => {
+    FN       => 'Jerry Testerson',
+    NICKNAME => 'three',
+});
+
+test_vcard(four => {
+    FN       => 'Jack Testerson',
+    NICKNAME => 'four',
+});
+
+test_vcard(five => {
+    FN       => 'Jimmy Testerson',
+    NICKNAME => 'five',
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/dev/t/TestConnection.pm	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,58 @@
+package TestConnection;
+
+use strict;
+use warnings;
+use parent 'AnyEvent::XMPP::IM::Connection';
+
+use 5.010;
+
+our $HOST         = 'localhost';
+our $TIMEOUT      = 5;
+our %PASSWORD_FOR = (
+    one   => '12345',
+    two   => '23451',
+    three => '34512',
+    four  => '45123',
+    five  => '51234',
+);
+
+sub new {
+    my ( $class, $username, %options ) = @_;
+
+    my $cond  = AnyEvent->condvar;
+    my $timer = AnyEvent->timer(
+        after => $TIMEOUT,
+        cb    => sub {
+            $cond->send('timeout');
+        },
+    );
+
+    my $self = $class->SUPER::new(
+        username => $username,
+        domain   => $HOST,
+        password => $options{'password'} // $PASSWORD_FOR{$username},
+    );
+
+    $self->reg_cb(error => sub {
+        my ( undef, $error ) = @_;
+
+        $cond->send($error->string);
+    });
+
+    bless $self, $class;
+
+    $self->{'condvar'}       = $cond;
+    $self->{'timeout_timer'} = $timer;
+
+    $self->connect;
+
+    return $self;
+}
+
+sub cond {
+    my ( $self ) = @_;
+
+    return $self->{'condvar'};
+}
+
+1;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/dev/t/XMPP/TestUtils.pm	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,6 @@
+package XMPP::TestUtils;
+
+use strict;
+use warnings;
+
+1;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_lib_ldap/ldap.lib.lua	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,246 @@
+-- vim:sts=4 sw=4
+
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2012 Rob Hoelz
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local ldap;
+local connection;
+local params  = module:get_option("ldap");
+local format  = string.format;
+local tconcat = table.concat;
+
+local _M = {};
+
+local config_params = {
+    hostname = 'string',
+    user     = {
+        basedn        = 'string',
+        namefield     = 'string',
+        filter        = 'string',
+        usernamefield = 'string',
+    },
+    groups   = {
+        basedn      = 'string',
+        namefield   = 'string',
+        memberfield = 'string',
+
+        _member = {
+          name  = 'string',
+          admin = 'boolean?',
+        },
+    },
+    admin    = {
+        _optional = true,
+        basedn    = 'string',
+        namefield = 'string',
+        filter    = 'string',
+    }
+}
+
+local function run_validation(params, config, prefix)
+    prefix = prefix or '';
+
+    -- verify that every required member of config is present in params
+    for k, v in pairs(config) do
+        if type(k) == 'string' and k:sub(1, 1) ~= '_' then
+            local is_optional;
+            if type(v) == 'table' then
+                is_optional = v._optional;
+            else
+                is_optional = v:sub(-1) == '?';
+            end
+
+            if not is_optional and params[k] == nil then
+                return nil, prefix .. k .. ' is required';
+            end
+        end
+    end
+
+    for k, v in pairs(params) do
+        local expected_type = config[k];
+
+        local ok, err = true;
+
+        if type(k) == 'string' then
+            -- verify that this key is present in config
+            if k:sub(1, 1) == '_' or expected_type == nil then
+                return nil, 'invalid parameter ' .. prefix .. k;
+            end
+
+            -- type validation
+            if type(expected_type) == 'string' then
+                if expected_type:sub(-1) == '?' then
+                    expected_type = expected_type:sub(1, -2);
+                end
+
+                if type(v) ~= expected_type then
+                    return nil, 'invalid type for parameter ' .. prefix .. k;
+                end
+            else -- it's a table (or had better be)
+                if type(v) ~= 'table' then
+                    return nil, 'invalid type for parameter ' .. prefix .. k;
+                end
+
+                -- recurse into child
+                ok, err = run_validation(v, expected_type, prefix .. k .. '.');
+            end
+        else -- it's an integer (or had better be)
+            if not config._member then
+                return nil, 'invalid parameter ' .. prefix .. tostring(k);
+            end
+            ok, err = run_validation(v, config._member, prefix .. tostring(k) .. '.');
+        end
+
+        if not ok then
+            return ok, err;
+        end
+    end
+
+    return true;
+end
+
+local function validate_config()
+    if true then
+        return true; -- XXX for now
+    end
+
+    -- this is almost too clever (I mean that in a bad
+    -- maintainability sort of way)
+    --
+    -- basically this allows a free pass for a key in group members
+    -- equal to params.groups.namefield
+    setmetatable(config_params.groups._member, {
+        __index = function(_, k)
+          if k == params.groups.namefield then
+              return 'string';
+          end
+        end
+    });
+
+    local ok, err = run_validation(params, config_params);
+
+    setmetatable(config_params.groups._member, nil);
+
+    if ok then
+        -- a little extra validation that doesn't fit into
+        -- my recursive checker
+        local group_namefield = params.groups.namefield;
+        for i, group in ipairs(params.groups) do
+            if not group[group_namefield] then
+                return nil, format('groups.%d.%s is required', i, group_namefield);
+            end
+        end
+
+        -- fill in params.admin if you can
+        if not params.admin and params.groups then
+          local admingroup;
+
+          for _, groupconfig in ipairs(params.groups) do
+              if groupconfig.admin then
+                  admingroup = groupconfig;
+                  break;
+              end
+          end
+
+          if admingroup then
+              params.admin = {
+                  basedn    = params.groups.basedn,
+                  namefield = params.groups.memberfield,
+                  filter    = group_namefield .. '=' .. admingroup[group_namefield],
+              };
+          end
+        end
+    end
+
+    return ok, err;
+end
+
+-- what to do if connection isn't available?
+local function connect()
+    return ldap.open_simple(params.hostname, params.bind_dn, params.bind_password, params.use_tls);
+end
+
+-- this is abstracted so we can maintain persistent connections at a later time
+function _M.getconnection()
+    return connect();
+end
+
+function _M.getparams()
+  return params;
+end
+
+-- XXX consider renaming this...it doesn't bind the current connection
+function _M.bind(username, password)
+    local who = format('%s=%s,%s', params.user.usernamefield, username, params.user.basedn);
+    local conn, err = ldap.open_simple(params.hostname, who, password, params.use_tls);
+
+    if conn then
+        conn:close();
+        return true;
+    end
+
+    return conn, err;
+end
+
+function _M.singlematch(query)
+    local ld = _M.getconnection();
+
+    query.sizelimit = 1;
+    query.scope     = 'onelevel';
+
+    for dn, attribs in ld:search(query) do
+        return attribs;
+    end
+end
+
+_M.filter = {};
+
+function _M.filter.combine_and(...)
+    local parts = { '(&' };
+
+    local arg = { ... };
+
+    for _, filter in ipairs(arg) do
+        if filter:sub(1, 1) ~= '(' and filter:sub(-1) ~= ')' then
+            filter = '(' .. filter .. ')'
+        end
+        parts[#parts + 1] = filter;
+    end
+
+    parts[#parts + 1] = ')';
+
+    return tconcat(parts, '');
+end
+
+do
+    local ok, err;
+
+    prosody.unlock_globals();
+    ok, ldap = pcall(require, 'lualdap');
+    prosody.lock_globals();
+    if not ok then
+        module:log("error", "Failed to load the LuaLDAP library for accessing LDAP: %s", ldap);
+        module:log("error", "More information on install LuaLDAP can be found at http://www.keplerproject.org/lualdap");
+        return;
+    end
+
+    if not params then
+        module:log("error", "LDAP configuration required to use the LDAP storage module");
+        return;
+    end
+
+    ok, err = validate_config();
+
+    if not ok then
+        module:log("error", "LDAP configuration is invalid: %s", tostring(err));
+        return;
+    end
+end
+
+return _M;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_storage_ldap/ldap/vcard.lib.lua	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,131 @@
+-- vim:sts=4 sw=4
+
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2012 Rob Hoelz
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local st = require 'util.stanza';
+
+local VCARD_NS = 'vcard-temp';
+
+local builder_methods = {};
+
+function builder_methods:addvalue(key, value)
+    self.vcard:tag(key):text(value):up();
+end
+
+function builder_methods:addregularfield(tagname, format_section)
+    local record = self.record;
+    local format = self.format;
+    local vcard  = self.vcard;
+
+    if not format[format_section] then
+        return;
+    end
+
+    local tag = vcard:tag(tagname);
+
+    for k, v in pairs(format[format_section]) do
+        tag:tag(string.upper(k)):text(record[v]):up();
+    end
+
+    vcard:up();
+end
+
+function builder_methods:addmultisectionedfield(tagname, format_section)
+    local record = self.record;
+    local format = self.format;
+    local vcard  = self.vcard;
+
+    if not format[format_section] then
+        return;
+    end
+
+    for k, v in pairs(format[format_section]) do
+        local tag = vcard:tag(tagname);
+
+        if type(k) == 'string' then
+            tag:tag(string.upper(k)):up();
+        end
+
+        for k2, v2 in pairs(v) do
+            if type(v2) == 'boolean' then
+                tag:tag(string.upper(k2)):up();
+            else
+                tag:tag(string.upper(k2)):text(record[v2]):up();
+            end
+        end
+
+        vcard:up();
+    end
+end
+
+function builder_methods:build()
+    local record = self.record;
+    local format = self.format;
+
+    self:addvalue(              'VERSION',     '2.0');
+    self:addvalue(              'FN',          record[format.displayname]);
+    self:addregularfield(       'N',           'name');
+    self:addvalue(              'NICKNAME',    record[format.nickname]);
+    self:addregularfield(       'PHOTO',       'photo');
+    self:addvalue(              'BDAY',        record[format.birthday]);
+    self:addmultisectionedfield('ADR',         'address');
+    self:addvalue(              'LABEL',       nil); -- we don't support LABEL...yet.
+    self:addmultisectionedfield('TEL',         'telephone');
+    self:addmultisectionedfield('EMAIL',       'email');
+    self:addvalue(              'JABBERID',    record.jid);
+    self:addvalue(              'MAILER',      record[format.mailer]);
+    self:addvalue(              'TZ',          record[format.timezone]);
+    self:addregularfield(       'GEO',         'geo');
+    self:addvalue(              'TITLE',       record[format.title]);
+    self:addvalue(              'ROLE',        record[format.role]);
+    self:addregularfield(       'LOGO',        'logo');
+    self:addvalue(              'AGENT',       nil); -- we don't support AGENT...yet.
+    self:addregularfield(       'ORG',         'org');
+    self:addvalue(              'CATEGORIES',  nil); -- we don't support CATEGORIES...yet.
+    self:addvalue(              'NOTE',        record[format.note]);
+    self:addvalue(              'PRODID',      nil); -- we don't support PRODID...yet.
+    self:addvalue(              'REV',         record[format.rev]);
+    self:addvalue(              'SORT-STRING', record[format.sortstring]);
+    self:addregularfield(       'SOUND',       'sound');
+    self:addvalue(              'UID',         record[format.uid]);
+    self:addvalue(              'URL',         record[format.url]);
+    self:addvalue(              'CLASS',       nil); -- we don't support CLASS...yet.
+    self:addregularfield(       'KEY',         'key');
+    self:addvalue(              'DESC',        record[format.description]);
+
+    return self.vcard;
+end
+
+local function new_builder(params)
+  local vcard_tag = st.stanza('vCard', { xmlns = VCARD_NS });
+
+  local object = {
+      vcard   = vcard_tag,
+      __index = builder_methods,
+  };
+
+  for k, v in pairs(params) do
+      object[k] = v;
+  end
+
+  setmetatable(object, object);
+
+  return object;
+end
+
+local _M = {};
+
+function _M.create(params)
+  local builder = new_builder(params);
+
+  return builder:build();
+end
+
+return _M;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_storage_ldap/mod_storage_ldap.lua	Sun Sep 02 15:35:50 2012 +0200
@@ -0,0 +1,180 @@
+-- vim:sts=4 sw=4
+
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2012 Rob Hoelz
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+----------------------------------------
+-- Constants and such --
+----------------------------------------
+
+local setmetatable = setmetatable;
+local ldap         = module:require 'ldap';
+local vcardlib     = module:require 'ldap/vcard';
+local st           = require 'util.stanza';
+local gettime      = require 'socket'.gettime;
+
+if not ldap then
+    return;
+end
+
+local CACHE_EXPIRY = 300;
+local params       = module:get_option('ldap');
+
+----------------------------------------
+-- Utility Functions --
+----------------------------------------
+
+local function ldap_record_to_vcard(record)
+    return vcardlib.create {
+        record = record,
+        format = params.vcard_format,
+    }
+end
+
+local get_alias_for_user;
+
+do
+  local user_cache;
+  local last_fetch_time;
+
+  local function populate_user_cache()
+      local ld = ldap.getconnection();
+
+      local usernamefield = params.user.usernamefield;
+      local namefield     = params.user.namefield;
+
+      user_cache = {};
+
+      for _, attrs in ld:search { base = params.user.basedn, scope = 'onelevel', filter = params.user.filter } do
+          user_cache[attrs[usernamefield]] = attrs[namefield];
+      end
+      last_fetch_time = gettime();
+  end
+
+  function get_alias_for_user(user)
+      if last_fetch_time and last_fetch_time + CACHE_EXPIRY < gettime() then
+          user_cache = nil;
+      end
+      if not user_cache then
+          populate_user_cache();
+      end
+      return user_cache[user];
+  end
+end
+
+----------------------------------------
+-- General Setup --
+----------------------------------------
+
+local ldap_store   = {};
+ldap_store.__index = ldap_store;
+
+local adapters = {
+    roster = {},
+    vcard  = {},
+}
+
+for k, v in pairs(adapters) do
+    setmetatable(v, ldap_store);
+    v.__index = v;
+    v.name    = k;
+end
+
+function ldap_store:get(username)
+    return nil, "get method unimplemented on store '" .. tostring(self.name) .. "'"
+end
+
+function ldap_store:set(username, data)
+    return nil, "LDAP storage is currently read-only";
+end
+
+----------------------------------------
+-- Roster Storage Implementation --
+----------------------------------------
+
+function adapters.roster:get(username)
+    local ld = ldap.getconnection();
+    local contacts = {};
+
+    local memberfield = params.groups.memberfield;
+    local namefield   = params.groups.namefield;
+    local filter      = memberfield .. '=' .. tostring(username);
+
+    local groups = {};
+    for _, config in ipairs(params.groups) do
+        groups[ config[namefield] ] = config.name;
+    end
+
+    -- XXX this kind of relies on the way we do groups at INOC
+    for _, attrs in ld:search { base = params.groups.basedn, scope = 'onelevel', filter = filter } do
+        if groups[ attrs[namefield] ] then
+            local members = attrs[memberfield];
+
+            for _, user in ipairs(members) do
+                if user ~= username then
+                    local jid    = user .. '@' .. module.host;
+                    local record = contacts[jid];
+
+                    if not record then
+                        record = {
+                            subscription = 'both',
+                            groups       = {},
+                            name         = get_alias_for_user(user),
+                        };
+                        contacts[jid] = record;
+                    end
+
+                    record.groups[ groups[ attrs[namefield] ] ] = true;
+                end
+            end
+        end
+    end
+
+    return contacts;
+end
+
+----------------------------------------
+-- vCard Storage Implementation --
+----------------------------------------
+
+function adapters.vcard:get(username)
+    if not params.vcard_format then
+        return nil, '';
+    end
+
+    local ld     = ldap.getconnection();
+    local filter = params.user.usernamefield .. '=' .. tostring(username);
+
+    local match = ldap.singlematch {
+        base   = params.user.basedn,
+        filter = filter,
+    };
+    if match then
+        match.jid = username .. '@' .. module.host
+        return st.preserialize(ldap_record_to_vcard(match));
+    else
+        return nil, 'not found';
+    end
+end
+
+----------------------------------------
+-- Driver Definition --
+----------------------------------------
+
+local driver = { name = "ldap" };
+
+function driver:open(store, typ)
+    local adapter = adapters[store];
+
+    if adapter and not typ then
+        return adapter;
+    end
+    return nil, "unsupported-store";
+end
+module:add_item("data-driver", driver);