File Coverage

blib/lib/Dancer/Plugin/Auth/Extensible.pm
Criterion Covered Total %
statement 110 129 85.2
branch 53 76 69.7
condition 6 12 50.0
subroutine 21 23 91.3
pod 0 1 0.0
total 190 241 78.8


line stmt bran cond sub pod time code
1             package Dancer::Plugin::Auth::Extensible;
2              
3 2     2   276695 use warnings;
  2         2  
  2         55  
4 2     2   6 use strict;
  2         3  
  2         29  
5              
6 2     2   6 use Carp;
  2         5  
  2         83  
7 2     2   883 use Dancer::Plugin;
  2         56330  
  2         128  
8 2     2   604 use Dancer qw(:syntax);
  2         107026  
  2         8  
9              
10             our $VERSION = '1.00';
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   1084 my $coderef = shift;
217             return sub {
218 14 50 33 14   39915 if (!$coderef || ref $coderef ne 'CODE') {
219 0         0 croak "Invalid require_login usage, please see docs";
220             }
221              
222 14         26 my $user = logged_in_user();
223 14 100       32 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         102 return redirect uri_for($loginpage, { return_url => request->request_uri });
227             }
228 11         33 return $coderef->();
229 7         27 };
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   5 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   2 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   1 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   6 my $require_role = shift;
290 5         3 my $coderef = shift;
291 5         5 my $mode = shift;
292              
293 5 100       10 my @role_list = ref $require_role eq 'ARRAY'
294             ? @$require_role
295             : $require_role;
296             return sub {
297 7     7   25542 my $user = logged_in_user();
298 7 100       17 if (!$user) {
299 2         7 execute_hook('login_required', $coderef);
300             # TODO: see if any code executed by that hook set up a response
301 2         67 return redirect uri_for($loginpage, { return_url => request->request_uri });
302             }
303              
304 5         29 my $role_match;
305 5 100       18 if ($mode eq 'single') {
    100          
    50          
306 3         5 for (user_roles()) {
307 6 100 50     13 $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         2 for (user_roles()) {
312 2 100 50     8 $role_match++ and last if $role_ok{$_};
313             }
314             } elsif ($mode eq 'all') {
315 1         2 $role_match++;
316 1         3 for my $role (@role_list) {
317 2 50       5 if (!user_has_role($role)) {
318 0         0 $role_match = 0;
319 0         0 last;
320             }
321             }
322             }
323              
324 5 100       10 if ($role_match) {
325             # We're happy with their roles, so go head and execute the route
326             # handler coderef.
327 4         13 return $coderef->();
328             }
329              
330 1         5 execute_hook('permission_denied', $coderef);
331             # TODO: see if any code executed by that hook set up a response
332 1         37 return redirect uri_for($deniedpage, { return_url => request->request_uri });
333 5         20 };
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 30 100   30   3580 if (my $user = session 'logged_in_user') {
347 20         2290 my $realm = session 'logged_in_user_realm';
348              
349             # First, if we've cached the details of this user earlier in this route
350             # execution in vars, just return it rather than ask the provider again
351 20 100       1768 if (my $cached = vars->{dpae_user_cache}{$realm}{$user}) {
352 2         17 return $cached;
353             }
354 18         123 my $provider = auth_provider($realm);
355 18         40 my $result = $provider->get_user_details($user, $realm);
356 18         30 vars->{dpae_user_cache}{$realm}{$user} = $result;
357 18         91 return $result;
358             } else {
359 10         1707 return;
360             }
361             }
362             register logged_in_user => \&logged_in_user;
363              
364             =item user_has_role
365              
366             Check if a user has the role named.
367              
368             By default, the currently-logged-in user will be checked, so you need only name
369             the role you're looking for:
370              
371             if (user_has_role('BeerDrinker')) { pour_beer(); }
372              
373             You can also provide the username to check;
374              
375             if (user_has_role($user, $role)) { .... }
376              
377             =cut
378              
379             sub user_has_role {
380 2     2   1 my ($username, $want_role);
381 2 50       5 if (@_ == 2) {
382 0         0 ($username, $want_role) = @_;
383             } else {
384 2         5 $username = session 'logged_in_user';
385 2         183 $want_role = shift;
386             }
387              
388 2 50       6 return unless defined $username;
389              
390 2         4 my $roles = user_roles($username);
391              
392 2         5 for my $has_role (@$roles) {
393 3 100       11 return 1 if $has_role eq $want_role;
394             }
395              
396 0         0 return 0;
397             }
398             register user_has_role => \&user_has_role;
399              
400             =item user_roles
401              
402             Returns a list of the roles of a user.
403              
404             By default, roles for the currently-logged-in user will be checked;
405             alternatively, you may supply a username to check.
406              
407             Returns a list or arrayref depending on context.
408              
409             =cut
410              
411             sub user_roles {
412 9     9   308 my ($username, $realm) = @_;
413 9 100       25 $username = session 'logged_in_user' unless defined $username;
414              
415 9 100       442 my $search_realm = ($realm ? $realm : '');
416              
417             # First, if we cached the roles they have earlier in the route execution,
418             # don't ask the provider again
419 9 100       19 if (my $cached = vars->{dpae_roles_cache}{$search_realm}{$username}) {
420             # Deref even if returning an arrayref, so calling code can't modify the
421             # cached entry
422 1 50       8 return wantarray ? @$cached : [ @$cached ];
423             }
424              
425 8         41 my $roles = auth_provider($search_realm)->get_user_roles($username);
426 8 50       17 return unless defined $roles;
427 8         13 vars->{dpae_roles_cache}{$search_realm}{$username} = $roles;
428 8 100       63 return wantarray ? @$roles : $roles;
429             }
430             register user_roles => \&user_roles;
431              
432              
433             =item authenticate_user
434              
435             Usually you'll want to let the built-in login handling code deal with
436             authenticating users, but in case you need to do it yourself, this keyword
437             accepts a username and password, and optionally a specific realm, and checks
438             whether the username and password are valid.
439              
440             For example:
441              
442             if (authenticate_user($username, $password)) {
443             ...
444             }
445              
446             If you are using multiple authentication realms, by default each realm will be
447             consulted in turn. If you only wish to check one of them (for instance, you're
448             authenticating an admin user, and there's only one realm which applies to them),
449             you can supply the realm as an optional third parameter.
450              
451             In boolean context, returns simply true or false; in list context, returns
452             C<($success, $realm)>.
453              
454             =cut
455              
456             sub authenticate_user {
457 5     5   8 my ($username, $password, $realm) = @_;
458              
459 5 50       25 my @realms_to_check = $realm? ($realm) : (keys %{ $settings->{realms} });
  5         30  
460              
461 5         10 for my $realm (@realms_to_check) {
462 8         38 debug "Attempting to authenticate $username against realm $realm";
463 8         336 my $provider = auth_provider($realm);
464 8 100       30 if ($provider->authenticate_user($username, $password)) {
465 4         3440 debug "$realm accepted user $username";
466 4 50       156 return wantarray ? (1, $realm) : 1;
467             }
468             }
469              
470             # If we get to here, we failed to authenticate against any realm using the
471             # details provided.
472             # TODO: allow providers to raise an exception if something failed, and catch
473             # that and do something appropriate, rather than just treating it as a
474             # failed login.
475 1 50       5 return wantarray ? (0, undef) : 0;
476             }
477              
478             register authenticate_user => \&authenticate_user;
479              
480              
481             =back
482              
483             =head2 SAMPLE CONFIGURATION
484              
485             In your application's configuation file:
486              
487             session: simple
488             plugins:
489             Auth::Extensible:
490             # Set to 1 if you want to disable the use of roles (0 is default)
491             disable_roles: 0
492             # After /login: If no return_url is given: land here ('/' is default)
493             user_home_page: '/user'
494             # After /logout: If no return_url is given: land here (no default)
495             exit_page: '/'
496            
497             # List each authentication realm, with the provider to use and the
498             # provider-specific settings (see the documentation for the provider
499             # you wish to use)
500             realms:
501             realm_one:
502             provider: Database
503             db_connection_name: 'foo'
504              
505             B that you B have a session provider configured. The
506             authentication framework requires sessions in order to track information about
507             the currently logged in user.
508             Please see L for information on how to configure session
509             management within your application.
510              
511             =cut
512              
513             # Given a realm, returns a configured and ready to use instance of the provider
514             # specified by that realm's config.
515             {
516             my %realm_provider;
517             sub auth_provider {
518 34     34 0 33 my $realm = shift;
519              
520             # If no realm was provided, but we have a logged in user, use their realm:
521 34 50 66     80 if (!$realm && session->{logged_in_user}) {
522 7         585 $realm = session->{logged_in_user_realm};
523             }
524              
525             # First, if we already have a provider for this realm, go ahead and use it:
526 34 100       673 return $realm_provider{$realm} if exists $realm_provider{$realm};
527              
528             # OK, we need to find out what provider this realm uses, and get an instance
529             # of that provider, configured with the settings from the realm.
530 2 50       6 my $realm_settings = $settings->{realms}{$realm}
531             or die "Invalid realm $realm";
532             my $provider_class = $realm_settings->{provider}
533 2 50       6 or die "No provider configured - consult documentation for "
534             . __PACKAGE__;
535              
536 2 50       6 if ($provider_class !~ /::/) {
537 2         6 $provider_class = __PACKAGE__ . "::Provider::$provider_class";
538             }
539 2         11 my ($ok, $error) = Dancer::ModuleLoader->load($provider_class);
540              
541 2 50       44 if (! $ok) {
542 0         0 die "Cannot load provider $provider_class: $error";
543             }
544              
545 2         13 return $realm_provider{$realm} = $provider_class->new($realm_settings);
546             }
547             }
548              
549             register_hook qw(login_required permission_denied);
550             register_plugin for_versions => [qw(1 2)];
551              
552              
553             # Given a class method name and a set of parameters, try calling that class
554             # method for each realm in turn, arranging for each to receive the configuration
555             # defined for that realm, until one returns a non-undef, then return the realm which
556             # succeeded and the response.
557             # Note: all provider class methods return a single value; if any need to return
558             # a list in future, this will need changing)
559             sub _try_realms {
560 0     0   0 my ($method, @args);
561 0         0 for my $realm (keys %{ $settings->{realms} }) {
  0         0  
562 0         0 my $provider = auth_provider($realm);
563 0 0       0 if (!$provider->can($method)) {
564 0         0 die "Provider $provider does not provide a $method method!";
565             }
566 0 0       0 if (defined(my $result = $provider->$method(@args))) {
567 0         0 return $result;
568             }
569             }
570 0         0 return;
571             }
572              
573             # Set up routes to serve default pages, if desired
574             if ( !$settings->{no_default_pages} ) {
575             get $loginpage => sub {
576             if(logged_in_user()) {
577             redirect params->{return_url} || $userhomepage;
578             }
579              
580             status 401;
581             my $_default_login_page =
582             $settings->{login_page_handler} || '_default_login_page';
583 2     2   2730 no strict 'refs';
  2         4  
  2         126  
584             return &{$_default_login_page}();
585             };
586             get $deniedpage => sub {
587             status 403;
588             my $_default_permission_denied_page =
589             $settings->{permission_denied_page_handler}
590             || '_default_permission_denied_page';
591 2     2   8 no strict 'refs';
  2         2  
  2         717  
592             return &{$_default_permission_denied_page}();
593             };
594             }
595              
596              
597             # If no_login_handler is set, let the user do the login/logout herself
598             if (!$settings->{no_login_handler}) {
599              
600             # Handle logging in...
601             post $loginpage => sub {
602             # For security, ensure the username and password are straight scalars; if
603             # the app is using a serializer and we were sent a blob of JSON, they could
604             # have come from that JSON, and thus could be hashrefs (JSON SQL injection)
605             # - for database providers, feeding a carefully crafted hashref to the SQL
606             # builder could result in different SQL to what we'd expect.
607             # For instance, if we pass password => params->{password} to an SQL builder,
608             # we'd expect the query to include e.g. "WHERE password = '...'" (likely
609             # with paremeterisation) - but if params->{password} was something
610             # different, e.g. { 'like' => '%' }, we might end up with some SQL like
611             # WHERE password LIKE '%' instead - which would not be a Good Thing.
612             my ($username, $password) = @{ params() }{qw(username password)};
613             for ($username, $password) {
614             if (ref $_) {
615             # TODO: handle more cleanly
616             die "Attempt to pass a reference as username/password blocked";
617             }
618             }
619              
620             if(logged_in_user()) {
621             redirect params->{return_url} || $userhomepage;
622             }
623              
624             my ($success, $realm) = authenticate_user(
625             $username, $password
626             );
627             if ($success) {
628             session logged_in_user => $username;
629             session logged_in_user_realm => $realm;
630             redirect params->{return_url} || $userhomepage;
631             } else {
632             vars->{login_failed}++;
633             forward $loginpage, { login_failed => 1 }, { method => 'GET' };
634             }
635             };
636              
637             # ... and logging out.
638             any ['get','post'] => $logoutpage => sub {
639             session->destroy;
640             if (params->{return_url}) {
641             redirect params->{return_url};
642             } elsif ($exitpage) {
643             redirect $exitpage;
644             } else {
645             # TODO: perhaps make this more configurable, perhaps by attempting to
646             # render a template first.
647             return "OK, logged out successfully.";
648             }
649             };
650              
651             }
652              
653              
654             sub _default_permission_denied_page {
655             return <
656            

Permission Denied

657              
658            

659             Sorry, you're not allowed to access that page.
660            

661             PAGE
662 0     0   0 }
663              
664             sub _default_login_page {
665             my $login_fail_message = vars->{login_failed}
666 1 50   1   5 ? "

LOGIN FAILED

"
667             : "";
668 1   50     10 my $return_url = params->{return_url} || '';
669 1         29 return <
670            

Login Required

671              
672            

673             You need to log in to continue.
674            

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