1#!perl -w
2
3=head1 NAME
4
5auth_ldap_bind - Authenticate user via an LDAP bind
6
7=head1 DESCRIPTION
8
9This plugin authenticates users against an LDAP Directory.  The plugin
10first performs a lookup for an entry matching the connecting user.  This
11lookup uses the 'ldap_auth_filter_attr' attribute to match the connecting
12user to their LDAP DN.  Once the plugin has found the user's DN, the plugin
13will attempt to bind to the Directory as that DN with the password that has
14been supplied.
15
16=head1 CONFIGURATION
17
18Configuration items can be held in either the 'ldap' configuration file, or as
19arguments to the plugin.
20
21Configuration items in the 'ldap' configuration file
22are set one per line, starting the line with the configuration item key,
23followed by a space, then the values associated with the configuration item.
24
25Configuration items given as arguments to the plugin are keys and values
26separated by spaces.  Be sure to quote any values that have spaces in them.
27
28The only configuration item which is required is 'ldap_base'.  This tells the
29plugin what your base DN is.  The plugin will not work until it has been
30configured.
31
32The configuration items 'ldap_host' and 'ldap_port' specify the host and port
33at which your Directory server may be contacted.  If these are not specified,
34the plugin will use port '389' on 'localhost'.
35
36The configuration item 'ldap_timeout' specifies how long the plugin should
37wait for a response from your Directory server.  By default, the value is 5
38seconds.
39
40The configuration item 'ldap_auth_filter_attr' specifies how the plugin should
41find the user in your Directory.  By default, the plugin will look up the user
42based on the 'uid' attribute.
43
44=head1 NOTES
45
46Each auth requires an initial lookup to find the user's DN.  Ideally, the
47plugin would simply bind as the user without the need for this lookup (see
48FUTURE DIRECTION below).
49
50This plugin requires that the Directory allow anonymous bind (see FUTURE
51DIRECTION below).
52
53=head1 FUTURE DIRECTION
54
55A configurable LDAP filter should be made available, to account for users
56who are over quota, have had their accounts disabled, or whatever other
57arbitrary requirements.
58
59A configurable DN template (uid=$USER,ou=$DOMAIN,$BASE).  This would prevent
60the need of the initial user lookup, as the DN is created from the template.
61
62A configurable bind DN, for Directories that do not allow anonymous bind.
63
64Another plugin ('ldap_auth_cleartext'?), to allow retrieval of plain-text
65passwords from the Directory, permitting CRAM-MD5 or other hash algorithm
66authentication.
67
68=head1 AUTHOR
69
70Elliot Foster <elliotf@gratuitous.net>
71
72=head1 COPYRIGHT AND LICENSE
73
74Copyright (c) 2005 Elliot Foster
75
76This plugin is licensed under the same terms as the qpsmtpd package itself.
77Please see the LICENSE file included with qpsmtpd for details.
78
79=cut
80
81use strict;
82use warnings;
83
84use Net::LDAP qw(:all);
85use Qpsmtpd::Constants;
86
87sub register {
88    my ($self, $qp, @args) = @_;
89
90    $self->register_hook("auth-plain", "authldap");
91    $self->register_hook("auth-login", "authldap");
92
93    # pull config defaults in from file
94    %{$self->{"ldconf"}} =
95      map { (split /\s+/, $_, 2)[0, 1] } $self->qp->config('ldap');
96
97    # override ldap config defaults with plugin args
98    for my $ldap_arg (@args) {
99        %{$self->{"ldconf"}} = map { (split /\s+/, $_, 2)[0, 1] } $ldap_arg;
100    }
101
102    # do light validation of ldap_host and ldap_port to satisfy -T
103    my $ldhost = $self->{"ldconf"}->{'ldap_host'};
104    my $ldport = $self->{"ldconf"}->{'ldap_port'};
105    if (($ldhost) && ($ldhost =~ m/^(([a-z0-9]+\.?)+)$/)) {
106        $self->{"ldconf"}->{'ldap_host'} = $1;
107    }
108    else {
109        undef $self->{"ldconf"}->{'ldap_host'};
110    }
111    if (($ldport) && ($ldport =~ m/^(\d+)$/)) {
112        $self->{"ldconf"}->{'ldap_port'} = $1;
113    }
114    else {
115        undef $self->{"ldconf"}->{'ldap_port'};
116    }
117
118    # set any values that are not already
119    $self->{"ldconf"}->{"ldap_host"}             ||= "127.0.0.1";
120    $self->{"ldconf"}->{"ldap_port"}             ||= 389;
121    $self->{"ldconf"}->{"ldap_timeout"}          ||= 5;
122    $self->{"ldconf"}->{"ldap_auth_filter_attr"} ||= "uid";
123}
124
125sub authldap {
126    my ($self, $transaction, $method, $user, $passClear, $passHash, $ticket) =
127      @_;
128    my ($ldhost, $ldport, $ldwait, $ldbase, $ldmattr, $lduserdn, $ldh, $mesg);
129
130    # pull values in from config
131    $ldhost = $self->{"ldconf"}->{"ldap_host"};
132    $ldport = $self->{"ldconf"}->{"ldap_port"};
133    $ldbase = $self->{"ldconf"}->{"ldap_base"};
134
135 # log error here and DECLINE if no baseDN, because a custom baseDN is required:
136    unless ($ldbase) {
137        $self->log(LOGERROR, "skip: please configure ldap_base");
138        return (DECLINED, "authldap - temporary auth error");
139    }
140    $ldwait  = $self->{"ldconf"}->{'ldap_timeout'};
141    $ldmattr = $self->{"ldconf"}->{'ldap_auth_filter_attr'};
142
143    my ($pw_name, $pw_domain) = split "@", lc($user);
144
145    # find dn of user matching supplied username
146    $ldh = Net::LDAP->new($ldhost, port => $ldport, timeout => $ldwait) or do {
147        $self->log(LOGALERT, "skip: error in initial conn");
148        return (DECLINED, "authldap - temporary auth error");
149    };
150
151    # find the user's DN
152    $mesg = $ldh->search(
153                         base      => $ldbase,
154                         scope     => 'sub',
155                         filter    => "$ldmattr=$pw_name",
156                         attrs     => ['uid'],
157                         timeout   => $ldwait,
158                         sizelimit => '1'
159                        )
160      or do {
161        $self->log(LOGALERT, "skip: err in search for user");
162        return (DECLINED, "authldap - temporary auth error");
163      };
164
165    # deal with errors if they exist
166    if ($mesg->code) {
167        $self->log(LOGALERT,
168                   "skip: err " . $mesg->code . " in search for user");
169        return (DECLINED, "authldap - temporary auth error");
170    }
171
172    # unbind, so as to allow a rebind below
173    $ldh->unbind if $ldh;
174
175    # bind against directory as user with password supplied
176    if (!$mesg->count || $lduserdn = $mesg->entry->dn) {
177        $self->log(LOGALERT, "fail: user not found");
178        return (DECLINED, "authldap - wrong username or password");
179    }
180
181    $ldh = Net::LDAP->new($ldhost, port => $ldport, timeout => $ldwait) or do {
182        $self->log(LOGALERT, "skip: err in user conn");
183        return (DECLINED, "authldap - temporary auth error");
184    };
185
186    # here's the whole reason for the script
187    $mesg = $ldh->bind($lduserdn, password => $passClear, timeout => $ldwait);
188    $ldh->unbind if $ldh;
189
190    # deal with errors if they exist, or allow success
191    if ($mesg->code) {
192        $self->log(LOGALERT, "fail: error in user bind");
193        return (DECLINED, "authldap - wrong username or password");
194    }
195
196    $self->log(LOGINFO,  "pass: $user auth success");
197    $self->log(LOGDEBUG, "user: $user, pass: $passClear");
198    return (OK, "authldap");
199}
200
201