1package Dancer::Plugin::Auth::Extensible;
2
3use warnings;
4use strict;
5
6use Carp;
7use Dancer::Plugin;
8use Dancer qw(:syntax);
9
10our $VERSION = '1.00';
11
12my $settings = plugin_setting;
13
14my $loginpage = $settings->{login_page} || '/login';
15my $userhomepage = $settings->{user_home_page} || '/';
16my $logoutpage = $settings->{logout_page} || '/logout';
17my $deniedpage = $settings->{denied_page} || '/login/denied';
18my $exitpage = $settings->{exit_page};
19
20
21=head1 NAME
22
23Dancer::Plugin::Auth::Extensible - extensible authentication framework for Dancer apps
24
25=head1 DESCRIPTION
26
27A user authentication and authorisation framework plugin for Dancer apps.
28
29Makes it easy to require a user to be logged in to access certain routes,
30provides role-based access control, and supports various authentication
31methods/sources (config file, database, Unix system users, etc).
32
33Designed to support multiple authentication realms and to be as extensible as
34possible, and to make secure password handling easy.  The base class for auth
35providers makes handling C<RFC2307>-style hashed passwords really simple, so you
36have no excuse for storing plain-text passwords.  A simple script to generate
37RFC2307-style hashed passwords is included, or you can use L<Crypt::SaltedHash>
38yourself to do so, or use the C<slappasswd> utility if you have it installed.
39
40
41=head1 SYNOPSIS
42
43Configure 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
52The configuration you provide will depend on the authentication provider module
53in use.  For a simple example, see
54L<Dancer::Plugin::Auth::Extensible::Provider::Config>.
55
56Define that a user must be logged in and have the proper permissions to
57access a route:
58
59    get '/secret' => require_role Confidant => sub { tell_secrets(); };
60
61Define that a user must be logged in to access a route - and find out who is
62logged in with the C<logged_in_user> 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
71For flexibility, this authentication framework uses simple authentication
72provider classes, which implement a simple interface and do whatever is required
73to authenticate a user against the chosen source of authentication.
74
75For an example of how simple provider classes are, so you can build your own if
76required or just try out this authentication framework plugin easily,
77see L<Dancer::Plugin::Auth::Extensible::Provider::Example>.
78
79This framework supplies the following providers out-of-the-box:
80
81=over 4
82
83=item L<Dancer::Plugin::Auth::Extensible::Provider::Unix>
84
85Authenticates users using system accounts on Linux/Unix type boxes
86
87=item L<Dancer::Plugin::Auth::Extensible::Provider::Database>
88
89Authenticates users stored in a database table
90
91=item L<Dancer::Plugin::Auth::Extensible::Provider::Config>
92
93Authenticates users stored in the app's config
94
95=back
96
97Need to write your own?  Just subclass
98L<Dancer::Plugin::Auth::Extensible::Provider::Base> and implement the required
99methods, and you're good to go!
100
101=head1 CONTROLLING ACCESS TO ROUTES
102
103Keywords 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
111If the user is not logged in, they will be redirected to the login page URL to
112log in.  The default URL is C</login> - this may be changed with the
113C<login_page> option.
114
115=item require_role - require the user to have a specified role
116
117    get '/beer' => require_role BeerDrinker => sub { ... };
118
119Requires that the user be logged in as a user who has the specified role.  If
120the user is not logged in, they will be redirected to the login page URL.  If
121they are logged in, but do not have the required role, they will be redirected
122to 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
130Requires that the user be logged in as a user who has any one (or more) of the
131roles listed.  If the user is not logged in, they will be redirected to the
132login page URL.  If they are logged in, but do not have any of the specified
133roles, 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
139Requires that the user be logged in as a user who has all of the roles listed.
140If the user is not logged in, they will be redirected to the login page URL.  If
141they are logged in but do not have all of the specified roles, they will be
142redirected to the access denied URL.
143
144=back
145
146=head2 Replacing the Default C< /login > and C< /login/denied > Routes
147
148By default, the plugin adds a route to present a simple login form at that URL.
149If you would rather add your own, set the C<no_default_pages> setting to a true
150value, and define your own route which responds to C</login> with a login page.
151Alternatively you can let DPAE add the routes and handle the status codes, etc.
152and simply define the setting C<login_page_handler> and/or
153C<permission_denied_page_handler> with the name of a subroutine to be called to
154handle 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
161Then in your code you might simply use a template:
162
163    sub permission_denied_page_handler {
164        template 'account/login';
165    }
166
167
168If the user is logged in, but tries to access a route which requires a specific
169role they don't have, they will be redirected to the "permission denied" page
170URL, which defaults to C</login/denied> but may be changed using the
171C<denied_page> option.
172
173Again, by default a route is added to respond to that URL with a default page;
174again, you can disable this by setting C<no_default_pages> and creating your
175own.
176
177This would still leave the routes C<post '/login'> and C<any '/logout'>
178routes in place. To disable them too, set the option C<no_login_handler>
179to a true value. In this case, these routes should be defined by the user,
180and 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
199If you want to use the default C<post '/login'> and C<any '/logout'> routes
200you can configure them. See below.
201
202=head2 Keywords
203
204=over
205
206=item require_login
207
208Used to wrap a route which requires a user to be logged in order to access
209it.
210
211    get '/secret' => require_login sub { .... };
212
213=cut
214
215sub require_login {
216    my $coderef = shift;
217    return sub {
218        if (!$coderef || ref $coderef ne 'CODE') {
219            croak "Invalid require_login usage, please see docs";
220        }
221
222        my $user = logged_in_user();
223        if (!$user) {
224            execute_hook('login_required', $coderef);
225            # TODO: see if any code executed by that hook set up a response
226            return redirect uri_for($loginpage, { return_url => request->request_uri });
227        }
228        return $coderef->();
229    };
230}
231
232register require_login  => \&require_login;
233register requires_login => \&require_login;
234
235=item require_role
236
237Used to wrap a route which requires a user to be logged in as a user with the
238specified role in order to access it.
239
240    get '/beer' => require_role BeerDrinker => sub { ... };
241
242You can also provide a regular expression, if you need to match the role using a
243regex - for example:
244
245    get '/beer' => require_role qr/Drinker$/ => sub { ... };
246
247=cut
248sub require_role {
249    return _build_wrapper(@_, 'single');
250}
251
252register require_role  => \&require_role;
253register requires_role => \&require_role;
254
255=item require_any_role
256
257Used to wrap a route which requires a user to be logged in as a user with any
258one (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
264sub require_any_role {
265    return _build_wrapper(@_, 'any');
266}
267
268register require_any_role  => \&require_any_role;
269register requires_any_role => \&require_any_role;
270
271=item require_all_roles
272
273Used to wrap a route which requires a user to be logged in as a user with all
274of the roles listed in order to access it.
275
276    get '/foo' => require_all_roles [qw(Foo Bar)] => sub { ... };
277
278=cut
279
280sub require_all_roles {
281    return _build_wrapper(@_, 'all');
282}
283
284register require_all_roles  => \&require_all_roles;
285register requires_all_roles => \&require_all_roles;
286
287
288sub _build_wrapper {
289    my $require_role = shift;
290    my $coderef = shift;
291    my $mode = shift;
292
293    my @role_list = ref $require_role eq 'ARRAY'
294        ? @$require_role
295        : $require_role;
296    return sub {
297        my $user = logged_in_user();
298        if (!$user) {
299            execute_hook('login_required', $coderef);
300            # TODO: see if any code executed by that hook set up a response
301            return redirect uri_for($loginpage, { return_url => request->request_uri });
302        }
303
304        my $role_match;
305        if ($mode eq 'single') {
306            for (user_roles()) {
307                $role_match++ and last if _smart_match($_, $require_role);
308            }
309        } elsif ($mode eq 'any') {
310            my %role_ok = map { $_ => 1 } @role_list;
311            for (user_roles()) {
312                $role_match++ and last if $role_ok{$_};
313            }
314        } elsif ($mode eq 'all') {
315            $role_match++;
316            for my $role (@role_list) {
317                if (!user_has_role($role)) {
318                    $role_match = 0;
319                    last;
320                }
321            }
322        }
323
324        if ($role_match) {
325            # We're happy with their roles, so go head and execute the route
326            # handler coderef.
327            return $coderef->();
328        }
329
330        execute_hook('permission_denied', $coderef);
331        # TODO: see if any code executed by that hook set up a response
332        return redirect uri_for($deniedpage, { return_url => request->request_uri });
333    };
334}
335
336
337=item logged_in_user
338
339Returns a hashref of details of the currently logged-in user, if there is one.
340
341The details you get back will depend upon the authentication provider in use.
342
343=cut
344
345sub logged_in_user {
346    if (my $user = session 'logged_in_user') {
347        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        if (my $cached = vars->{dpae_user_cache}{$realm}{$user}) {
352            return $cached;
353        }
354        my $provider = auth_provider($realm);
355        my $result = $provider->get_user_details($user, $realm);
356        vars->{dpae_user_cache}{$realm}{$user} = $result;
357        return $result;
358    } else {
359        return;
360    }
361}
362register logged_in_user => \&logged_in_user;
363
364=item user_has_role
365
366Check if a user has the role named.
367
368By default, the currently-logged-in user will be checked, so you need only name
369the role you're looking for:
370
371    if (user_has_role('BeerDrinker')) { pour_beer(); }
372
373You can also provide the username to check;
374
375    if (user_has_role($user, $role)) { .... }
376
377=cut
378
379sub user_has_role {
380    my ($username, $want_role);
381    if (@_ == 2) {
382        ($username, $want_role) = @_;
383    } else {
384        $username  = session 'logged_in_user';
385        $want_role = shift;
386    }
387
388    return unless defined $username;
389
390    my $roles = user_roles($username);
391
392    for my $has_role (@$roles) {
393        return 1 if $has_role eq $want_role;
394    }
395
396    return 0;
397}
398register user_has_role => \&user_has_role;
399
400=item user_roles
401
402Returns a list of the roles of a user.
403
404By default, roles for the currently-logged-in user will be checked;
405alternatively, you may supply a username to check.
406
407Returns a list or arrayref depending on context.
408
409=cut
410
411sub user_roles {
412    my ($username, $realm) = @_;
413    $username = session 'logged_in_user' unless defined $username;
414
415    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    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        return wantarray ? @$cached : [ @$cached ];
423    }
424
425    my $roles = auth_provider($search_realm)->get_user_roles($username);
426    return unless defined $roles;
427    vars->{dpae_roles_cache}{$search_realm}{$username} = $roles;
428    return wantarray ? @$roles : $roles;
429}
430register user_roles => \&user_roles;
431
432
433=item authenticate_user
434
435Usually you'll want to let the built-in login handling code deal with
436authenticating users, but in case you need to do it yourself, this keyword
437accepts a username and password, and optionally a specific realm, and checks
438whether the username and password are valid.
439
440For example:
441
442    if (authenticate_user($username, $password)) {
443        ...
444    }
445
446If you are using multiple authentication realms, by default each realm will be
447consulted in turn.  If you only wish to check one of them (for instance, you're
448authenticating an admin user, and there's only one realm which applies to them),
449you can supply the realm as an optional third parameter.
450
451In boolean context, returns simply true or false; in list context, returns
452C<($success, $realm)>.
453
454=cut
455
456sub authenticate_user {
457    my ($username, $password, $realm) = @_;
458
459    my @realms_to_check = $realm? ($realm) : (keys %{ $settings->{realms} });
460
461    for my $realm (@realms_to_check) {
462        debug "Attempting to authenticate $username against realm $realm";
463        my $provider = auth_provider($realm);
464        if ($provider->authenticate_user($username, $password)) {
465            debug "$realm accepted user $username";
466            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    return wantarray ? (0, undef) : 0;
476}
477
478register authenticate_user => \&authenticate_user;
479
480
481=back
482
483=head2 SAMPLE CONFIGURATION
484
485In 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
505B<Please note> that you B<must> have a session provider configured.  The
506authentication framework requires sessions in order to track information about
507the currently logged in user.
508Please see L<Dancer::Session> for information on how to configure session
509management 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{
516my %realm_provider;
517sub auth_provider {
518    my $realm = shift;
519
520    # If no realm was provided, but we have a logged in user, use their realm:
521    if (!$realm && session->{logged_in_user}) {
522        $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    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    my $realm_settings = $settings->{realms}{$realm}
531        or die "Invalid realm $realm";
532    my $provider_class = $realm_settings->{provider}
533        or die "No provider configured - consult documentation for "
534            . __PACKAGE__;
535
536    if ($provider_class !~ /::/) {
537        $provider_class = __PACKAGE__ . "::Provider::$provider_class";
538    }
539    my ($ok, $error) = Dancer::ModuleLoader->load($provider_class);
540
541    if (! $ok) {
542        die "Cannot load provider $provider_class: $error";
543    }
544
545    return $realm_provider{$realm} = $provider_class->new($realm_settings);
546}
547}
548
549register_hook qw(login_required permission_denied);
550register_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)
559sub _try_realms {
560    my ($method, @args);
561    for my $realm (keys %{ $settings->{realms} }) {
562        my $provider = auth_provider($realm);
563        if (!$provider->can($method)) {
564            die "Provider $provider does not provide a $method method!";
565        }
566        if (defined(my $result = $provider->$method(@args))) {
567            return $result;
568        }
569    }
570    return;
571}
572
573# Set up routes to serve default pages, if desired
574if ( !$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        no strict 'refs';
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        no strict 'refs';
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
598if (!$settings->{no_login_handler}) {
599
600# Handle logging in...
601post $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.
638any ['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
654sub _default_permission_denied_page {
655    return <<PAGE
656<h1>Permission Denied</h1>
657
658<p>
659Sorry, you're not allowed to access that page.
660</p>
661PAGE
662}
663
664sub _default_login_page {
665    my $login_fail_message = vars->{login_failed}
666        ? "<p>LOGIN FAILED</p>"
667        : "";
668    my $return_url = params->{return_url} || '';
669    return <<PAGE;
670<h1>Login Required</h1>
671
672<p>
673You need to log in to continue.
674</p>
675
676$login_fail_message
677
678<form method="post">
679<label for="username">Username:</label>
680<input type="text" name="username" id="username">
681<br />
682<label for="password">Password:</label>
683<input type="password" name="password" id="password">
684<br />
685<input type="hidden" name="return_url" value="$return_url">
686<input type="submit" value="Login">
687</form>
688PAGE
689}
690
691# Replacement for much maligned and misunderstood smartmatch operator
692sub _smart_match {
693    my ($got, $want) = @_;
694    if (!ref $want) {
695        return $got eq $want;
696    } elsif (ref $want eq 'Regexp') {
697        return $got =~ $want;
698    } elsif (ref $want eq 'ARRAY') {
699        return grep { $_ eq $got } @$want;
700    } else {
701        carp "Don't know how to match against a " . ref $want;
702    }
703}
704
705
706
707
708=head1 AUTHOR
709
710David Precious, C<< <davidp at preshweb.co.uk> >>
711
712
713=head1 BUGS / FEATURE REQUESTS
714
715This is an early version; there may still be bugs present or features missing.
716
717This is developed on GitHub - please feel free to raise issues or pull requests
718against the repo at:
719L<https://github.com/bigpresh/Dancer-Plugin-Auth-Extensible>
720
721
722
723=head1 ACKNOWLEDGEMENTS
724
725Valuable feedback on the early design of this module came from many people,
726including Matt S Trout (mst), David Golden (xdg), Damien Krotkine (dams),
727Daniel Perrett, and others.
728
729Configurable login/logout URLs added by Rene (hertell)
730
731Regex support for require_role by chenryn
732
733Support for user_roles looking in other realms by Colin Ewen (casao)
734
735Config options for default login/logout handlers by Henk van Oers (hvoers)
736
737=head1 LICENSE AND COPYRIGHT
738
739
740Copyright 2012-16 David Precious.
741
742This program is free software; you can redistribute it and/or modify it
743under the terms of either: the GNU General Public License as published
744by the Free Software Foundation; or the Artistic License.
745
746See http://dev.perl.org/licenses/ for more information.
747
748
749=cut
750
7511; # End of Dancer::Plugin::Auth::Extensible
752