File Coverage

blib/lib/Dancer/Plugin/Auth/Extensible.pm
Criterion Covered Total %
statement 103 122 84.4
branch 49 70 70.0
condition 6 12 50.0
subroutine 21 23 91.3
pod 0 1 0.0
total 179 228 78.5


line stmt bran cond sub pod time code
1             package Dancer::Plugin::Auth::Extensible;
2              
3 2     2   239574 use warnings;
  2         4  
  2         64  
4 2     2   7 use strict;
  2         3  
  2         47  
5              
6 2     2   5 use Carp;
  2         7  
  2         101  
7 2     2   784 use Dancer::Plugin;
  2         60296  
  2         130  
8 2     2   473 use Dancer qw(:syntax);
  2         139852  
  2         12  
9              
10             our $VERSION = '0.40';
11              
12             my $settings = plugin_setting;
13              
14             my $loginpage = $settings->{login_page} || '/login';
15             my $userhomepage = $settings->{user_home_page} || '/';
16             my $logoutpage = $settings->{logout_page} || '/logout';
17             my $deniedpage = $settings->{denied_page} || '/login/denied';
18             my $exitpage = $settings->{exit_page};
19              
20              
21             =head1 NAME
22              
23             Dancer::Plugin::Auth::Extensible - extensible authentication framework for Dancer apps
24              
25             =head1 DESCRIPTION
26              
27             A user authentication and authorisation framework plugin for Dancer apps.
28              
29             Makes it easy to require a user to be logged in to access certain routes,
30             provides role-based access control, and supports various authentication
31             methods/sources (config file, database, Unix system users, etc).
32              
33             Designed to support multiple authentication realms and to be as extensible as
34             possible, and to make secure password handling easy. The base class for auth
35             providers makes handling C-style hashed passwords really simple, so you
36             have no excuse for storing plain-text passwords. A simple script to generate
37             RFC2307-style hashed passwords is included, or you can use L
38             yourself to do so, or use the C utility if you have it installed.
39              
40              
41             =head1 SYNOPSIS
42              
43             Configure the plugin to use the authentication provider class you wish to use:
44              
45             plugins:
46             Auth::Extensible:
47             realms:
48             users:
49             provider: Example
50             ....
51              
52             The configuration you provide will depend on the authentication provider module
53             in use. For a simple example, see
54             L.
55              
56             Define that a user must be logged in and have the proper permissions to
57             access a route:
58              
59             get '/secret' => require_role Confidant => sub { tell_secrets(); };
60              
61             Define that a user must be logged in to access a route - and find out who is
62             logged in with the C keyword:
63              
64             get '/users' => require_login sub {
65             my $user = logged_in_user;
66             return "Hi there, $user->{username}";
67             };
68              
69             =head1 AUTHENTICATION PROVIDERS
70              
71             For flexibility, this authentication framework uses simple authentication
72             provider classes, which implement a simple interface and do whatever is required
73             to authenticate a user against the chosen source of authentication.
74              
75             For an example of how simple provider classes are, so you can build your own if
76             required or just try out this authentication framework plugin easily,
77             see L.
78              
79             This framework supplies the following providers out-of-the-box:
80              
81             =over 4
82              
83             =item L
84              
85             Authenticates users using system accounts on Linux/Unix type boxes
86              
87             =item L
88              
89             Authenticates users stored in a database table
90              
91             =item L
92              
93             Authenticates users stored in the app's config
94              
95             =back
96              
97             Need to write your own? Just subclass
98             L and implement the required
99             methods, and you're good to go!
100              
101             =head1 CONTROLLING ACCESS TO ROUTES
102              
103             Keywords are provided to check if a user is logged in / has appropriate roles.
104              
105             =over
106              
107             =item require_login - require the user to be logged in
108              
109             get '/dashboard' => require_login sub { .... };
110              
111             If the user is not logged in, they will be redirected to the login page URL to
112             log in. The default URL is C - this may be changed with the
113             C option.
114              
115             =item require_role - require the user to have a specified role
116              
117             get '/beer' => require_role BeerDrinker => sub { ... };
118              
119             Requires that the user be logged in as a user who has the specified role. If
120             the user is not logged in, they will be redirected to the login page URL. If
121             they are logged in, but do not have the required role, they will be redirected
122             to the access denied URL.
123              
124             =item require_any_roles - require the user to have one of a list of roles
125              
126             get '/drink' => require_any_role [qw(BeerDrinker VodaDrinker)] => sub {
127             ...
128             };
129              
130             Requires that the user be logged in as a user who has any one (or more) of the
131             roles listed. If the user is not logged in, they will be redirected to the
132             login page URL. If they are logged in, but do not have any of the specified
133             roles, they will be redirected to the access denied URL.
134              
135             =item require_all_roles - require the user to have all roles listed
136              
137             get '/foo' => require_all_roles [qw(Foo Bar)] => sub { ... };
138              
139             Requires that the user be logged in as a user who has all of the roles listed.
140             If the user is not logged in, they will be redirected to the login page URL. If
141             they are logged in but do not have all of the specified roles, they will be
142             redirected to the access denied URL.
143              
144             =back
145              
146             =head2 Replacing the Default C< /login > and C< /login/denied > Routes
147              
148             By default, the plugin adds a route to present a simple login form at that URL.
149             If you would rather add your own, set the C setting to a true
150             value, and define your own route which responds to C with a login page.
151             Alternatively you can let DPAE add the routes and handle the status codes, etc.
152             and simply define the setting C and/or
153             C with the name of a subroutine to be called to
154             handle the route. Note that it must be a fully qualified sub. E.g.
155              
156             plugins:
157             Auth::Extensible:
158             login_page_handler: 'My::App:login_page_handler'
159             permission_denied_page_handler: 'My::App:permission_denied_page_handler'
160              
161             Then in your code you might simply use a template:
162              
163             sub permission_denied_page_handler {
164             template 'account/login';
165             }
166              
167              
168             If the user is logged in, but tries to access a route which requires a specific
169             role they don't have, they will be redirected to the "permission denied" page
170             URL, which defaults to C but may be changed using the
171             C option.
172              
173             Again, by default a route is added to respond to that URL with a default page;
174             again, you can disable this by setting C and creating your
175             own.
176              
177             This would still leave the routes C and C
178             routes in place. To disable them too, set the option C
179             to a true value. In this case, these routes should be defined by the user,
180             and should do at least the following:
181              
182             post '/login' => sub {
183             my ($success, $realm) = authenticate_user(
184             params->{username}, params->{password}
185             );
186             if ($success) {
187             session logged_in_user => params->{username};
188             session logged_in_user_realm => $realm;
189             # other code here
190             } else {
191             # authentication failed
192             }
193             };
194            
195             any '/logout' => sub {
196             session->destroy;
197             };
198            
199             If you want to use the default C and C routes
200             you can configure them. See below.
201              
202             =head2 Keywords
203              
204             =over
205              
206             =item require_login
207              
208             Used to wrap a route which requires a user to be logged in order to access
209             it.
210              
211             get '/secret' => require_login sub { .... };
212              
213             =cut
214              
215             sub require_login {
216 7     7   1050 my $coderef = shift;
217             return sub {
218 14 50 33 14   30775 if (!$coderef || ref $coderef ne 'CODE') {
219 0         0 croak "Invalid require_login usage, please see docs";
220             }
221              
222 14         27 my $user = logged_in_user();
223 14 100       28 if (!$user) {
224 3         10 execute_hook('login_required', $coderef);
225             # TODO: see if any code executed by that hook set up a response
226 3         120 return redirect uri_for($loginpage, { return_url => request->request_uri });
227             }
228 11         26 return $coderef->();
229 7         26 };
230             }
231              
232             register require_login => \&require_login;
233             register requires_login => \&require_login;
234              
235             =item require_role
236              
237             Used to wrap a route which requires a user to be logged in as a user with the
238             specified role in order to access it.
239              
240             get '/beer' => require_role BeerDrinker => sub { ... };
241              
242             You can also provide a regular expression, if you need to match the role using a
243             regex - for example:
244              
245             get '/beer' => require_role qr/Drinker$/ => sub { ... };
246              
247             =cut
248             sub require_role {
249 3     3   7 return _build_wrapper(@_, 'single');
250             }
251              
252             register require_role => \&require_role;
253             register requires_role => \&require_role;
254              
255             =item require_any_role
256              
257             Used to wrap a route which requires a user to be logged in as a user with any
258             one (or more) of the specified roles in order to access it.
259              
260             get '/foo' => require_any_role [qw(Foo Bar)] => sub { ... };
261              
262             =cut
263              
264             sub require_any_role {
265 1     1   3 return _build_wrapper(@_, 'any');
266             }
267              
268             register require_any_role => \&require_any_role;
269             register requires_any_role => \&require_any_role;
270              
271             =item require_all_roles
272              
273             Used to wrap a route which requires a user to be logged in as a user with all
274             of the roles listed in order to access it.
275              
276             get '/foo' => require_all_roles [qw(Foo Bar)] => sub { ... };
277              
278             =cut
279              
280             sub require_all_roles {
281 1     1   2 return _build_wrapper(@_, 'all');
282             }
283              
284             register require_all_roles => \&require_all_roles;
285             register requires_all_roles => \&require_all_roles;
286              
287              
288             sub _build_wrapper {
289 5     5   5 my $require_role = shift;
290 5         3 my $coderef = shift;
291 5         5 my $mode = shift;
292              
293 5 100       13 my @role_list = ref $require_role eq 'ARRAY'
294             ? @$require_role
295             : $require_role;
296             return sub {
297 7     7   23686 my $user = logged_in_user();
298 7 100       17 if (!$user) {
299 2         9 execute_hook('login_required', $coderef);
300             # TODO: see if any code executed by that hook set up a response
301 2         76 return redirect uri_for($loginpage, { return_url => request->request_uri });
302             }
303              
304 5         5 my $role_match;
305 5 100       13 if ($mode eq 'single') {
    100          
    50          
306 3         6 for (user_roles()) {
307 6 100 50     9 $role_match++ and last if _smart_match($_, $require_role);
308             }
309             } elsif ($mode eq 'any') {
310 1         2 my %role_ok = map { $_ => 1 } @role_list;
  2         5  
311 1         3 for (user_roles()) {
312 2 100 50     7 $role_match++ and last if $role_ok{$_};
313             }
314             } elsif ($mode eq 'all') {
315 1         1 $role_match++;
316 1         2 for my $role (@role_list) {
317 2 50       3 if (!user_has_role($role)) {
318 0         0 $role_match = 0;
319 0         0 last;
320             }
321             }
322             }
323              
324 5 100       9 if ($role_match) {
325             # We're happy with their roles, so go head and execute the route
326             # handler coderef.
327 4         32 return $coderef->();
328             }
329              
330 1         4 execute_hook('permission_denied', $coderef);
331             # TODO: see if any code executed by that hook set up a response
332 1         38 return redirect uri_for($deniedpage, { return_url => request->request_uri });
333 5         27 };
334             }
335              
336              
337             =item logged_in_user
338              
339             Returns a hashref of details of the currently logged-in user, if there is one.
340              
341             The details you get back will depend upon the authentication provider in use.
342              
343             =cut
344              
345             sub logged_in_user {
346 28 100   28   63 if (my $user = session 'logged_in_user') {
347 18         1810 my $realm = session 'logged_in_user_realm';
348 18         1502 my $provider = auth_provider($realm);
349 18         41 return $provider->get_user_details($user, $realm);
350             } else {
351 10         1924 return;
352             }
353             }
354             register logged_in_user => \&logged_in_user;
355              
356             =item user_has_role
357              
358             Check if a user has the role named.
359              
360             By default, the currently-logged-in user will be checked, so you need only name
361             the role you're looking for:
362              
363             if (user_has_role('BeerDrinker')) { pour_beer(); }
364              
365             You can also provide the username to check;
366              
367             if (user_has_role($user, $role)) { .... }
368              
369             =cut
370              
371             sub user_has_role {
372 2     2   3 my ($username, $want_role);
373 2 50       3 if (@_ == 2) {
374 0         0 ($username, $want_role) = @_;
375             } else {
376 2         3 $username = session 'logged_in_user';
377 2         174 $want_role = shift;
378             }
379              
380 2 50       5 return unless defined $username;
381              
382 2         3 my $roles = user_roles($username);
383              
384 2         3 for my $has_role (@$roles) {
385 3 100       10 return 1 if $has_role eq $want_role;
386             }
387              
388 0         0 return 0;
389             }
390             register user_has_role => \&user_has_role;
391              
392             =item user_roles
393              
394             Returns a list of the roles of a user.
395              
396             By default, roles for the currently-logged-in user will be checked;
397             alternatively, you may supply a username to check.
398              
399             Returns a list or arrayref depending on context.
400              
401             =cut
402              
403             sub user_roles {
404 9     9   278 my ($username, $realm) = @_;
405 9 100       19 $username = session 'logged_in_user' unless defined $username;
406              
407 9 100       439 my $search_realm = ($realm ? $realm : '');
408              
409 9         11 my $roles = auth_provider($search_realm)->get_user_roles($username);
410 9 50       15 return unless defined $roles;
411 9 100       33 return wantarray ? @$roles : $roles;
412             }
413             register user_roles => \&user_roles;
414              
415              
416             =item authenticate_user
417              
418             Usually you'll want to let the built-in login handling code deal with
419             authenticating users, but in case you need to do it yourself, this keyword
420             accepts a username and password, and optionally a specific realm, and checks
421             whether the username and password are valid.
422              
423             For example:
424              
425             if (authenticate_user($username, $password)) {
426             ...
427             }
428              
429             If you are using multiple authentication realms, by default each realm will be
430             consulted in turn. If you only wish to check one of them (for instance, you're
431             authenticating an admin user, and there's only one realm which applies to them),
432             you can supply the realm as an optional third parameter.
433              
434             In boolean context, returns simply true or false; in list context, returns
435             C<($success, $realm)>.
436              
437             =cut
438              
439             sub authenticate_user {
440 5     5   9 my ($username, $password, $realm) = @_;
441              
442 5 50       14 my @realms_to_check = $realm? ($realm) : (keys %{ $settings->{realms} });
  5         22  
443              
444 5         9 for my $realm (@realms_to_check) {
445 8         34 debug "Attempting to authenticate $username against realm $realm";
446 8         339 my $provider = auth_provider($realm);
447 8 100       23 if ($provider->authenticate_user($username, $password)) {
448 4         3301 debug "$realm accepted user $username";
449 4 50       131 return wantarray ? (1, $realm) : 1;
450             }
451             }
452              
453             # If we get to here, we failed to authenticate against any realm using the
454             # details provided.
455             # TODO: allow providers to raise an exception if something failed, and catch
456             # that and do something appropriate, rather than just treating it as a
457             # failed login.
458 1 50       6 return wantarray ? (0, undef) : 0;
459             }
460              
461             register authenticate_user => \&authenticate_user;
462              
463              
464             =back
465              
466             =head2 SAMPLE CONFIGURATION
467              
468             In your application's configuation file:
469              
470             session: simple
471             plugins:
472             Auth::Extensible:
473             # Set to 1 if you want to disable the use of roles (0 is default)
474             disable_roles: 0
475             # After /login: If no return_url is given: land here ('/' is default)
476             user_home_page: '/user'
477             # After /logout: If no return_url is given: land here (no default)
478             exit_page: '/'
479            
480             # List each authentication realm, with the provider to use and the
481             # provider-specific settings (see the documentation for the provider
482             # you wish to use)
483             realms:
484             realm_one:
485             provider: Database
486             db_connection_name: 'foo'
487              
488             B that you B have a session provider configured. The
489             authentication framework requires sessions in order to track information about
490             the currently logged in user.
491             Please see L for information on how to configure session
492             management within your application.
493              
494             =cut
495              
496             # Given a realm, returns a configured and ready to use instance of the provider
497             # specified by that realm's config.
498             {
499             my %realm_provider;
500             sub auth_provider {
501 35     35 0 39 my $realm = shift;
502              
503             # If no realm was provided, but we have a logged in user, use their realm:
504 35 100 66     81 if (!$realm && session->{logged_in_user}) {
505 8         711 $realm = session->{logged_in_user_realm};
506             }
507              
508             # First, if we already have a provider for this realm, go ahead and use it:
509 35 100       793 return $realm_provider{$realm} if exists $realm_provider{$realm};
510              
511             # OK, we need to find out what provider this realm uses, and get an instance
512             # of that provider, configured with the settings from the realm.
513 2 50       11 my $realm_settings = $settings->{realms}{$realm}
514             or die "Invalid realm $realm";
515 2 50       8 my $provider_class = $realm_settings->{provider}
516             or die "No provider configured - consult documentation for "
517             . __PACKAGE__;
518              
519 2 50       10 if ($provider_class !~ /::/) {
520 2         8 $provider_class = __PACKAGE__ . "::Provider::$provider_class";
521             }
522 2         15 my ($ok, $error) = Dancer::ModuleLoader->load($provider_class);
523              
524 2 50       68 if (! $ok) {
525 0         0 die "Cannot load provider $provider_class: $error";
526             }
527              
528 2         19 return $realm_provider{$realm} = $provider_class->new($realm_settings);
529             }
530             }
531              
532             register_hook qw(login_required permission_denied);
533             register_plugin for_versions => [qw(1 2)];
534              
535              
536             # Given a class method name and a set of parameters, try calling that class
537             # method for each realm in turn, arranging for each to receive the configuration
538             # defined for that realm, until one returns a non-undef, then return the realm which
539             # succeeded and the response.
540             # Note: all provider class methods return a single value; if any need to return
541             # a list in future, this will need changing)
542             sub _try_realms {
543 0     0   0 my ($method, @args);
544 0         0 for my $realm (keys %{ $settings->{realms} }) {
  0         0  
545 0         0 my $provider = auth_provider($realm);
546 0 0       0 if (!$provider->can($method)) {
547 0         0 die "Provider $provider does not provide a $method method!";
548             }
549 0 0       0 if (defined(my $result = $provider->$method(@args))) {
550 0         0 return $result;
551             }
552             }
553 0         0 return;
554             }
555              
556             # Set up routes to serve default pages, if desired
557             if ( !$settings->{no_default_pages} ) {
558             get $loginpage => sub {
559             if(logged_in_user()) {
560             redirect params->{return_url} || $userhomepage;
561             }
562              
563             status 401;
564             my $_default_login_page =
565             $settings->{login_page_handler} || '_default_login_page';
566 2     2   3606 no strict 'refs';
  2         5  
  2         237  
567             return &{$_default_login_page}();
568             };
569             get $deniedpage => sub {
570             status 403;
571             my $_default_permission_denied_page =
572             $settings->{permission_denied_page_handler}
573             || '_default_permission_denied_page';
574 2     2   12 no strict 'refs';
  2         3  
  2         1019  
575             return &{$_default_permission_denied_page}();
576             };
577             }
578              
579              
580             # If no_login_handler is set, let the user do the login/logout herself
581             if (!$settings->{no_login_handler}) {
582              
583             # Handle logging in...
584             post $loginpage => sub {
585             # For security, ensure the username and password are straight scalars; if
586             # the app is using a serializer and we were sent a blob of JSON, they could
587             # have come from that JSON, and thus could be hashrefs (JSON SQL injection)
588             # - for database providers, feeding a carefully crafted hashref to the SQL
589             # builder could result in different SQL to what we'd expect.
590             # For instance, if we pass password => params->{password} to an SQL builder,
591             # we'd expect the query to include e.g. "WHERE password = '...'" (likely
592             # with paremeterisation) - but if params->{password} was something
593             # different, e.g. { 'like' => '%' }, we might end up with some SQL like
594             # WHERE password LIKE '%' instead - which would not be a Good Thing.
595             my ($username, $password) = @{ params() }{qw(username password)};
596             for ($username, $password) {
597             if (ref $_) {
598             # TODO: handle more cleanly
599             die "Attempt to pass a reference as username/password blocked";
600             }
601             }
602              
603             if(logged_in_user()) {
604             redirect params->{return_url} || $userhomepage;
605             }
606              
607             my ($success, $realm) = authenticate_user(
608             $username, $password
609             );
610             if ($success) {
611             session logged_in_user => $username;
612             session logged_in_user_realm => $realm;
613             redirect params->{return_url} || $userhomepage;
614             } else {
615             vars->{login_failed}++;
616             forward $loginpage, { login_failed => 1 }, { method => 'GET' };
617             }
618             };
619              
620             # ... and logging out.
621             any ['get','post'] => $logoutpage => sub {
622             session->destroy;
623             if (params->{return_url}) {
624             redirect params->{return_url};
625             } elsif ($exitpage) {
626             redirect $exitpage;
627             } else {
628             # TODO: perhaps make this more configurable, perhaps by attempting to
629             # render a template first.
630             return "OK, logged out successfully.";
631             }
632             };
633              
634             }
635              
636              
637             sub _default_permission_denied_page {
638             return <
639            

Permission Denied

640              
641            

642             Sorry, you're not allowed to access that page.
643            

644             PAGE
645 0     0   0 }
646              
647             sub _default_login_page {
648 1 50   1   2 my $login_fail_message = vars->{login_failed}
649             ? "

LOGIN FAILED

"
650             : "";
651 1   50     7 my $return_url = params->{return_url} || '';
652 1         18 return <
653            

Login Required

654              
655            

656             You need to log in to continue.
657            

658              
659             $login_fail_message
660              
661            
662            
663            
664            
665            
666            
667            
668            
669            
670            
671             PAGE
672             }
673              
674             # Replacement for much maligned and misunderstood smartmatch operator
675             sub _smart_match {
676 6     6   7 my ($got, $want) = @_;
677 6 100       12 if (!ref $want) {
    50          
    0          
678 4         11 return $got eq $want;
679             } elsif (ref $want eq 'Regexp') {
680 2         20 return $got =~ $want;
681             } elsif (ref $want eq 'ARRAY') {
682 0           return grep { $_ eq $got } @$want;
  0            
683             } else {
684 0           carp "Don't know how to match against a " . ref $want;
685             }
686             }
687              
688              
689              
690              
691             =head1 AUTHOR
692              
693             David Precious, C<< >>
694              
695              
696             =head1 BUGS / FEATURE REQUESTS
697              
698             This is an early version; there may still be bugs present or features missing.
699              
700             This is developed on GitHub - please feel free to raise issues or pull requests
701             against the repo at:
702             L
703              
704              
705              
706             =head1 ACKNOWLEDGEMENTS
707              
708             Valuable feedback on the early design of this module came from many people,
709             including Matt S Trout (mst), David Golden (xdg), Damien Krotkine (dams),
710             Daniel Perrett, and others.
711              
712             Configurable login/logout URLs added by Rene (hertell)
713              
714             Regex support for require_role by chenryn
715              
716             Support for user_roles looking in other realms by Colin Ewen (casao)
717              
718             LDAP provider added by Mark Meyer (ofosos)
719              
720             Config options for default login/logout handlers by Henk van Oers (hvoers)
721              
722             =head1 LICENSE AND COPYRIGHT
723              
724              
725             Copyright 2012-13 David Precious.
726              
727             This program is free software; you can redistribute it and/or modify it
728             under the terms of either: the GNU General Public License as published
729             by the Free Software Foundation; or the Artistic License.
730              
731             See http://dev.perl.org/licenses/ for more information.
732              
733              
734             =cut
735              
736             1; # End of Dancer::Plugin::Auth::Extensible