File Coverage

blib/lib/Dancer/Plugin/Auth/Extensible.pm
Criterion Covered Total %
statement 96 114 84.2
branch 49 70 70.0
condition 6 12 50.0
subroutine 19 21 90.4
pod 0 1 0.0
total 170 218 77.9


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

Permission Denied

599              
600            

601             Sorry, you're not allowed to access that page.
602            

603             PAGE
604 0     0   0 }
605              
606             sub _default_login_page {
607 1 50   1   5 my $login_fail_message = vars->{login_failed}
608             ? "

LOGIN FAILED

"
609             : "";
610 1   50     14 my $return_url = params->{return_url} || '';
611 1         29 return <
612            

Login Required

613              
614            

615             You need to log in to continue.
616            

617              
618             $login_fail_message
619              
620            
621            
622            
623            
624            
625            
626            
627            
628            
629            
630             PAGE
631             }
632              
633             # Replacement for much maligned and misunderstood smartmatch operator
634             sub _smart_match {
635 6     6   11 my ($got, $want) = @_;
636 6 100       20 if (!ref $want) {
    50          
    0          
637 4         20 return $got eq $want;
638             } elsif (ref $want eq 'Regexp') {
639 2         30 return $got =~ $want;
640             } elsif (ref $want eq 'ARRAY') {
641 0           return grep { $_ eq $got } @$want;
  0            
642             } else {
643 0           carp "Don't know how to match against a " . ref $want;
644             }
645             }
646              
647              
648              
649              
650             =head1 AUTHOR
651              
652             David Precious, C<< >>
653              
654              
655             =head1 BUGS / FEATURE REQUESTS
656              
657             This is an early version; there may still be bugs present or features missing.
658              
659             This is developed on GitHub - please feel free to raise issues or pull requests
660             against the repo at:
661             L
662              
663              
664              
665             =head1 ACKNOWLEDGEMENTS
666              
667             Valuable feedback on the early design of this module came from many people,
668             including Matt S Trout (mst), David Golden (xdg), Damien Krotkine (dams),
669             Daniel Perrett, and others.
670              
671             Configurable login/logout URLs added by Rene (hertell)
672              
673             Regex support for require_role by chenryn
674              
675             Support for user_roles looking in other realms by Colin Ewen (casao)
676              
677             LDAP provider added by Mark Meyer (ofosos)
678              
679             Config options for default login/logout handlers by Henk van Oers (hvoers)
680              
681             =head1 LICENSE AND COPYRIGHT
682              
683              
684             Copyright 2012-13 David Precious.
685              
686             This program is free software; you can redistribute it and/or modify it
687             under the terms of either: the GNU General Public License as published
688             by the Free Software Foundation; or the Artistic License.
689              
690             See http://dev.perl.org/licenses/ for more information.
691              
692              
693             =cut
694              
695             1; # End of Dancer::Plugin::Auth::Extensible