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