The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
MANIFEST 01
META.yml 11
MYMETA.json 11
MYMETA.yml 11
lib/Connector/Builtin/Authentication/LDAP.pm 0744
lib/Connector.pm 11
t/01-proxy-proc-safeexec.t 11
7 files changed (This is a version diff) 5750
@@ -22,6 +22,7 @@ inc/Module/Install/Win32.pm
 inc/Module/Install/WriteAll.pm
 lib/Connector.pm
 lib/Connector/Builtin.pm
+lib/Connector/Builtin/Authentication/LDAP.pm
 lib/Connector/Builtin/Authentication/Password.pm
 lib/Connector/Builtin/Authentication/PasswordScheme.pm
 lib/Connector/Builtin/Env.pm
@@ -37,4 +37,4 @@ requires:
   perl: 5.8.8
 resources:
   license: http://dev.perl.org/licenses/
-version: 1.10
+version: 1.12
@@ -58,5 +58,5 @@
          "http://dev.perl.org/licenses/"
       ]
    },
-   "version" : "1.10"
+   "version" : "1.11"
 }
@@ -36,4 +36,4 @@ requires:
   perl: '5.008008'
 resources:
   license: http://dev.perl.org/licenses/
-version: '1.10'
+version: '1.11'
@@ -0,0 +1,744 @@
+# Connector::Builtin::Authentication::LDAP
+#
+# Authenticate users against LDAP directory.
+#
+# NOTE: This module shoud (in theory) use the Connetor::Proxy::Net::LDAP,
+# but it didn't fit well. Reusing the proxy connector would require too much of
+# hacking.
+package Connector::Builtin::Authentication::LDAP;
+
+use strict;
+use warnings;
+use English;
+use Template;
+use Data::Dumper;
+use Net::LDAP;
+
+use Moose;
+extends 'Connector::Builtin';
+
+#
+# Options used by Net::LDAP::new()
+#
+has keepalive => (
+    is => 'rw',
+    isa => 'Int',
+);
+
+has timeout => (
+    is => 'rw',
+    isa => 'Int',
+);
+
+has multihomed => (
+    is => 'rw',
+    isa => 'Int',
+);
+
+has localaddr => (
+    is  => 'rw',
+    isa => 'Str',
+);
+
+has debug => (
+    is  => 'rw',
+    isa => 'Int',
+);
+
+# SSL options for Net::LDAP::new()
+has verify => (
+    is  => 'rw',
+    isa => 'Int',
+);
+
+has sslversion => (
+    is => 'rw',
+    isa => 'Str',
+);
+
+has ciphers => (
+    is  => 'rw',
+    isa => 'Str',
+);
+
+has cafile => (
+    is  => 'rw',
+    isa => 'Str',
+);
+
+has capath => (
+    is  => 'rw',
+    isa => 'Str',
+);
+
+has clientcert => (
+    is  => 'rw',
+    isa => 'Str',
+);
+
+has clientkey => (
+    is  => 'rw',
+    isa => 'Str',
+);
+
+has checkcrl => (
+    is  => 'rw',
+    isa => 'Int',
+);
+
+#
+# Options used by _bind()
+#
+has binddn => (
+    is  => 'rw',
+    isa => 'Str',
+);
+
+has password => (
+    is  => 'rw',
+    isa => 'Str',
+);
+
+
+#
+# Options used by Net::LDAP::search()
+#
+has timelimit => (
+    is  => 'rw',
+    isa => 'Int',
+);
+
+has sizelimit => (
+    is  => 'rw',
+    isa => 'Int',
+);
+
+has base => (
+    is  => 'rw',
+    isa => 'Str',
+);
+
+has filter => (
+    is  => 'rw',
+    # TODO: this does not work (currently); NB: do we need that?
+    #    isa => 'Str|Net::LDAP::Filter',
+    isa => 'Str',
+);
+
+has scope => (
+    is  => 'rw',
+    isa => 'Str',
+);
+
+#
+# Authentication-specific options
+#
+has userattr => (
+    is => 'rw',
+    isa => 'Str',
+    default => 'uid',
+);
+
+has groupattr => (
+    is => 'rw',
+    isa => 'Str',
+    default => 'member',
+);
+
+has groupdn => (
+    is => 'rw',
+    isa => 'Str',
+);
+
+has indirect => (
+    is => 'rw',
+    isa => 'Bool',
+    default => 1,
+);
+
+has ambiguous => (
+    is => 'rw',
+    isa => 'Bool',
+    default => 0,
+);
+
+#
+# Lazy method to initiate LDAP connection once
+#
+has ldap => (
+    is => 'ro',
+    isa => 'Net::LDAP',
+    builder => '_new_ldap',
+    reader => '_ldap',
+    lazy => 1,
+);
+
+sub _build_options {
+    my $self = shift;
+
+    my %options;
+    foreach my $key (@_) {
+        if (defined $self->$key()) {
+            $options{$key} = $self->$key();
+        }
+    }
+    return %options;
+}
+
+sub _build_new_options {
+    my $self = shift;
+    my %options = $self->_build_options(qw( keepalive timeout multihomed localaddr
+                                     debug verify sslversion ciphers cafile
+                                     capath clientcert clientkey checkcrl ));
+    $self->log()->debug('LDAP connection ' . Data::Dumper->Dump([\%options],[qw(*options)]));
+    return %options;
+}
+
+sub _build_bind_options {
+    my $self = shift;
+    return $self->_build_options(qw( password ));
+}
+
+# NOTE: it requires $arg->{LOGIN} to be defined and well formed!!
+sub _build_search_options {
+    my $self = shift;
+    my $arg = shift;
+
+    my %options = $self->_build_options(qw( base scope sizelimit timelimit ));
+
+    my $filter = $self->filter();
+    if(!defined $filter) {
+        $filter = '('.$self->userattr().'=[% LOGIN %])';
+    }
+
+    # template expansion is performed on filter strings only, not
+    # on Net::LDAP::Filter objects
+    my $value;
+    if (ref $filter eq '') {
+        my $template = Template->new({ });
+        $template->process(\$filter, $arg, \$value) || die "Error processing argument template.";
+        $options{filter} = $value;
+    } else {
+        $options{filter} = $filter;
+    }
+
+    # We don't retrieve any attributes!!! Just search for user's DN.
+    #$options{attrs} = ['dn'];
+
+    $self->log()->debug('LDAP search ' . Data::Dumper->Dump([\%options],[qw(*options)]));
+
+    return %options;
+}
+
+# NOTE: it returns undef in case of error
+sub _new_ldap {
+    my $self = shift;
+    my $ldapuri = $self->LOCATION();
+    $self->log()->debug('Connecting to "' . $ldapuri .'"');
+    my $ldap = Net::LDAP->new(
+        $ldapuri,
+        onerror => undef,
+        $self->_build_new_options(),
+    );
+    if(defined $ldap) {
+        $self->log()->debug('Connection established');
+    } else {
+        $self->log()->error('Connection failed (error: '.$@.')');
+    }
+    return $ldap;
+}
+
+# NOTE: it returns undef in case of error
+sub _bind {
+    my $self = shift;
+    my $mesg;
+    my $ldap = $self->_ldap;
+    if(!defined $ldap) {
+        return undef;
+    }
+    if(defined $self->binddn()) {
+        $self->log()->debug('Binding to "'.$self->binddn().'"');
+        $mesg = $ldap->bind(
+                $self->binddn(),
+                $self->_build_bind_options(),
+            );
+    } else {
+        # anonymous bind
+        $self->log()->debug('Binding anonymously');
+        $mesg = $ldap->bind(
+                $self->_build_bind_options(),
+        );
+    }
+    if($mesg->is_error()) {
+        $self->log()->error('LDAP bind returned error code '.$mesg->code.' (error: '.$mesg->error_desc.')');
+        return undef;
+    } else {
+        $self->log()->debug('LDAP bind successfull');
+        return $ldap;
+    }
+}
+
+# NOTE: it returns undef in case of error, or ref to an array of user DNs
+#       it MAY return an empty array if user is not found
+sub _search_user {
+    my $self = shift;
+    my $user = shift;
+
+    my $ldap = $self->_ldap();
+    if(!defined $ldap) {
+        $self->log()->error('Can not perform LDAP search');
+        return undef;
+    }
+    $self->log()->debug('Searching LDAP databse for user "'.$user. '"');
+    my $result = $ldap->search(
+        $self->_build_search_options({ LOGIN => $user })
+    );
+    if($result->is_error()) {
+        $self->log()->error('LDAP search returned error code '.$result->code.' (error: '.$result->error_desc().')');
+        return undef;
+    } else {
+        $self->log()->debug('LDAP search returned '.$result->count . (($result->count ==1) ? ' entry' : ' entries'));
+    }
+    if($self->groupdn()) {
+        $self->log()->debug('Group check requested, groupdn: "'.$self->groupdn().'", groupattr: "'.$self->groupattr().'"');
+    }
+    my $dns;
+    for my $entry ($result->entries()) {
+        my $dn = $entry->dn();
+        if(defined $self->groupdn()) { 
+            if(!$self->_check_user_group($dn)) {
+                next;
+            }
+        }
+        push(@$dns, $dn);
+    }
+    return $dns;
+}
+
+sub _check_user_group {
+    my $self = shift;
+    my $dn = shift;
+    my $ldap = $self->_ldap();
+    $self->log()->debug('Checking if "'.$dn.'" belongs to group "'.$self->groupdn().'"');
+    my $result = $ldap->compare($self->groupdn(), attr => $self->groupattr(), value => $dn);
+    if($result->is_error()) {
+      $self->log()->error('LDAP compare returned error code '.$result->code.' (error: '.$result->error_desc().')');
+      return 0;
+    }
+    if($result->code != 6) { # !compareTrue
+      $self->log()->debug('User "'.$dn.'" does not belong to group "'.$self->groupdn().'"');
+      return 0;
+    }
+    $self->log()->debug('User "'.$dn.'" belongs to group "'.$self->groupdn().'"');
+    return 1
+}
+
+sub _check_user_password {
+    my $self = shift;
+    my $userdns = shift;
+    my $password = shift;
+    my $ldap = $self->_ldap;
+
+    my $userdn;
+    foreach my $dn (@$userdns) {
+      # Try to bind to $dn
+      $self->log()->debug('Trying to bind to dn: '.$dn);
+      my $mesg = $ldap->bind($dn, password => $password);
+      if($mesg->is_error()) {
+        $self->log()->debug('LDAP bind to '.$dn.' returned error code '.$mesg->code.' (error: '.$mesg->error_desc().')');
+      } else {
+        $self->log()->debug('LDAP bind to '.$dn.' succeeded');
+        $userdn = $dn;
+        last;
+      }
+    }
+
+    if(!defined $userdn) {
+      $self->log()->error('Authentication failed');
+      return 0;
+    } else {
+      $self->log()->info('User successfuly authenticated: (dn: '.$userdn.')');
+      return 1;
+    }
+}
+
+sub get {
+    my $self = shift;
+    my $arg = shift;
+    my $params = shift;
+
+    my @args = $self->_build_path( $arg );
+    my $user = shift @args;
+
+    my $password = $params->{password};
+
+    if(!$user) {
+        $self->log()->error('Missing user name');
+        return undef;
+    }
+    # enforce valueencoding, see RFC4515, note that we allow non-ascii (utf-8) characters
+    # I assume that Net::LDAP->search() escapes them internally as needed
+    if (!($user =~ /^([\x01-\x27\x2B-\x5B\x5D-\x7F]|[^[:ascii:]]|\\[0-9a-fA-F][0-9a-fA-F])*$/)) {
+        $self->log()->error('Invalid chars in username ("'.$user.'")');
+        return undef;
+    }
+
+    #
+    # let's check if we were instructed to search for the auth user 
+    #
+    my @userdns;
+    if($self->indirect()) {
+        $self->_bind();
+        my $result = $self->_search_user($user);
+        if(!defined $result) {
+            return $self->_node_not_exists($user);
+        }
+        @userdns = @$result;
+        my $count = @userdns;
+        if($count == 0) {
+            $self->log()->error('User not found in LDAP database');
+            return $self->_node_not_exists($user);
+        } elsif($count > 1) {
+            $self->log()->debug('Found '.$count.' LDAP entries matching the user "'.$user.'"');
+            if(!$self->ambiguous()) {
+                $self->log()->error('Ambiguous search result');
+                return $self->_node_not_exists($user);
+            }
+        }
+    } else {
+        if($self->groupdn()) {
+            $self->_bind();
+            if(!$self->_check_user_group($user)) {
+                return $self->_node_not_exists($user);
+            }
+        }
+        @userdns = ($user);
+    }
+
+    return $self->_check_user_password(\@userdns, $password);
+}
+
+no Moose;
+__PACKAGE__->meta->make_immutable;
+
+1;
+__END__
+
+=head1 NAME
+
+Connector::Builtin::Authentication::LDAP
+
+=head1 DESCRIPTION
+
+Connector (see perldoc I<Connector>) to authenticate users against LDAP.
+Supports simple authentication (via LDAP bind), SASL authentication is not
+supported.
+
+The module allows for direct bind or indirect bind (with preliminary user
+search). Direct bind is the most straightforward method, but it requires 
+users to know their Distinguished Names (DNs) in LDAP. Indirect bind is more
+convenient for users, but it involves LDAP database search, which requires read
+access to larger parts of LDAP directory (so LDAP ACLs must be set properly to
+allow indirect bind).
+
+The module implements group participation checking. With this option enabled,
+only users that belong to a predefined group may pass the authentication.
+The group is stored in LDAP directory (it may be for example an entry of 
+type I<groupOfUniqueNames> with the group participants listed in attribute
+I<uniqueMember>).
+
+When requesting indirect bind, the internal user search may return multiple
+DNs. By default this is treated as an error (because of ambiguity) and results
+with authentication failuer. This may be changed by setting a parameter named
+I<ambiguous>, in which case the module will try to consecutively bind to each
+DN from the search result.
+
+The indirect bind may be configured to use custom search filter, instead of
+the default one. This allows to incorporate additional restrictions on users
+based on their attributes stored in LDAP.
+
+=head2 Usage
+
+The username is the first component of the path, the password needs to be
+passed in the extended parameters using the key password.
+
+Example:
+
+   $connector->get('username', {  password => 'mySecret' } );
+
+To configure module for direct bind, the connector object should be created
+with parameter I<indirect> => 0. This is the simplest authentication method
+and requires least parameters to be configured. 
+
+Example:
+
+    my $connector = Connector::Builtin::Authentication::LDAP->new({
+        LOCATION => 'ldap://ldap.example.org',
+        indirect => 0
+    })
+    my $result = $connector->get(
+        'uid=jsmith,ou=people,dc=example,dc=org',
+        { password => 'secret' }
+    );
+
+
+Indirect bind, which is default, searches through the LDAP directory. This
+usually requires read access to database, and is performed by a separate user.
+We'll call that user I<binddn>. For indirect-bind authentication, one usually
+has to provide DN and password of the existing I<binddn> user.
+
+Example:
+
+    my $connector = Connector::Builtin::Authentication::LDAP->new({
+        LOCATION => 'ldap://ldap.example.org',
+        binddn => 'cn=admin,dc=example,dc=org',
+        password => 'binddnPassword'
+    })
+    my $result = $connector->get('jsmith', { password => 'secret' });
+
+Two parameters are used to check group participation: I<groupdn> and
+I<groupattr>. The I<groupdn> parameter specifies DN of a group entry and the
+I<groupattr> specifies an attribute of the I<groupdn> object where group
+participants are listed. If you specify I<groupdn>, the group participation
+check is enabled.
+
+
+Example:
+
+    # Assume, we have in LDAP:
+    #
+    # dn: cn=vip,dc=example,dc=org
+    # objectClass: groupOfNames
+    # member: uid=jsmith,ou=people,dc=example,dc=org
+    #
+    my $connector = Connector::Builtin::Authentication::LDAP->new({
+        LOCATION => 'ldap://ldap.example.org',
+        indirect => 0,
+        binddn => 'cn=admin,dc=example,dc=org',
+        password => 'binddnPassword',
+        groupdn => 'cn=vip,dc=example,dc=org',
+    })
+    my $result = $connector->get(
+        'uid=jsmith,ou=people,dc=example,dc=org',
+        { password => 'secret' } 
+    );
+
+Note, that in this case we have provided I<binddn> despite the direct-bind
+authentication was used. This is, because we needed read access to the
+C<cn=vip,dc=example,dc=org> entry (the group object).
+
+The indirect-bind method accepts custom filters for user search.
+
+Example:
+
+    my $connector = Connector::Builtin::Authentication::LDAP->new({
+        LOCATION => 'ldap://ldap.example.org',
+        binddn => 'cn=admin,dc=example,dc=org',
+        password => 'binddnPassword',
+        filter => '(&(uid=[% LOGIN %])(accountStatus=active))'
+    })
+    my $result = $connector->get('jsmith', { password => 'secret' });
+
+You may substitute user name by using I<[% LOGIN %]> template parameter,
+as shown in the above example.
+
+=head2 Configuration
+
+Below is the full list of configuration options.
+
+=head3 Connection options
+
+=over 8
+
+=item B<keepalive> => 1
+
+If given, set the socket's SO_KEEPALIVE option depending on the Boolean value
+of the option. (Default: use system default).
+
+=item B<timeout> => N
+
+Timeout passed to IO::Socket when connecting the remote server. (Default: 120)
+
+=item B<multihomed> => N
+
+Will be passed to IO::Socket as the MultiHomed parameter when connecting to the
+remote server
+
+=item B<localaddr> => HOST
+
+Will be passed to IO::Socket as the LocalAddr parameter, which sets the
+client's IP address (as opposed to the server's IP address.)
+
+=item B<debug> => N
+
+Set the LDAP debug level.
+
+=back
+
+=head3 SSL Connection options
+
+=over 8
+
+=item B<verify> => 'none' | 'optional' | 'require'
+
+How to verify the server's certificate:
+
+    none
+        The server may provide a certificate but it will not be checked - this
+        may mean you are be connected to the wrong server
+    optional
+        Verify only when the server offers a certificate
+    require
+        The server must provide a certificate, and it must be valid.  
+
+If you set B<verify> to optional or I<require>, you must also set either
+B<cafile> or B<capath>. The most secure option is require.
+
+=item B<sslversion>  => 'sslv2' | 'sslv3' | 'sslv23' | 'tlsv1'
+
+This defines the version of the SSL/TLS protocol to use. Defaults to 'tlsv1'.
+
+=item B<ciphers> => CIPHERS
+
+Specify which subset of cipher suites are permissible for this connection,
+using the standard OpenSSL string format. The default behavior is to keep the
+decision on the underlying cryptographic library.
+
+=item B<capath> => '/path/to/servercerts/'
+
+See B<cafile>.
+
+=item B<cafile> => '/path/to/servercert.pem'
+
+When verifying the server's certificate, either set B<capath> to the pathname
+of the directory containing CA certificates, or set B<cafile> to the filename
+containing the certificate of the CA who signed the server's certificate. These
+certificates must all be in PEM format.
+
+
+=item B<clientcert> => '/path/to/cert.pem'
+
+See B<clientkey>.
+
+=item B<clientkey> => '/path/to/key.pem'
+
+If you want to use the client to offer a certificate to the server for SSL
+authentication (which is not the same as for the LDAP Bind operation) then set
+B<clientcert> to the user's certificate file, and B<clientkey> to the user's
+private key file. These files must be in PEM format.
+
+=item B<checkcrl> => 1
+
+=back
+
+=head3 BindDN
+
+=over 8
+
+=item B<binddn> => DN
+
+Distinguished Name of the LDAP entry used to search LDAP database for users
+being authenticated (indirect bind) and check their group participation.
+
+=item B<password> => PASSWORD
+
+Password for the B<binddn> user.
+
+=back
+
+=head3 Search options (indirect bind)
+
+=over 8
+
+=item B<timelimit> => N
+
+A timelimit that restricts the maximum time (in seconds) allowed for a search.
+A value of 0 (the default), means that no timelimit will be requested.
+
+=item B<sizelimit> => N
+
+A sizelimit that restricts the maximum number of entries to be returned as a
+result of the search. A value of 0, and the default, means that no restriction
+is requested. Servers may enforce a maximum number of entries to return.
+
+=item B<base> => DN
+
+The DN that is the base object entry relative to which the search is to be
+performed.
+
+=item B<filter> => TEMPLATESTRING
+
+A filter that defines the conditions an entry in the directory must meet in
+order for it to be returned by the search. This may be a (template) string or a
+Net::LDAP::Filter object.
+
+=item B<scope>  => 'base' | 'one' | 'sub' | 'subtree' | 'children'
+
+By default the search is performed on the whole tree below the specified base
+object. This maybe changed by specifying a scope parameter with one of the
+following values:
+
+    base
+        Search only the base object.
+    one 
+        Search the entries immediately below the base object.
+    sub
+    subtree 
+        Search the whole tree below (and including) the base object. This is
+        the default.
+    children 
+        Search the whole subtree below the base object, excluding the base object itself.
+
+Note: children scope requires LDAPv3 subordinate feature extension.
+
+=back
+
+=head3 Other options
+
+=over 8
+
+=item B<userattr> => ATTRNAME
+
+If the search B<filter> (for indirect bind) is not specified, it is constructed
+internally as I<"($userattr=[% LOGIN %])">, where I<$userattr> represents the
+value of B<userattr> parameter.
+
+=item B<groupattr> => ATTRNAME
+
+If B<groupdn> is specified by caller, the B<groupattr> defines an attribute 
+within B<groupdn> object which shall be compared against the DN of the user
+being authenticated in order to check its participation to the group. Defaults
+to I<'member'>.
+
+=item B<groupdn> => DN
+
+DN of an LDAP entry which defines a group of users allowed to be authenticated.
+If not defined, the group participation is not checked.
+
+=item B<indirect> => 1 | 0
+
+Use indirect bind (default). Set to I<0> to disable indirect bind and use
+direct bind.
+
+=item B<ambiguous> => 0 | 1
+
+Accept ambiguous search results when doing indirect-bind authentication. By
+default, this option is disabled.
+
+=back
+
+=head2 Return values
+
+1 if the password matches, 0 if the user is found but the password does not
+match and undef if the user is not found (or it's found but group check
+failed).
+
+=head2 Limitations
+
+User names are limited to so called I<valueencoding> syntax defined by RFC4515.
+We allow non-ascii (utf-8) characters and non-printable characters. Invalid
+names are treated as not found.
+
+=cut
+
+# vim: set expandtab tabstop=4 shiftwidth=4:
@@ -8,7 +8,7 @@ package Connector;
 
 use 5.008_008;  # This is the earliest version we've tested on
 
-our $VERSION = '1.10';
+our $VERSION = '1.12';
 
 use strict;
 use warnings;
@@ -24,7 +24,7 @@ BEGIN {
 #diag "Connector::Proxy::Proc::SafeExec\n";
 ###########################################################################
 SKIP: {
-    skip "Proc::SafeExec not installed", 17 if $req_err;
+    skip "Proc::SafeExec not installed", 22 if $req_err;
 
     require_ok('Connector::Proxy::Proc::SafeExec');
     my $conn = Connector::Proxy::Proc::SafeExec->new(