change default value for persist_in_session to username
[catagits/Catalyst-Authentication-Store-LDAP.git] / lib / Catalyst / Authentication / Store / LDAP / Backend.pm
1
2 =pod
3
4 =head1 NAME
5
6 Catalyst::Authentication::Store::LDAP::Backend
7   - LDAP authentication storage backend.
8
9 =head1 SYNOPSIS
10
11     # you probably just want Store::LDAP under most cases,
12     # but if you insist you can instantiate your own store:
13
14     use Catalyst::Authentication::Store::LDAP::Backend;
15
16     use Catalyst qw/
17         Authentication
18         Authentication::Credential::Password
19     /;
20
21     my %config = (
22             'ldap_server' => 'ldap1.yourcompany.com',
23             'ldap_server_options' => {
24                 'timeout' => 30,
25             },
26             'binddn' => 'anonymous',
27             'bindpw' => 'dontcarehow',
28             'start_tls' => 1,
29             'start_tls_options' => {
30                 'verify' => 'none',
31             },
32             'user_basedn' => 'ou=people,dc=yourcompany,dc=com',
33             'user_filter' => '(&(objectClass=posixAccount)(uid=%s))',
34             'user_scope' => 'one',  # or 'sub' for Active Directory
35             'user_field' => 'uid',
36             'user_search_options' => {
37                 'deref' => 'always',
38                 'attrs' => [qw( distinguishedname name mail )],
39             },
40             'user_results_filter' => sub { return shift->pop_entry },
41             'entry_class' => 'MyApp::LDAP::Entry',
42             'user_class' => 'MyUser',
43             'use_roles' => 1,
44             'role_basedn' => 'ou=groups,dc=yourcompany,dc=com',
45             'role_filter' => '(&(objectClass=posixGroup)(member=%s))',
46             'role_scope' => 'one',
47             'role_field' => 'cn',
48             'role_value' => 'dn',
49             'role_search_options' => {
50                 'deref' => 'always',
51             },
52             'role_search_as_user' => 0,
53             'persist_in_session'  => 'all',
54     );
55
56     our $users = Catalyst::Authentication::Store::LDAP::Backend->new(\%config);
57
58 =head1 DESCRIPTION
59
60 You probably want L<Catalyst::Authentication::Store::LDAP>.
61
62 Otherwise, this lets you create a store manually.
63
64 See the L<Catalyst::Authentication::Store::LDAP> documentation for
65 an explanation of the configuration options.
66
67 =head1 METHODS
68
69 =cut
70
71 package Catalyst::Authentication::Store::LDAP::Backend;
72 use base qw( Class::Accessor::Fast );
73
74 use strict;
75 use warnings;
76
77 our $VERSION = '1.015';
78
79 use Catalyst::Authentication::Store::LDAP::User;
80 use Net::LDAP;
81 use Catalyst::Utils ();
82
83 BEGIN {
84     __PACKAGE__->mk_accessors(
85         qw( ldap_server ldap_server_options binddn
86             bindpw entry_class user_search_options
87             user_filter user_basedn user_scope
88             user_attrs user_field use_roles role_basedn
89             role_filter role_scope role_field role_value
90             role_search_options start_tls start_tls_options
91             user_results_filter user_class role_search_as_user
92             persist_in_session
93             )
94     );
95 }
96
97 =head2 new($config)
98
99 Creates a new L<Catalyst::Authentication::Store::LDAP::Backend> object.
100 $config should be a hashref, which should contain the configuration options
101 listed in L<Catalyst::Authentication::Store::LDAP>'s documentation.
102
103 Also sets a few sensible defaults.
104
105 =cut
106
107 sub new {
108     my ( $class, $config ) = @_;
109
110     unless ( defined($config) && ref($config) eq "HASH" ) {
111         Catalyst::Exception->throw(
112             "Catalyst::Authentication::Store::LDAP::Backend needs to be configured with a hashref."
113         );
114     }
115     my %config_hash = %{$config};
116     $config_hash{'binddn'}      ||= 'anonymous';
117     $config_hash{'user_filter'} ||= '(uid=%s)';
118     $config_hash{'user_scope'}  ||= 'sub';
119     $config_hash{'user_field'}  ||= 'uid';
120     $config_hash{'role_filter'} ||= '(memberUid=%s)';
121     $config_hash{'role_scope'}  ||= 'sub';
122     $config_hash{'role_field'}  ||= 'cn';
123     $config_hash{'use_roles'}   ||= '1';
124     $config_hash{'start_tls'}   ||= '0';
125     $config_hash{'entry_class'} ||= 'Catalyst::Model::LDAP::Entry';
126     $config_hash{'user_class'}
127         ||= 'Catalyst::Authentication::Store::LDAP::User';
128     $config_hash{'role_search_as_user'} ||= 0;
129     $config_hash{'persist_in_session'}  ||= 'username';
130
131     Catalyst::Utils::ensure_class_loaded( $config_hash{'user_class'} );
132     my $self = \%config_hash;
133     bless( $self, $class );
134     return $self;
135 }
136
137 =head2 find_user( I<authinfo>, $c )
138
139 Creates a L<Catalyst::Authentication::Store::LDAP::User> object
140 for the given User ID.  This is the preferred mechanism for getting a
141 given User out of the Store.
142
143 I<authinfo> should be a hashref with a key of either C<id> or
144 C<username>. The value will be compared against the LDAP C<user_field> field.
145
146 =cut
147
148 sub find_user {
149     my ( $self, $authinfo, $c ) = @_;
150     return $self->get_user( $authinfo->{id} || $authinfo->{username}, $c );
151 }
152
153 =head2 get_user( I<id>, $c)
154
155 Creates a L<Catalyst::Authentication::Store::LDAP::User> object
156 for the given User ID, or calls C<new> on the class specified in
157 C<user_class>.  This instance of the store object, the results of
158 C<lookup_user> and $c are passed as arguments (in that order) to C<new>.
159 This is the preferred mechanism for getting a given User out of the Store.
160
161 =cut
162
163 sub get_user {
164     my ( $self, $id, $c ) = @_;
165     my $user = $self->user_class->new( $self, $self->lookup_user($id), $c );
166     return $user;
167 }
168
169 =head2 ldap_connect
170
171 Returns a L<Net::LDAP> object, connected to your LDAP server. (According
172 to how you configured the Backend, of course)
173
174 =cut
175
176 sub ldap_connect {
177     my ($self) = shift;
178     my $ldap;
179     if ( defined( $self->ldap_server_options() ) ) {
180         $ldap
181             = Net::LDAP->new( $self->ldap_server,
182             %{ $self->ldap_server_options } )
183             or Catalyst::Exception->throw($@);
184     }
185     else {
186         $ldap = Net::LDAP->new( $self->ldap_server )
187             or Catalyst::Exception->throw($@);
188     }
189     if ( defined( $self->start_tls ) && $self->start_tls =~ /(1|true)/i ) {
190         my $mesg;
191         if ( defined( $self->start_tls_options ) ) {
192             $mesg = $ldap->start_tls( %{ $self->start_tls_options } );
193         }
194         else {
195             $mesg = $ldap->start_tls;
196         }
197         if ( $mesg->is_error ) {
198             Catalyst::Exception->throw( "TLS Error: " . $mesg->error );
199         }
200     }
201     return $ldap;
202 }
203
204 =head2 ldap_bind($ldap, $binddn, $bindpw)
205
206 Bind's to the directory.  If $ldap is undef, it will connect to the
207 LDAP server first.  $binddn should be the DN of the object you wish
208 to bind as, and $bindpw the password.
209
210 If $binddn is "anonymous", an anonymous bind will be performed.
211
212 =cut
213
214 sub ldap_bind {
215     my ( $self, $ldap, $binddn, $bindpw ) = @_;
216     $ldap ||= $self->ldap_connect;
217     if ( !defined($ldap) ) {
218         Catalyst::Exception->throw("LDAP Server undefined!");
219     }
220
221     # if username is present, make sure password is present too.
222     # see https://rt.cpan.org/Ticket/Display.html?id=81908
223     if ( !defined $binddn ) {
224         $binddn = $self->binddn;
225         $bindpw = $self->bindpw;
226     }
227
228     if ( $binddn eq "anonymous" ) {
229         $self->_ldap_bind_anon($ldap);
230     }
231     else {
232         if ($bindpw) {
233             my $mesg = $ldap->bind( $binddn, 'password' => $bindpw );
234             if ( $mesg->is_error ) {
235                 Catalyst::Exception->throw(
236                     "Error on Initial Bind: " . $mesg->error );
237             }
238         }
239         else {
240             $self->_ldap_bind_anon( $ldap, $binddn );
241         }
242     }
243     return $ldap;
244 }
245
246 sub _ldap_bind_anon {
247     my ( $self, $ldap, $dn ) = @_;
248     my $mesg = $ldap->bind($dn);
249     if ( $mesg->is_error ) {
250         Catalyst::Exception->throw( "Error on Bind: " . $mesg->error );
251     }
252 }
253
254 =head2 ldap_auth( $binddn, $bindpw )
255
256 Connect to the LDAP server and do an authenticated bind against the
257 directory. Throws an exception if connecting to the LDAP server fails.
258 Returns 1 if binding succeeds, 0 if it fails.
259
260 =cut
261
262 sub ldap_auth {
263     my ( $self, $binddn, $bindpw ) = @_;
264     my $ldap = $self->ldap_connect;
265     if ( !defined $ldap ) {
266         Catalyst::Exception->throw("LDAP server undefined!");
267     }
268     my $mesg = $ldap->bind( $binddn, password => $bindpw );
269     return $mesg->is_error ? 0 : 1;
270 }
271
272 =head2 lookup_user($id)
273
274 Given a User ID, this method will:
275
276   A) Bind to the directory using the configured binddn and bindpw
277   B) Perform a search for the User Object in the directory, using
278      user_basedn, user_filter, and user_scope.
279   C) Assuming we found the object, we will walk it's attributes
280      using L<Net::LDAP::Entry>'s get_value method.  We store the
281      results in a hashref. If we do not find the object, then
282      undef is returned.
283   D) Return a hashref that looks like:
284
285      $results = {
286         'ldap_entry' => $entry, # The Net::LDAP::Entry object
287         'attributes' => $attributes,
288      }
289
290 This method is usually only called by find_user().
291
292 =cut
293
294 sub lookup_user {
295     my ( $self, $id ) = @_;
296
297     # Trim trailing space or we confuse ourselves
298     $id =~ s/\s+$//;
299     my $ldap = $self->ldap_bind;
300     my @searchopts;
301     if ( defined( $self->user_basedn ) ) {
302         push( @searchopts, 'base' => $self->user_basedn );
303     }
304     else {
305         Catalyst::Exception->throw(
306             "You must set user_basedn before looking up users!");
307     }
308     my $filter = $self->_replace_filter( $self->user_filter, $id );
309     push( @searchopts, 'filter' => $filter );
310     push( @searchopts, 'scope'  => $self->user_scope );
311     if ( defined( $self->user_search_options ) ) {
312         push( @searchopts, %{ $self->user_search_options } );
313     }
314     my $usersearch = $ldap->search(@searchopts);
315
316     return undef if ( $usersearch->is_error );
317
318     my $userentry;
319     my $user_field     = $self->user_field;
320     my $results_filter = $self->user_results_filter;
321     my $entry;
322     if ( defined($results_filter) ) {
323         $entry = &$results_filter($usersearch);
324     }
325     else {
326         $entry = $usersearch->pop_entry;
327     }
328     if ( $usersearch->pop_entry ) {
329         Catalyst::Exception->throw(
330                   "More than one entry matches user search.\n"
331                 . "Consider defining a user_results_filter sub." );
332     }
333
334     # a little extra sanity check with the 'eq' since LDAP already
335     # says it matches.
336     # NOTE that Net::LDAP returns exactly what you asked for, but
337     # because LDAP is often case insensitive, FoO can match foo
338     # and so we normalize with lc().
339     if ( defined($entry) ) {
340         unless ( lc( $entry->get_value($user_field) ) eq lc($id) ) {
341             Catalyst::Exception->throw(
342                 "LDAP claims '$user_field' equals '$id' but results entry does not match."
343             );
344         }
345         $userentry = $entry;
346     }
347
348     $ldap->unbind;
349     $ldap->disconnect;
350     unless ($userentry) {
351         return undef;
352     }
353     my $attrhash;
354     foreach my $attr ( $userentry->attributes ) {
355         my @attrvalues = $userentry->get_value($attr);
356         if ( scalar(@attrvalues) == 1 ) {
357             $attrhash->{ lc($attr) } = $attrvalues[0];
358         }
359         else {
360             $attrhash->{ lc($attr) } = \@attrvalues;
361         }
362     }
363
364     eval { Catalyst::Utils::ensure_class_loaded( $self->entry_class ) };
365     if ( !$@ ) {
366         bless( $userentry, $self->entry_class );
367         $userentry->{_use_unicode}++;
368     }
369     my $rv = {
370         'ldap_entry' => $userentry,
371         'attributes' => $attrhash,
372     };
373     return $rv;
374 }
375
376 =head2 lookup_roles($userobj, [$ldap])
377
378 This method looks up the roles for a given user.  It takes a
379 L<Catalyst::Authentication::Store::LDAP::User> object
380 as it's first argument, and can optionally take a I<Net::LDAP> object which
381 is used rather than the default binding if supplied.
382
383 It returns an array containing the role_field attribute from all the
384 objects that match it's criteria.
385
386 =cut
387
388 sub lookup_roles {
389     my ( $self, $userobj, $ldap ) = @_;
390     if ( $self->use_roles == 0 || $self->use_roles =~ /^false$/i ) {
391         return undef;
392     }
393     $ldap ||= $self->role_search_as_user
394         ? $userobj->ldap_connection : $self->ldap_bind;
395     my @searchopts;
396     if ( defined( $self->role_basedn ) ) {
397         push( @searchopts, 'base' => $self->role_basedn );
398     }
399     else {
400         Catalyst::Exception->throw(
401             "You must set up role_basedn before looking up roles!");
402     }
403     my $filter_value = $userobj->has_attribute( $self->role_value );
404     if ( !defined($filter_value) ) {
405         Catalyst::Exception->throw( "User object "
406                 . $userobj->username
407                 . " has no "
408                 . $self->role_value
409                 . " attribute, so I can't look up it's roles!" );
410     }
411     my $filter = $self->_replace_filter( $self->role_filter, $filter_value );
412     push( @searchopts, 'filter' => $filter );
413     push( @searchopts, 'scope'  => $self->role_scope );
414     push( @searchopts, 'attrs'  => [ $self->role_field ] );
415     if ( defined( $self->role_search_options ) ) {
416         push( @searchopts, %{ $self->role_search_options } );
417     }
418     my $rolesearch = $ldap->search(@searchopts);
419     my @roles;
420 RESULT: foreach my $entry ( $rolesearch->entries ) {
421         push( @roles, $entry->get_value( $self->role_field ) );
422     }
423     return @roles;
424 }
425
426 sub _replace_filter {
427     my $self    = shift;
428     my $filter  = shift;
429     my $replace = shift;
430     $replace =~ s/([*()\\\x{0}])/sprintf '\\%02x', ord($1)/ge;
431     $filter =~ s/\%s/$replace/g;
432     return $filter;
433 }
434
435 =head2 user_supports
436
437 Returns the value of
438 Catalyst::Authentication::Store::LDAP::User->supports(@_).
439
440 =cut
441
442 sub user_supports {
443     my $self = shift;
444
445     # this can work as a class method
446     Catalyst::Authentication::Store::LDAP::User->supports(@_);
447 }
448
449 =head2 from_session( I<id>, I<$c> )
450
451 Returns get_user() for I<id>.
452
453 =cut
454
455 sub from_session {
456     my ( $self, $c, $frozenuser ) = @_;
457
458     if ( $self->persist_in_session eq 'all' ) {
459         return $self->user_class->new( $self, $frozenuser->{user}, $c, $frozenuser->{_roles} );
460     }
461
462     return $self->get_user( $frozenuser, $c );
463 }
464
465 1;
466
467 __END__
468
469 =head1 AUTHORS
470
471 Adam Jacob <holoway@cpan.org>
472
473 Some parts stolen shamelessly and entirely from
474 L<Catalyst::Plugin::Authentication::Store::Htpasswd>.
475
476 Currently maintained by Peter Karman <karman@cpan.org>.
477
478 =head1 THANKS
479
480 To nothingmuch, ghenry, castaway and the rest of #catalyst for the help. :)
481
482 =head1 SEE ALSO
483
484 L<Catalyst::Authentication::Store::LDAP>, L<Catalyst::Authentication::Store::LDAP::User>, L<Catalyst::Plugin::Authentication>, L<Net::LDAP>
485
486 =head1 COPYRIGHT & LICENSE
487
488 Copyright (c) 2005 the aforementioned authors. All rights
489 reserved. This program is free software; you can redistribute
490 it and/or modify it under the same terms as Perl itself.
491
492 =cut
493