@@ -, +, @@ elements to a separate data structure. authentication route to take. Route returns the authenticated Koha::Borrower or an Exception if login failed. framework in the format the framework needs. Eg. login failed, no permission, under maintenance. ----------------------------------- -::- TEST PLAN: -::- ----------------------------------- top right of the browser window. --- C4/Auth.pm | 290 ++++++++++++------ C4/Auth_with_ldap.pm | 11 +- C4/Context.pm | 9 +- Koha/Auth.pm | 227 ++++++++++++++ Koha/Auth/Component.pm | 340 +++++++++++++++++++++ Koha/Auth/Component/Password.pm | 91 ++++++ Koha/Auth/RequestNormalizer.pm | 157 ++++++++++ Koha/Auth/Route.pm | 69 +++++ Koha/Auth/Route/Cookie.pm | 41 +++ Koha/Auth/Route/Password.pm | 42 +++ Koha/Auth/Route/RESTV1.pm | 41 +++ Koha/AuthUtils.pm | 65 +++- Koha/Borrower.pm | 26 ++ koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt | 4 +- .../opac-tmpl/bootstrap/en/modules/opac-auth.tt | 4 +- opac/opac-user.pl | 2 +- t/db_dependent/Auth_with_ldap.t | 18 +- t/db_dependent/Koha/Borrower.t | 55 ++++ 18 files changed, 1381 insertions(+), 111 deletions(-) create mode 100644 Koha/Auth.pm create mode 100644 Koha/Auth/Component.pm create mode 100644 Koha/Auth/Component/Password.pm create mode 100644 Koha/Auth/RequestNormalizer.pm create mode 100644 Koha/Auth/Route.pm create mode 100644 Koha/Auth/Route/Cookie.pm create mode 100644 Koha/Auth/Route/Password.pm create mode 100644 Koha/Auth/Route/RESTV1.pm create mode 100644 t/db_dependent/Koha/Borrower.t --- a/C4/Auth.pm +++ a/C4/Auth.pm @@ -19,7 +19,7 @@ package C4::Auth; use strict; use warnings; -use Digest::MD5 qw(md5_base64); +use Digest::MD5 qw(md5_base64); #@DEPRECATED Digest::MD5, don't use it or you will get hurt. use JSON qw/encode_json/; use URI::Escape; use CGI::Session; @@ -34,6 +34,8 @@ use C4::Branch; # GetBranches use C4::Search::History; use Koha; use Koha::AuthUtils qw(hash_password); +use Koha::Auth; +use Koha::Auth::Component; use POSIX qw/strftime/; use List::MoreUtils qw/ any /; use Encode qw( encode is_utf8); @@ -636,50 +638,64 @@ has authenticated. =cut +=head _version_check +#@DEPRECATED See Bug 7174 +use Koha::Auth::Component::* instead +=cut + sub _version_check { + #@DEPRECATED See Bug 7174 my $type = shift; my $query = shift; my $version; - # If version syspref is unavailable, it means Koha is being installed, - # and so we must redirect to OPAC maintenance page or to the WebInstaller - # also, if OpacMaintenance is ON, OPAC should redirect to maintenance - if ( C4::Context->preference('OpacMaintenance') && $type eq 'opac' ) { - warn "OPAC Install required, redirecting to maintenance"; - print $query->redirect("/cgi-bin/koha/maintenance.pl"); - safe_exit; - } - unless ( $version = C4::Context->preference('Version') ) { # assignment, not comparison - if ( $type ne 'opac' ) { - warn "Install required, redirecting to Installer"; - print $query->redirect("/cgi-bin/koha/installer/install.pl"); - } else { - warn "OPAC Install required, redirecting to maintenance"; - print $query->redirect("/cgi-bin/koha/maintenance.pl"); + try { + Koha::Auth::Component::checkOPACMaintenance() if ( $type eq 'opac' ); + Koha::Auth::Component::checkVersion(); + } catch { + if (blessed($_)) { + if ($_->isa('Koha::Exception::VersionMismatch')) { + # check that database and koha version are the same + # there is no DB version, it's a fresh install, + # go to web installer + # there is a DB version, compare it to the code version + my $warning = $_->error()." Redirecting to %s."; + if ( $type ne 'opac' ) { + warn sprintf( $warning, 'Installer' ); + print $query->redirect("/cgi-bin/koha/installer/install.pl?step=1&op=updatestructure"); + } else { + warn sprintf( "OPAC: " . $warning, 'maintenance' ); + print $query->redirect("/cgi-bin/koha/maintenance.pl"); + } + safe_exit; + } + elsif ($_->isa('Koha::Exception::ServiceTemporarilyUnavailable')) { + # also, if OpacMaintenance is ON, OPAC should redirect to maintenance + warn "OPAC Install required, redirecting to maintenance"; + print $query->redirect("/cgi-bin/koha/maintenance.pl"); + safe_exit; + } + elsif ($_->isa('Koha::Exception::BadSystemPreference')) { + # If version syspref is unavailable, it means Koha is being installed, + # and so we must redirect to OPAC maintenance page or to the WebInstaller + if ( $type ne 'opac' ) { + warn "Install required, redirecting to Installer"; + print $query->redirect("/cgi-bin/koha/installer/install.pl"); + } else { + warn "OPAC Install required, redirecting to maintenance"; + print $query->redirect("/cgi-bin/koha/maintenance.pl"); + } + safe_exit; + } + else { + warn "Unknown exception class ".ref($_)."\n"; + die $_->rethrow(); #Unhandled exception case + } } - safe_exit; - } - - # check that database and koha version are the same - # there is no DB version, it's a fresh install, - # go to web installer - # there is a DB version, compare it to the code version - my $kohaversion = Koha::version(); - - # remove the 3 last . to have a Perl number - $kohaversion =~ s/(.*\..*)\.(.*)\.(.*)/$1$2$3/; - $debug and print STDERR "kohaversion : $kohaversion\n"; - if ( $version < $kohaversion ) { - my $warning = "Database update needed, redirecting to %s. Database is $version and Koha is $kohaversion"; - if ( $type ne 'opac' ) { - warn sprintf( $warning, 'Installer' ); - print $query->redirect("/cgi-bin/koha/installer/install.pl?step=1&op=updatestructure"); - } else { - warn sprintf( "OPAC: " . $warning, 'maintenance' ); - print $query->redirect("/cgi-bin/koha/maintenance.pl"); + else { + die $_; #Not a Koha::Exception-object, so rethrow it } - safe_exit; - } + }; } sub _session_log { @@ -699,7 +715,101 @@ sub _timeout_syspref { return $timeout; } +=head checkauth + +Compatibility layer for old Koha authentication system. +Tries to authenticate using Koha::Auth, but if no authentication mechanism is +identified, falls back to the deprecated legacy behaviour. +=cut + sub checkauth { + my @params = @_; #Clone params so we don't accidentally change them if we fallback to checkauth_legacy() + my $query = shift; + my $authnotrequired = shift; + my $flagsrequired = _changeAllPermissionsMarkerToAnyPermissionMarker(shift); #circulate => 1 is deprecated, only circulate => '*' is supported. + my $type = shift; + my $persona = shift; + + ##Revert to legacy authentication for more complex authentication mechanisms. + if ($persona && $cas && $shib) { + return checkauth_legacy(@params); + } + + my ($borrower, $cookie); + try { + ($borrower, $cookie) = Koha::Auth::authenticate($query, $flagsrequired, {authnotrequired => $authnotrequired}); + } catch { + if (blessed($_)) { + if ($_->isa('Koha::Exception::VersionMismatch')) { + + my $warning = $_->error()." Redirecting to %s."; + if ( $type ne 'opac' ) { + warn sprintf( $warning, 'Installer' ); + print $query->redirect("/cgi-bin/koha/installer/install.pl?step=1&op=updatestructure"); + } else { + warn sprintf( "OPAC: " . $warning, 'maintenance' ); + print $query->redirect("/cgi-bin/koha/maintenance.pl"); + } + safe_exit; + + } + elsif ($_->isa('Koha::Exception::BadSystemPreference')) { + + if ( $type ne 'opac' ) { + warn $_->error()." Redirecting to installer."; + print $query->redirect("/cgi-bin/koha/installer/install.pl"); + } else { + warn $_->error()." Redirecting to maintenance."; + print $query->redirect("/cgi-bin/koha/maintenance.pl"); + } + safe_exit; + + } + elsif ($_->isa('Koha::Exception::LoginFailed')) { + _showLoginPage($query, $type, {}, {invalid_username_or_password => 1}, undef); + } + elsif ($_->isa('Koha::Exception::NoPermission')) { + _showLoginPage($query, $type, {}, {nopermission => 1}, undef); + } + elsif ($_->isa('Koha::Exception::Logout')) { + _showLoginPage($query, $type, {}, {}, undef); + } + elsif ($_->isa('Koha::Exception::ServiceTemporarilyUnavailable')) { + warn $_->error(); + print $query->redirect("/cgi-bin/koha/maintenance.pl"); + safe_exit; + } + else { + warn "Unknown exception class ".ref($_)."\n"; + $_->rethrow(); #Unhandled exception case + } + } + else { + die $_; #Not a Koha::Exception-object + } + }; + + if ($borrower) { #We authenticated succesfully! Emulate the legacy interface for get_template_and_user(); + my $user = $borrower->userid; + my $sessionID = $cookie->{value}->[0]; + my $aa = $borrower->userid; + my $flags = getuserflags(undef, $borrower->userid, undef) if $borrower->userid; + return ( $user, $cookie, $sessionID, $flags ); + } +} + +=head checkauth_legacy +@DEPRECATED See Bug 7174 + +We are calling this because the given authentication mechanism is not yet supported +in Koha::Auth. + +See checkauth-documentation floating somewhere in this file for info about the +legacy authentication. +=cut + +sub checkauth_legacy { + #@DEPRECATED See Bug 7174 my $query = shift; $debug and warn "Checking Auth"; @@ -721,8 +831,6 @@ sub checkauth { my ( $userid, $cookie, $sessionID, $flags, $barshelves, $pubshelves ); my $logout = $query->param('logout.x'); - my $anon_search_history; - # This parameter is the name of the CAS server we want to authenticate against, # when using authentication against multiple CAS servers, as configured in Auth_cas_servers.yaml my $casparam = $query->param('cas'); @@ -783,7 +891,6 @@ sub checkauth { #if a user enters an id ne to the id in the current session, we need to log them in... #first we need to clear the anonymous session... $debug and warn "query id = $q_userid but session id = $s_userid"; - $anon_search_history = $session->param('search_history'); $session->delete(); $session->flush; C4::Context->_unset_userenv($sessionID); @@ -864,13 +971,6 @@ sub checkauth { #we initiate a session prior to checking for a username to allow for anonymous sessions... my $session = get_session("") or die "Auth ERROR: Cannot get_session()"; - # Save anonymous search history in new session so it can be retrieved - # by get_template_and_user to store it in user's search history after - # a successful login. - if ($anon_search_history) { - $session->param( 'search_history', $anon_search_history ); - } - my $sessionID = $session->id; C4::Context->_new_userenv($sessionID); $cookie = $query->cookie( @@ -989,12 +1089,12 @@ sub checkauth { $info{'nopermission'} = 1; C4::Context->_unset_userenv($sessionID); } - my ( $borrowernumber, $firstname, $surname, $userflags, + my ( $borrowernumber, $firstname, $surname, $branchcode, $branchname, $branchprinter, $emailaddress ); if ( $return == 1 ) { my $select = " - SELECT borrowernumber, firstname, surname, flags, borrowers.branchcode, + SELECT borrowernumber, firstname, surname, borrowers.branchcode, branches.branchname as branchname, branches.branchprinter as branchprinter, email @@ -1017,10 +1117,10 @@ sub checkauth { } } if ( $sth->rows ) { - ( $borrowernumber, $firstname, $surname, $userflags, + ( $borrowernumber, $firstname, $surname, $branchcode, $branchname, $branchprinter, $emailaddress ) = $sth->fetchrow; $debug and print STDERR "AUTH_3 results: " . - "$cardnumber,$borrowernumber,$userid,$firstname,$surname,$userflags,$branchcode,$emailaddress\n"; + "$cardnumber,$borrowernumber,$userid,$firstname,$surname,$branchcode,$emailaddress\n"; } else { print STDERR "AUTH_3: no results for userid='$userid', cardnumber='$cardnumber'.\n"; } @@ -1036,7 +1136,7 @@ sub checkauth { $branchname = GetBranchName($branchcode); } my $branches = GetBranches(); - if ( C4::Context->boolean_preference('IndependentBranches') && C4::Context->boolean_preference('Autolocation') ) { + if ( C4::Context->boolean_preference('IndependentBranches') && C4::Context->boolean_preference('Autolocation') ) { #Why Autolocation cannot work without IndependetBranches?? # we have to check they are coming from the right ip range my $domain = $branches->{$branchcode}->{'branchip'}; @@ -1066,7 +1166,6 @@ sub checkauth { $session->param( 'surname', $surname ); $session->param( 'branch', $branchcode ); $session->param( 'branchname', $branchname ); - $session->param( 'flags', $userflags ); $session->param( 'emailaddress', $emailaddress ); $session->param( 'ip', $session->remote_addr() ); $session->param( 'lasttime', time() ); @@ -1149,6 +1248,12 @@ sub checkauth { # # get the inputs from the incoming query + _showLoginPage($query, $type, $cookie, \%info, $casparam); +} + +sub _showLoginPage { + my ($query, $type, $cookie, $info, $casparam) = @_; + my @inputs = (); foreach my $name ( param $query) { (next) if ( $name eq 'userid' || $name eq 'password' || $name eq 'ticket' ); @@ -1200,7 +1305,7 @@ sub checkauth { IntranetUserJS => C4::Context->preference("IntranetUserJS"), IndependentBranches => C4::Context->preference("IndependentBranches"), AutoLocation => C4::Context->preference("AutoLocation"), - wrongip => $info{'wrongip'}, + wrongip => $info->{'wrongip'}, PatronSelfRegistration => C4::Context->preference("PatronSelfRegistration"), PatronSelfRegistrationDefaultCategory => C4::Context->preference("PatronSelfRegistrationDefaultCategory"), persona => C4::Context->preference("Persona"), @@ -1208,7 +1313,7 @@ sub checkauth { ); $template->param( OpacPublic => C4::Context->preference("OpacPublic") ); - $template->param( loginprompt => 1 ) unless $info{'nopermission'}; + $template->param( loginprompt => 1 ) unless $info->{'nopermission'}; if ( $type eq 'opac' ) { require C4::VirtualShelves; @@ -1238,7 +1343,7 @@ sub checkauth { } $template->param( - invalidCasLogin => $info{'invalidCasLogin'} + invalidCasLogin => $info->{'invalidCasLogin'} ); } @@ -1254,7 +1359,7 @@ sub checkauth { url => $self_url, LibraryName => C4::Context->preference("LibraryName"), ); - $template->param(%info); + $template->param(%$info); # $cookie = $query->cookie(CGISESSID => $session->id # ); @@ -1268,6 +1373,7 @@ sub checkauth { } =head2 check_api_auth +@DEPRECATED See Bug 7174 ($status, $cookie, $sessionId) = check_api_auth($query, $userflags); @@ -1302,6 +1408,7 @@ Possible return values in C<$status> are: =cut sub check_api_auth { + #@DEPRECATED See Bug 7174 my $query = shift; my $flagsrequired = shift; @@ -1433,17 +1540,17 @@ sub check_api_auth { if ( $return == 1 ) { my ( $borrowernumber, $firstname, $surname, - $userflags, $branchcode, $branchname, + $branchcode, $branchname, $branchprinter, $emailaddress ); my $sth = $dbh->prepare( -"select borrowernumber, firstname, surname, flags, borrowers.branchcode, branches.branchname as branchname,branches.branchprinter as branchprinter, email from borrowers left join branches on borrowers.branchcode=branches.branchcode where userid=?" +"select borrowernumber, firstname, surname, borrowers.branchcode, branches.branchname as branchname,branches.branchprinter as branchprinter, email from borrowers left join branches on borrowers.branchcode=branches.branchcode where userid=?" ); $sth->execute($userid); ( $borrowernumber, $firstname, $surname, - $userflags, $branchcode, $branchname, + $branchcode, $branchname, $branchprinter, $emailaddress ) = $sth->fetchrow if ( $sth->rows ); @@ -1454,14 +1561,14 @@ sub check_api_auth { $sth->execute($cardnumber); ( $borrowernumber, $firstname, $surname, - $userflags, $branchcode, $branchname, + $branchcode, $branchname, $branchprinter, $emailaddress ) = $sth->fetchrow if ( $sth->rows ); unless ( $sth->rows ) { $sth->execute($userid); ( - $borrowernumber, $firstname, $surname, $userflags, + $borrowernumber, $firstname, $surname, $branchcode, $branchname, $branchprinter, $emailaddress ) = $sth->fetchrow if ( $sth->rows ); } @@ -1495,7 +1602,6 @@ sub check_api_auth { $session->param( 'surname', $surname ); $session->param( 'branch', $branchcode ); $session->param( 'branchname', $branchname ); - $session->param( 'flags', $userflags ); $session->param( 'emailaddress', $emailaddress ); $session->param( 'ip', $session->remote_addr() ); $session->param( 'lasttime', time() ); @@ -1529,6 +1635,7 @@ sub check_api_auth { } =head2 check_cookie_auth +@DEPRECATED See Bug 7174 ($status, $sessionId) = check_api_auth($cookie, $userflags); @@ -1556,6 +1663,7 @@ Possible return values in C<$status> are: =cut sub check_cookie_auth { + #@DEPRECATED See Bug 7174 my $cookie = shift; my $flagsrequired = shift; @@ -1679,12 +1787,13 @@ sub get_session { return $session; } +#@DEPRECATED See Bug 7174 sub checkpw { my ( $dbh, $userid, $password, $query, $type ) = @_; $type = 'opac' unless $type; if ($ldap) { $debug and print STDERR "## checkpw - checking LDAP\n"; - my ( $retval, $retcard, $retuserid ) = checkpw_ldap(@_); # EXTERNAL AUTH + my ( $retval, $retcard, $retuserid ) = checkpw_ldap($userid, $password); # EXTERNAL AUTH return 0 if $retval == -1; # Incorrect password for LDAP login attempt ($retval) and return ( $retval, $retcard, $retuserid ); } @@ -1723,6 +1832,7 @@ sub checkpw { return checkpw_internal(@_) } +#@DEPRECATED See Bug 7174 sub checkpw_internal { my ( $dbh, $userid, $password ) = @_; @@ -1748,13 +1858,13 @@ sub checkpw_internal { $sth->execute($userid); if ( $sth->rows ) { my ( $stored_hash, $cardnumber, $borrowernumber, $userid, $firstname, - $surname, $branchcode, $branchname, $flags ) + $surname, $branchcode, $branchname ) = $sth->fetchrow; if ( checkpw_hash( $password, $stored_hash ) ) { C4::Context->set_userenv( "$borrowernumber", $userid, $cardnumber, - $firstname, $surname, $branchcode, $branchname, $flags ); + $firstname, $surname, $branchcode, $branchname ); return 1, $cardnumber, $userid; } } @@ -1775,6 +1885,8 @@ sub checkpw_internal { return 1, $cardnumber, $userid; } } + + #@DEPRECATED see Bug 7174. I think the demo-user should be represented with permissions instead of a hard-coded non-borrower anomaly. if ( $userid && $userid eq 'demo' && "$password" eq 'demo' && C4::Context->config('demo') ) @@ -1787,6 +1899,7 @@ sub checkpw_internal { return 0; } +#@DEPRECATED See Bug 7174 sub checkpw_hash { my ( $password, $stored_hash ) = @_; @@ -1797,6 +1910,7 @@ sub checkpw_hash { if ( substr( $stored_hash, 0, 2 ) eq '$2' ) { $hash = hash_password( $password, $stored_hash ); } else { + #@DEPRECATED Digest::MD5, don't use it or you will get hurt. $hash = md5_base64($password); } return $hash eq $stored_hash; @@ -1820,7 +1934,6 @@ sub getuserflags { my $flags = shift; my $userid = shift; my $dbh = @_ ? shift : C4::Context->dbh; - my $userflags; { # I don't want to do this, but if someone logs in as the database # user, it would be preferable not to spam them to death with @@ -1829,28 +1942,6 @@ sub getuserflags { $flags += 0; } return get_user_subpermissions($userid); - - #@DEPRECATED, USE THE Koha::Auth::PermissionManager - my $sth = $dbh->prepare("SELECT bit, flag, defaulton FROM userflags"); - $sth->execute; - - while ( my ( $bit, $flag, $defaulton ) = $sth->fetchrow ) { - if ( ( $flags & ( 2**$bit ) ) || $defaulton ) { - $userflags->{$flag} = 1; - } - else { - $userflags->{$flag} = 0; - } - } - - # get subpermissions and merge with top-level permissions - my $user_subperms = get_user_subpermissions($userid); - foreach my $module ( keys %$user_subperms ) { - next if $userflags->{$module} == 1; # user already has permission for everything in this module - $userflags->{$module} = $user_subperms->{$module}; - } - - return $userflags; } =head2 get_user_subpermissions @@ -1952,9 +2043,7 @@ sub haspermission { my $flags = getuserflags( undef, $userid ); #Sanitate 1 to * because we no longer have 1's from the koha.borrowers.flags. - foreach my $module (%$flagsrequired) { - $flagsrequired->{$module} = '*' if $flagsrequired->{$module} && $flagsrequired->{$module} eq '1'; - } + _changeAllPermissionsMarkerToAnyPermissionMarker($flagsrequired); if ( $userid eq C4::Context->config('user') ) { @@ -1989,7 +2078,22 @@ sub haspermission { #FIXME - This fcn should return the failed permission so a suitable error msg can be delivered. } +=head +@DEPRECATED +Sanitate 1 to * because we no longer have 1's from the koha.borrowers.flags. +=cut + +sub _changeAllPermissionsMarkerToAnyPermissionMarker { + #@DEPRECATED + my ($flagsrequired) = @_; + foreach my $module (%$flagsrequired) { + $flagsrequired->{$module} = '*' if $flagsrequired->{$module} && $flagsrequired->{$module} eq '1'; + } + return $flagsrequired; +} + sub getborrowernumber { + #@DEPRECATED See Bug 7174 my ($userid) = @_; my $userenv = C4::Context->userenv; if ( defined($userenv) && ref($userenv) eq 'HASH' && $userenv->{number} ) { --- a/C4/Auth_with_ldap.pm +++ a/C4/Auth_with_ldap.pm @@ -103,8 +103,17 @@ sub search_method { return $search; } +=head checkpw_ldap + +@RETURNS Integer, -1 if login failed + , 0 if connection to the LDAP server couldn't be reliably established. + or List of (-1|1|0, $cardnumber, $local_userid); + where $cardnumber is koha.borrowers.cardnumber + $local_userid is the koha.borrowers.userid +=cut + sub checkpw_ldap { - my ($dbh, $userid, $password) = @_; + my ($userid, $password) = @_; my @hosts = split(',', $prefhost); my $db = Net::LDAP->new(\@hosts); unless ( $db ) { --- a/C4/Context.pm +++ a/C4/Context.pm @@ -1073,7 +1073,7 @@ sub userenv { C4::Context->set_userenv($usernum, $userid, $usercnum, $userfirstname, $usersurname, - $userbranch, $branchname, $userflags, + $userbranch, $branchname, $superlibrarian, $emailaddress, $branchprinter, $persona); Establish a hash of user environment variables. @@ -1085,7 +1085,7 @@ set_userenv is called in Auth.pm #' sub set_userenv { shift @_; - my ($usernum, $userid, $usercnum, $userfirstname, $usersurname, $userbranch, $branchname, $userflags, $emailaddress, $branchprinter, $persona, $shibboleth)= + my ($usernum, $userid, $usercnum, $userfirstname, $usersurname, $userbranch, $branchname, $superlibrarian, $emailaddress, $branchprinter, $persona, $shibboleth)= map { Encode::is_utf8( $_ ) ? $_ : Encode::decode('UTF-8', $_) } # CGI::Session doesn't handle utf-8, so we decode it here @_; my $var=$context->{"activeuser"} || ''; @@ -1098,7 +1098,7 @@ sub set_userenv { #possibly a law problem "branch" => $userbranch, "branchname" => $branchname, - "flags" => $userflags, + "flags" => $superlibrarian, #@DEPRECATED, use Koha::Borrower->isSuperlibrarian() instead of flags "emailaddress" => $emailaddress, "branchprinter" => $branchprinter, "persona" => $persona, @@ -1213,6 +1213,7 @@ sub tz { =head2 IsSuperLibrarian +@DEPRECATED, use Koha::Borrower->isSuperlibrarian() C4::Context->IsSuperLibrarian(); @@ -1229,7 +1230,7 @@ sub IsSuperLibrarian { return 1; } - return ($userenv->{flags}//0) % 2; + return ($userenv->{flags}//0) % 2; #If flags == 1, this is true. } =head2 interface --- a/Koha/Auth.pm +++ a/Koha/Auth.pm @@ -0,0 +1,227 @@ +package Koha::Auth; + +# Copyright 2015 Vaara-kirjastot +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +#Define common packages +use Modern::Perl; +use Scalar::Util qw(blessed); +use Try::Tiny; + +#Define Koha packages +use Koha::Auth::RequestNormalizer; +use Koha::Auth::Route::Password; +use Koha::Auth::Route::Cookie; +use Koha::Auth::Route::RESTV1; + +#Define Exceptions +use Koha::Exception::BadParameter; +use Koha::Exception::Logout; +use Koha::Exception::UnknownProgramState; + +use C4::Branch; + +#Define the headers, POST-parameters and cookies extracted from the various web-frameworks' +# request-objects and passed to the authentication system as normalized values. +our @authenticationHeaders = ('X-Koha-Date', 'Authorization'); +our @authenticationPOSTparams = ('password', 'userid', 'PT', 'branch', 'logout.x', 'koha_login_context'); +our @authenticationCookies = ('CGISESSID'); #Really we should have only one of these. + +=head authenticate + +@PARAM3 HASHRef of authentication directives. Supported values: + 'inOPAC' => 1, #Authentication context is in OPAC + 'inREST' => 'v1', #Authentication context is in REST API V1 + 'inSC' => 1, #Authentication context is in the staff client + 'authnotrequired' => 1, #Disregard all Koha::Exception::LoginFailed||NoPermission-exceptions, + #and authenticate as an anonymous user if normal authentication + #fails. +@THROWS Koha::Exception::VersionMismatch + Koha::Exception::BadSystemPreference + Koha::Exception::BadParameter + Koha::Exception::ServiceTemporarilyUnavailable + Koha::Exception::LoginFailed + Koha::Exception::NoPermission + Koha::Exception::Logout, catch this and redirect the request to the logout page. +=cut + +sub authenticate { + my ($controller, $permissions, $authParams) = @_; + my $rae = _authenticate_validateAndNormalizeParameters(@_); #Get the normalized request authentication elements + + my $borrower; #Each authentication route returns a Koha::Borrower-object on success. We use this to generate the Context() + + ##Select the Authentication route. + ##Routes are introduced in priority order, and if one matches, the other routes are ignored. + try { + #0. Logout + if ($rae->{postParams}->{'logout.x'}) { + clearUserEnvironment($rae, $authParams); + Koha::Exception::Logout->throw(error => "User logged out. Please redirect me!"); + } + #1. Check for password authentication, including LDAP. + elsif ($rae->{postParams}->{koha_login_context} && $rae->{postParams}->{userid} && $rae->{postParams}->{password}) { + $borrower = Koha::Auth::Route::Password::challenge($rae, $permissions, $authParams); + } + #2. Check for REST's signature-based authentication. + #elsif ($rae->{headers}->{'Authorization'} && $rae->{headers}->{'Authorization'} =~ /Koha/) { + elsif ($rae->{headers}->{'Authorization'}) { + $borrower = Koha::Auth::Route::RESTV1::challenge($rae, $permissions, $authParams); + } + #3. Check for the cookie. If cookies go stale, they block all subsequent authentication methods, so keep it down on this list. + elsif ($rae->{cookies}->{CGISESSID}) { + $borrower = Koha::Auth::Route::Cookie::challenge($rae, $permissions, $authParams); + } + else { #HTTP CAS ticket or shibboleth or Persona not implemented + #We don't know how to authenticate, or there is no authentication attempt. + Koha::Exception::LoginFailed->throw(error => "Koha doesn't understand your authentication protocol."); + } + } catch { + if (blessed($_)) { + if ($_->isa('Koha::Exception::LoginFailed') || $_->isa('Koha::Exception::NoPermission')) { + if ($authParams->{authnotrequired}) { #We failed to login, but we can continue anonymously. + $borrower = Koha::Borrower->new(); + } + else { + $_->rethrow(); #Anonymous login not allowed this time + } + } + else { + die $_; #Propagate other errors to the calling Controller to redirect as it wants. + } + } + else { + die $_; #Not a Koha::Exception-object + } + }; + + my $session = setUserEnvironment($controller, $rae, $borrower, $authParams); + my $cookie = Koha::Auth::RequestNormalizer::getSessionCookie($controller, $session); + + return ($borrower, $cookie); +} + +=head _authenticate_validateAndNormalizeParameters + +@PARAM1 CGI- or Mojolicious::Controller-object, this is used to identify which web framework to use. +@PARAM2 HASHRef or undef, Permissions HASH telling which Koha permissions the user must have, to access the resource. +@PARAM3 HASHRef or undef, Special authentication parameters, see authenticate() +@THROWS Koha::Exception::BadParameter, if validating parameters fails. +=cut + +sub _authenticate_validateAndNormalizeParameters { + my ($controller, $permissions, $authParams) = @_; + + #Validate $controller. + my $requestAuthElements; + if (blessed($controller) && $controller->isa('CGI')) { + $requestAuthElements = Koha::Auth::RequestNormalizer::normalizeCGI($controller, \@authenticationHeaders, \@authenticationPOSTparams, \@authenticationCookies); + } + elsif (blessed($controller) && $controller->isa('Mojolicious::Controller')) { + $requestAuthElements = Koha::Auth::RequestNormalizer::normalizeMojolicious($controller, \@authenticationHeaders, \@authenticationPOSTparams, \@authenticationCookies); + } + else { + Koha::Exception::BadParameter->throw(error => "Koha::Auth::authenticate():> The first parameter MUST be either a 'CGI'-object or a 'Mojolicious::Controller'-object"); + } + #Validate $permissions + unless (not($permissions) || (ref $permissions eq 'HASH')) { + Koha::Exception::BadParameter->throw(error => "Koha::Auth::authenticate():> The second parameter MUST be 'undef' or a HASHRef of Koha permissions. See C4::Auth::haspermission()."); + } + #Validate $authParams + unless (not($authParams) || (ref $authParams eq 'HASH')) { + Koha::Exception::BadParameter->throw(error => "Koha::Auth::authenticate():> The third parameter MUST be 'undef' or a HASHRef."); + } + + return $requestAuthElements; +} + +=head setUserEnvironment +Set the C4::Context::user_env() and CGI::Session. + +Any idea why there is both the CGI::Session and C4::Context::usernenv?? +=cut + +sub setUserEnvironment { + my ($controller, $rae, $borrower, $authParams) = @_; + + my $session = C4::Auth::get_session( $rae->{cookies}->{CGISESSID} || '' ); + C4::Context->_new_userenv( $session->id ); + + _determineUserBranch($rae, $borrower, $authParams, $session); + + #Then start setting remaining session parameters + $session->param( 'number', $borrower->borrowernumber ); + $session->param( 'id', $borrower->userid ); + $session->param( 'cardnumber', $borrower->cardnumber ); + $session->param( 'firstname', $borrower->firstname ); + $session->param( 'surname', $borrower->surname ); + $session->param( 'emailaddress', $borrower->email ); + $session->param( 'ip', $session->remote_addr() ); + $session->param( 'lasttime', time() ); + + #Finally configure the userenv. + C4::Context->set_userenv( + $session->param('number'), $session->param('id'), + $session->param('cardnumber'), $session->param('firstname'), + $session->param('surname'), $session->param('branch'), + $session->param('branchname'), undef, + $session->param('emailaddress'), $session->param('branchprinter'), + $session->param('persona'), $session->param('shibboleth') + ); + + return $session; +} + +sub _determineUserBranch { + my ($rae, $borrower, $authParams, $session) = @_; + + my ($branchcode, $branchname); + if ($rae->{postParams}->{branch}) { + #We are instructed to change the active branch + $branchcode = $rae->{postParams}->{branch}; + } + elsif ($session->param('branch')) { + ##Branch is already set so no reason to change it. + return; + } + elsif ($borrower->branchcode) { + #Default to the borrower's branch + $branchcode = $borrower->branchcode; + } + else { + #No borrower branch? This must be the superuser. + $branchcode = 'NO_LIBRARY_SET'; + $branchname = 'NO_LIBRARY_SET'; + } + $session->param( 'branch', $branchcode ); + $session->param( 'branchname', ($branchname || C4::Branch::GetBranchName($branchcode))); +} + +=head clearUserEnvironment + +Removes all active authentications +=cut + +sub clearUserEnvironment { + my ($rae, $authParams) = @_; + + my $session = C4::Auth::get_session( $rae->{cookies}->{CGISESSID} ); + $session->delete(); + $session->flush; + C4::Context->_unset_userenv( $rae->{cookies}->{CGISESSID} ); +} +1; --- a/Koha/Auth/Component.pm +++ a/Koha/Auth/Component.pm @@ -0,0 +1,340 @@ +package Koha::Auth::Component; + +# Copyright 2015 Vaara-kirjastot +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Digest::SHA qw(hmac_sha256_hex); +use DateTime::Format::HTTP; + +use Koha qw(version); +use Koha::Borrowers; +use Koha::AuthUtils; +use Koha::Auth::Component::Password; +use Koha::Auth::PermissionManager; +use C4::Auth; + +use Koha::Exception::VersionMismatch; +use Koha::Exception::BadSystemPreference; +use Koha::Exception::ServiceTemporarilyUnavailable; +use Koha::Exception::LoginFailed; + +=head1 NAME Koha::Auth::Component + +=head2 SYNOPSIS + +In this package we define the authentication steps we can use to define +authentication path behaviour. + +=head2 USAGE + + use Scalar::Util qw(blessed); + try { + ... + Koha::Auth::Component::checkVersion(); + Koha::Auth::Component::checkOPACMaintenance(); + ... + } catch { + if (blessed($_)) { + if ($_->isa('Koha::Exception::VersionMismatch')) { + ##handle exception + } + elsif ($_->isa('Koha::Exception::AnotherKindOfException')) { + ... + } + ... + else { + warn "Unknown exception class ".ref($_)."\n"; + die $_; #Unhandled exception case + } + } + else { + die $_; #Not a Koha::Exception-object + } + }; + +=cut + +=head checkVersion +STATIC + + Koha::Auth::Component::checkVersion(); + +Checks if the DB version is valid. + +@THROWS Koha::Exception::VersionMismatch, if versions do not match +@THROWS Koha::Exception::BadSystemPreference, if "Version"-syspref is not set. + This probably means that Koha has not been installed yet. +=cut + +sub checkVersion { + my $versionSyspref = C4::Context->preference('Version'); + unless ( $versionSyspref ) { + Koha::Exception::BadSystemPreference->throw(error => "No Koha 'Version'-system preference defined. Koha needs to be installed."); + } + + my $kohaversion = Koha::version(); + # remove the 3 last . to have a Perl number + $kohaversion =~ s/(.*\..*)\.(.*)\.(.*)/$1$2$3/; + if ( $versionSyspref < $kohaversion ) { + Koha::Exception::VersionMismatch->throw(error => "Database update needed. Database is 'v$versionSyspref' and Koha is 'v$kohaversion'"); + } +} + +=head checkOPACMaintenance +STATIC + + Koha::Auth::Component::checkOPACMaintenance(); + +Checks if OPAC is under maintenance. + +@THROWS Koha::Exception::ServiceTemporarilyUnavailable +=cut + +sub checkOPACMaintenance { + if ( C4::Context->preference('OpacMaintenance') ) { + Koha::Exception::ServiceTemporarilyUnavailable->throw(error => 'OPAC is under maintenance'); + } +} + +=head checkPassword +STATIC + + Koha::Auth::Component::checkPassword(); + +@RETURN Koha::Borrower-object if check succeedes, otherwise throws exceptions. +@THROWS Koha::Exception::LoginFailed from Koha::AuthUtils password checks. +=cut + +sub checkPassword { + my ($userid, $password) = @_; + + my $borrower; + if (C4::Context->config('useldapserver')) { + $borrower = Koha::Auth::Component::Password::checkLDAPPassword($userid, $password); + return $borrower if $borrower; + } + if (C4::Context->preference('casAuthentication')) { + warn("Koha::Auth doesn't support CAS-authentication yet. Please refactor the CAS client implementation to work with Koha::Auth. It cant be too hard :)"); + } + if (C4::Context->config('useshibboleth')) { + warn("Koha::Auth doesn't support Shibboleth-authentication yet. Please refactor the Shibboleth client implementation to work with Koha::Auth. It cant be too hard :)"); + } + + return Koha::Auth::Component::Password::checkKohaPassword($userid, $password); +} + +=head checkPermissions +STATIC + + Koha::Auth::Component::checkPermissions($borrower, $permissionsRequired); + +@THROWS Koha::Exception::NoPermission with the missing permission if permissions + are inadequate +=cut + +sub checkPermissions { + my ($borrower, $permissionsRequired) = @_; + + my $permissionManager = Koha::Auth::PermissionManager->new(); + $permissionManager->hasPermissions($borrower, $permissionsRequired); +} + +=head checkCookie +STATIC + + Koha::Auth::Component::checkCookie($cookieValue); + +Checks if the given authentication cookie value matches a session, and checks if +the session is still active. +@PARAM1 String, hashed session key identifying a session in koha.sessions +@RETURNS Koha::Borrower matching the verified and active session +@THROWS Koha::Exception::LoginFailed, if no session is found, + if the session has expired, + if the session IP address changes, + if no borrower was found for the session +=cut + +sub checkCookie { + my ($cookie) = @_; + + my $session = C4::Auth::get_session($cookie); + Koha::Exception::LoginFailed->throw(error => "No session matching the given session identifier '$session'.") unless $session; + + # See if the given session is timed out + if ( ($session->param('lasttime') || 0) < (time()- C4::Auth::_timeout_syspref()) ) { + $session->delete(); + $session->flush; + C4::Context::_unset_userenv($cookie); + Koha::Exception::LoginFailed->throw(error => "Session expired, please login again."); + } + # Check if we still access using the same IP than when the session was initialized. + elsif ( C4::Context->preference('SessionRestrictionByIP') && $session->param('ip') ne $ENV{'REMOTE_ADDR'} ) { + $session->delete(); + $session->flush; + C4::Context->_unset_userenv($cookie); + Koha::Exception::LoginFailed->throw(error => "Session's client address changed, please login again."); + } + + #Get the Borrower-object + my $userid = $session->param('id'); + my $borrower = Koha::AuthUtils::checkKohaSuperuserFromUserid($userid); + $borrower = Koha::Borrowers->find({userid => $userid}) unless $borrower; + Koha::Exception::LoginFailed->throw(error => "Cookie authentication succeeded, but no borrower found with userid '$userid'.") + unless $borrower; + + $session->param( 'lasttime', time() ); + return $borrower; +} + +=head checkIndependentBranchesAutolocation + +If sysprefs 'IndependentBranches' and 'Autolocation' are active, checks if the user +is in the correct network region to login. +@PARAM1 String, branchcode of the branch the current user is authenticating in to. +@THROWS Koha::Exception::LoginFailed, if the user is in the wrong network segment. +=cut + +sub checkIndependentBranchesAutolocation { + my ($currentBranchcode) = @_; + + if ( $currentBranchcode && C4::Context->boolean_preference('IndependentBranches') && C4::Context->boolean_preference('Autolocation') ) { + my $ip = $ENV{'REMOTE_ADDR'}; + + my $branches = GetBranches(); + # we have to check they are coming from the right ip range + my $domain = $branches->{$currentBranchcode}->{'branchip'}; + if ( $ip !~ /^$domain/ ) { + Koha::Exception::LoginFailed->throw(error => "Branch '$currentBranchcode' is inaccessible from this network."); + } + } +} + +=head checkRESTV1 + + my $borrower = Koha::Auth::Component::checkRESTV1(); + +For authentication to succeed, the client have to send 2 HTTP +headers: + - X-Koha-Date: the standard HTTP Date header complying to RFC 1123, simply wrapped to X-Koha-Date, + since the w3-specification forbids setting the Date-header from javascript. + - Authorization: the standard HTTP Authorization header, see below for how it is constructed. + +=head2 HTTP Request example + +GET /api/v1/borrowers/12 HTTP/1.1 +Host: api.yourkohadomain.fi +X-Koha-Date: Mon, 26 Mar 2007 19:37:58 +0000 +Authorization: Koha admin69:frJIUN8DYpKDtOLCwo//yllqDzg= + +=head2 Constructing the Authorization header + +-You brand the authorization header with "Koha" +-Then you give the userid/cardnumber of the user authenticating. +-Then the hashed signature. + +The signature is a HMAC-SHA256 hash of several elements of the request, +separated by spaces: + - HTTP method (uppercase) + - userid/cardnumber + - X-Koha-Date-header +Signed with the Borrowers API key + +The server then tries to rebuild the signature with each of the user's API keys. +If one matches the received signature, then authentication is almost OK. + +To avoid requests to be replayed, the last request's X-Koha-Date-header is stored +in database and the authentication succeeds only if the stored Date +is lesser than the X-Koha-Date-header. + +=head2 Constructing the signature example + +Signature = HMAC-SHA256-HEX("HTTPS" + " " + + "/api/v1/borrowers/12?howdoyoudo=voodoo" + " " + + "admin69" + " " + + "760818212" + " " + + "frJIUN8DYpKDtOLCwo//yllqDzg=" + ); + +=head + +@PARAM1 HASHRef of Header name => values +@PARAM2 String, upper case request method name, eg. HTTP or HTTPS +@PARAM3 String the request uri +@RETURNS Koha::Borrower if authentication succeeded. +@THROWS Koha::Exception::LoginFailed, if API key signature verification failed +@THROWS Koha::Exception::BadParameter +@THROWS Koha::Exception::UnknownObject, if we cannot find a Borrower with the given input. +=cut + +sub checkRESTV1 { + my ($headers, $method, $uri) = @_; + + my $req_dt; + eval { + $req_dt = DateTime::Format::HTTP->parse_datetime( $headers->{'X-Koha-Date'} ); #Returns DateTime + }; + my $authorizationHeader = $headers->{'Authorization'}; + my ($req_username, $req_signature); + if ($authorizationHeader =~ /^Koha (\w+?):(\w+)$/) { + $req_username = $1; + $req_signature = $2; + } + else { + Koha::Exception::BadParameter->throw(error => "Authorization HTTP-header is not well formed. It needs to be of format 'Authorization: Koha userid:signature'"); + } + unless ($req_dt) { + Koha::Exception::BadParameter->throw(error => "X-Koha-Date HTTP-header is not well formed. It needs to be of RFC 1123 -date format, eg. 'X-Koha-Date: Wed, 09 Feb 1994 22:23:32 +0200'"); + } + + my $borrower = Koha::Borrowers::castToBorrower($req_username); + + my @apikeys = Koha::ApiKeys->search({ + borrowernumber => $borrower->borrowernumber, + active => 1, + }); + Koha::Exception::LoginFailed->throw(error => "User has no API keys. Please add one using the Staff interface or OPAC.") unless @apikeys; + + my $message = join(' ', uc($method), $req_username, $headers->{'X-Koha-Date'}); + my $signature = ''; + my $matchingApiKey; + foreach my $apikey (@apikeys) { + $signature = Digest::SHA::hmac_sha256_hex($message, $apikey->api_key); + + if ($signature eq $req_signature) { + $matchingApiKey = $apikey; + last(); + } + } + + unless ($signature eq $req_signature) { + Koha::Exception::LoginFailed->throw(error => "API key authentication failed"); + } + + unless ($matchingApiKey->last_request_time < $req_dt->epoch()) { + DateTime::Format::HTTP->format_datetime($req_dt); + Koha::Exception::BadParameter->throw(error => "X-Koha-Date HTTP-header is stale, expected later date than '".DateTime::Format::HTTP->format_datetime($req_dt)."'"); + } + + $matchingApiKey->set({last_request_time => $req_dt->epoch()}); + $matchingApiKey->store(); + + return $borrower; +} + +1; --- a/Koha/Auth/Component/Password.pm +++ a/Koha/Auth/Component/Password.pm @@ -0,0 +1,91 @@ +package Koha::Auth::Component::Password; + +# Copyright 2015 Vaara-kirjastot +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Koha::AuthUtils; + +our @usernameAliasColumns = ('userid', 'cardnumber'); #Possible columns to treat as the username when authenticating. Must be UNIQUE in DB. + +=head NAME Koha::Auth::Component::Password + +=head SYNOPSIS + +This module implements the more specific behaviour of the password authentication component. + +=head checkKohaPassword + + my $borrower = Koha::AuthUtils::checkKohaPassword($userid, $password); + +Checks if the given username and password match anybody in the Koha DB +@PARAM1 String, user identifier, either the koha.borrowers.userid, or koha.borrowers.cardnumber +@PARAM2 String, clear text password from the authenticating user +@RETURN Koha::Borrower, if login succeeded. + Sets Koha::Borrower->isSuperuser() if the user is a superuser. +@THROWS Koha::Exception::LoginFailed, if no matching password was found for all username aliases in Koha. +=cut + +sub checkKohaPassword { + my ($userid, $password) = @_; + my $borrower; #Find the borrower to return + + $borrower = Koha::AuthUtils::checkKohaSuperuser($userid, $password); + return $borrower if $borrower; + + my $usernameFound = 0; #Report to the user if userid/barcode was found, even if the login failed. + #Check for each username alias if we can confirm a login with that. + for my $unameAlias (@usernameAliasColumns) { + my $borrower = Koha::Borrowers->find({$unameAlias => $userid}); + if ( $borrower ) { + $usernameFound = 1; + return $borrower if ( Koha::AuthUtils::checkHash( $password, $borrower->password ) ); + } + } + + Koha::Exception::LoginFailed->throw(error => "Password authentication failed for the given ".( ($usernameFound) ? "password" : "username and password")."."); +} + +=head checkLDAPPassword + +Checks if the given username and password match anybody in the LDAP service +@PARAM1 String, user identifier +@PARAM2 String, clear text password from the authenticating user +@RETURN Koha::Borrower, or + undef if we couldn't reliably contact the LDAP server so we should + fallback to local Koha Password authentication. +@THROWS Koha::Exception::LoginFailed, if LDAP login failed +=cut + +sub checkLDAPPassword { + my ($userid, $password) = @_; + + #Lazy load dependencies because somebody might never need them. + require C4::Auth_with_ldap; + + my ($retval, $cardnumber, $local_userid) = C4::Auth_with_ldap::checkpw_ldap($userid, $password); # EXTERNAL AUTH + if ($retval == -1) { + Koha::Exception::LoginFailed->throw(error => "LDAP authentication failed for the given username and password"); + } + + if ($retval) { + my $borrower = Koha::Borrower->find({userid => $local_userid}); + return $borrower; + } + return undef; +} --- a/Koha/Auth/RequestNormalizer.pm +++ a/Koha/Auth/RequestNormalizer.pm @@ -0,0 +1,157 @@ +package Koha::Auth::RequestNormalizer; + +# Copyright 2015 Vaara-kirjastot +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Scalar::Util qw(blessed); + + +=head normalizeCGI + +Takes a CGI-object and finds the authentication markers from it. +@PARAM1 CGI-object. +@PARAM2 ARRAYRef, authentication headers that should be extracted for authentication +@PARAM3 ARRAYRef, authentication POST parameters that should be extracted for authentication +@PARAM4 ARRAYRef, authentication cookies that should be extracted for authentication +@RETURNS List of : HASHRef of headers required for authentication, or undef + HASHRef of POST parameters required for authentication, or undef + HASHRef of the authenticaton cookie name => value, or undef +=cut + +sub normalizeCGI { + my ($controller, $authenticationHeaders, $authenticationPOSTparams, $authenticationCookies) = @_; + + my ($headers, $postParams, $cookies) = ({}, {}, {}); + foreach my $authHeader (@$authenticationHeaders) { + if (my $val = $controller->http($authHeader)) { + $headers->{$authHeader} = $val; + } + } + foreach my $authParam (@$authenticationPOSTparams) { + if (my $val = $controller->param($authParam)) { + $postParams->{$authParam} = $val; + } + } + foreach my $authCookie (@$authenticationCookies) { + if (my $val = $controller->cookie($authCookie)) { + $cookies->{$authCookie} = $val; + } + } + my $method = $1 if ($ENV{SERVER_PROTOCOL} =~ /^(.+?)\//); + my $requestAuthElements = { #Collect the authentication elements here. + headers => $headers, + postParams => $postParams, + cookies => $cookies, + method => $method, + url => $ENV{REQUEST_URI}, + }; + return $requestAuthElements; +} + +=head normalizeMojolicious + +Takes a Mojolicious::Controller-object and finds the authentication markers from it. +@PARAM1 Mojolicious::Controller-object. +@PARAM2-4 See normalizeCGI() +@RETURNS HASHRef of the request's authentication elements marked for extraction, eg: + { + headers => { X-Koha-Signature => '32rFrFw3iojsev34AS', + X-Koha-Username => 'pavlov'}, + POSTparams => { password => '1234', + userid => 'pavlov'}, + cookies => { CGISESSID => '233FADFEV3as1asS' }, + method => 'https', + url => '/borrower/12/holds' + } +=cut + +sub normalizeMojolicious { + my ($controller, $authenticationHeaders, $authenticationPOSTparams, $authenticationCookies) = @_; + + my $request = $controller->req(); + my ($headers, $postParams, $cookies) = ({}, {}, {}); + my $headersHash = $request->headers()->to_hash(); + foreach my $authHeader (@$authenticationHeaders) { + if (my $val = $headersHash->{$authHeader}) { + $headers->{$authHeader} = $val; + } + } + foreach my $authParam (@$authenticationPOSTparams) { + if (my $val = $request->param($authParam)) { + $postParams->{$authParam} = $val; + } + } + + my $requestCookies = $request->cookies; + if (scalar(@$requestCookies)) { + foreach my $authCookieName (@$authenticationCookies) { + foreach my $requestCookie (@$requestCookies) { + if ($authCookieName eq $requestCookie->name) { + $cookies->{$authCookieName} = $requestCookie->value; + } + } + } + } + + my $requestAuthElements = { #Collect the authentication elements here. + headers => $headers, + postParams => $postParams, + cookies => $cookies, + method => $controller->req->method, + url => '/' . $controller->req->url->path_query, + }; + return $requestAuthElements; +} + +=head getSessionCookie + +@PARAM1 CGI- or Mojolicious::Controller-object, this is used to identify which web framework to use. +@PARAM2 CGI::Session. +@RETURNS a Mojolicious cookie or a CGI::Cookie. +=cut + +sub getSessionCookie { + my ($controller, $session) = @_; + + my $cookie = { + name => 'CGISESSID', + value => $session->id, + }; + my $cookieOk; + + if (blessed($controller) && $controller->isa('CGI')) { + $cookie->{HttpOnly} = 1; + $cookieOk = $controller->cookie( $cookie ); + } + elsif (blessed($controller) && $controller->isa('Mojolicious::Controller')) { + $controller->res->cookies($cookie); + foreach my $c (@{$controller->res->cookies}) { + if ($c->name eq 'CGISESSID') { + $cookieOk = $c; + last; + } + } + } + unless ($cookieOk) { + Koha::Exception::UnknownProgramState->throw(error => __PACKAGE__."::getSessionCookie():> Unable to get a proper cookie?"); + } + return $cookieOk; +} + +1; --- a/Koha/Auth/Route.pm +++ a/Koha/Auth/Route.pm @@ -0,0 +1,69 @@ +package Koha::Auth::Route; + +# Copyright 2015 Vaara-kirjastot +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +=head + +=NAME Koha::Auth::Route + +=SYNOPSIS + +This is an interface definition for Koha::Auth::Route::* -subclasses. +This documentation explains how to subclass different routes. + +=USAGE + + if ($userid && $password) { + $borrower = Koha::Auth::Route::::challenge($requestAuthElements, $permissionsRequired, $routeParams); + } + +=head INPUT + +Each Route gets three parameters: + $requestAuthElements, HASHRef of HASHRefs: + headers => HASHRef of HTTP Headers matching the @authenticationHeaders-package + variable in Koha::Auth, + Eg. { 'X-Koha-Signature' => "23in4ow2gas2opcnpa", ... } + postParams => HASHRef of HTTP POST parameters matching the + @authenticationPOSTparams-package variable in Koha::Auth, + Eg. { password => '1234', 'userid' => 'admin'} + cookies => HASHRef of HTTP Cookies matching the + @authenticationPOSTparams-package variable in Koha::Auth, + EG. { CGISESSID => '9821rj1kn3tr9ff2of2ln1' } + $permissionsRequired: + HASHRef of Koha permissions. + See C4::Auth::haspermission() for example. + $routeParams: HASHRef of special Route-related data + {inOPAC => 1, authnotrequired => 0, ...} + +=head OUTPUT + +Each route must return a Koha::Borrower-object representing the authenticated user. +Even if the login succeeds with a superuser or similar virtual user, like +anonymous login, a mock Borrower-object must be returned. +If the login fails, each route must throw Koha::Exceptions to notify the cause +of the failure. + +=head ROUTE STRUCTURE + +Each route consists of Koha::Auth::Component-subroutine calls to test for various +authentication challenges. + +=cut + +1; --- a/Koha/Auth/Route/Cookie.pm +++ a/Koha/Auth/Route/Cookie.pm @@ -0,0 +1,41 @@ +package Koha::Auth::Route::Cookie; + +# Copyright 2015 Vaara-kirjastot +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Koha::Auth::Component; + +use base qw(Koha::Auth::Route); + +=head challenge +See Koha::Auth::Route, for usage documentation. +@THROWS Koha::Exceptions from authentication components. +=cut + +sub challenge { + my ($rae, $permissionsRequired, $routeParams) = @_; + + Koha::Auth::Component::checkOPACMaintenance() if $routeParams->{inOPAC}; + Koha::Auth::Component::checkVersion(); + my $borrower = Koha::Auth::Component::checkCookie($rae->{cookies}->{CGISESSID}); + Koha::Auth::Component::checkPermissions($borrower, $permissionsRequired) if $permissionsRequired; + return $borrower; +} + +1; --- a/Koha/Auth/Route/Password.pm +++ a/Koha/Auth/Route/Password.pm @@ -0,0 +1,42 @@ +package Koha::Auth::Route::Password; + +# Copyright 2015 Vaara-kirjastot +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Koha::Auth::Component; + +use base qw(Koha::Auth::Route); + +=head challenge +See Koha::Auth::Route, for usage documentation. +@THROWS Koha::Exceptions from authentication components. +=cut + +sub challenge { + my ($rae, $permissionsRequired, $routeParams) = @_; + + Koha::Auth::Component::checkOPACMaintenance() if $routeParams->{inOPAC}; + Koha::Auth::Component::checkVersion(); + Koha::Auth::Component::checkIndependentBranchesAutolocation($routeParams->{branch}); + my $borrower = Koha::Auth::Component::checkPassword($rae->{postParams}->{userid}, $rae->{postParams}->{password}); + Koha::Auth::Component::checkPermissions($borrower, $permissionsRequired) if $permissionsRequired; + return $borrower; +} + +1; --- a/Koha/Auth/Route/RESTV1.pm +++ a/Koha/Auth/Route/RESTV1.pm @@ -0,0 +1,41 @@ +package Koha::Auth::Route::RESTV1; + +# Copyright 2015 Vaara-kirjastot +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Koha::Auth::Component; + +use base qw(Koha::Auth::Route); + +=head challenge +See Koha::Auth::Route, for usage documentation. +@THROWS Koha::Exceptions from authentication components. +=cut + +sub challenge { + my ($rae, $permissionsRequired, $routeParams) = @_; + + #Koha::Auth::Component::checkRESTMaintenance() if $routeParams->{inREST}; #NOT IMPLEMENTED YET + Koha::Auth::Component::checkVersion(); + my $borrower = Koha::Auth::Component::checkRESTV1($rae->{headers}, $rae->{method}, $rae->{url}); + Koha::Auth::Component::checkPermissions($borrower, $permissionsRequired) if $permissionsRequired; + return $borrower; +} + +1; --- a/Koha/AuthUtils.pm +++ a/Koha/AuthUtils.pm @@ -24,6 +24,8 @@ use Fcntl qw/O_RDONLY/; # O_RDONLY is used in generate_salt use Koha::Borrower; +use Koha::Exception::LoginFailed; + use base 'Exporter'; our $VERSION = '1.01'; @@ -136,6 +138,66 @@ sub generate_salt { return $string; } +=head checkHash + + my $passwordOk = Koha::AuthUtils::checkHash($password1, $password2) + +Checks if a clear-text String/password matches the given hash when +MD5 or Bcrypt hashing algorith is applied to it. + +Bcrypt is applied if @PARAM2 starts with '$2' +MD5 otherwise + +@PARAM1 String, clear text passsword or any other String +@PARAM2 String, hashed text password or any other String. +@RETURN Boolean, 1 if given parameters match + , 0 if not +=cut + +sub checkHash { + my ( $password, $stored_hash ) = @_; + + $password = Encode::encode( 'UTF-8', $password ) + if Encode::is_utf8($password); + + return if $stored_hash eq '!'; + + my $hash; + if ( substr( $stored_hash, 0, 2 ) eq '$2' ) { + $hash = hash_password( $password, $stored_hash ); + } else { + #@DEPRECATED Digest::MD5, don't use it or you will get hurt. + require Digest::MD5; + $hash = Digest::MD5::md5_base64($password); + } + return $hash eq $stored_hash; +} + +=head checkKohaSuperuser + + my $borrower = Koha::AuthUtils::checkKohaSuperuser($userid, $password); + +Check if the userid and password match the ones in the $KOHA_CONF +@PARAM1 String, user identifier, either the koha.borrowers.userid, or koha.borrowers.cardnumber +@PARAM2 String, clear text password from the authenticating user +@RETURNS Koha::Borrower branded as superuser with ->isSuperuser() + or undef if user logging in is not a superuser. +@THROWS Koha::Exception::LoginFailed if user identifier matches, but password doesn't +=cut + +sub checkKohaSuperuser { + my ($userid, $password) = @_; + + if ( $userid && $userid eq C4::Context->config('user') ) { + if ( $password && $password eq C4::Context->config('pass') ) { + return _createTemporarySuperuser(); + } + else { + Koha::Exception::LoginFailed->throw(error => "Password authentication failed"); + } + } +} + =head checkKohaSuperuserFromUserid See checkKohaSuperuser(), with only the "user identifier"-@PARAM. @THROWS nothing. @@ -153,13 +215,14 @@ sub checkKohaSuperuserFromUserid { Create a temporary superuser which should be instantiated only to the environment and then discarded. So do not ->store() it! -@RETURN Koha::Borrower +@RETURN Koha::Borrower, stamped as superuser. =cut sub _createTemporarySuperuser { my $borrower = Koha::Borrower->new(); my $superuserName = C4::Context->config('user'); + $borrower->isSuperuser(1); $borrower->set({borrowernumber => 0, userid => $superuserName, cardnumber => $superuserName, --- a/Koha/Borrower.pm +++ a/Koha/Borrower.pm @@ -43,6 +43,32 @@ sub type { return 'Borrower'; } +=head isSuperuser + + $borrower->isSuperuser(1); #Set this borrower to be a superuser + if ($borrower->isSuperuser()) { + #All your base are belong to us + } + +Should be used from the authentication modules to mark this $borrower-object to +have unlimited access to all Koha-features. +This $borrower-object is the Koha DB user. +@PARAM1 Integer, 1 means this borrower is the super/DB user. + "0" disables the previously set superuserness. +=cut + +sub isSuperuser { + my ($self, $Iam) = @_; + + if (defined $Iam && $Iam == 1) { + $self->{superuser} = 1; + } + elsif (defined $Iam && $Iam eq "0") { #Dealing with zero is special in Perl + $self->{superuser} = undef; + } + return (exists($self->{superuser}) && $self->{superuser}) ? 1 : undef; +} + =head1 AUTHOR Kyle M Hall --- a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt @@ -43,7 +43,9 @@
[% FOREACH INPUT IN INPUTS %] - + [% UNLESS INPUT.name == 'logout.x' #No reason to send the logout-signal again %] + + [% END %] [% END %]

--- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt +++ a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt @@ -142,7 +142,9 @@

[% FOREACH INPUT IN INPUTS %] - + [% UNLESS INPUT.name == 'logout.x' #No reason to send the logout-signal again %] + + [% END %] [% END %] --- a/opac/opac-user.pl +++ a/opac/opac-user.pl @@ -349,7 +349,7 @@ foreach my $res (@reserves) { $template->param( WAITING => \@waiting ); # current alert subscriptions -my $alerts = getalert($borrowernumber); +my $alerts = getalert($borrowernumber) if $borrowernumber; #Superuser has no borrowernumber foreach ( @$alerts ) { $_->{ $_->{type} } = 1; $_->{relatedto} = findrelatedto( $_->{type}, $_->{externalid} ); --- a/t/db_dependent/Auth_with_ldap.t +++ a/t/db_dependent/Auth_with_ldap.t @@ -74,7 +74,7 @@ subtest "checkpw_ldap tests" => sub { ## Connection fail tests $desired_connection_result = 'error'; - warning_is { $ret = C4::Auth_with_ldap::checkpw_ldap( $dbh, 'hola', password => 'hey' ) } + warning_is { $ret = C4::Auth_with_ldap::checkpw_ldap( 'hola', password => 'hey' ) } "LDAP connexion failed", "checkpw_ldap prints correct warning if LDAP conexion fails"; is( $ret, 0, "checkpw_ldap returns 0 if LDAP conexion fails"); @@ -96,7 +96,7 @@ subtest "checkpw_ldap tests" => sub { warning_like { $ret = C4::Auth_with_ldap::checkpw_ldap( - $dbh, 'hola', password => 'hey' ) } + 'hola', password => 'hey' ) } qr/Anonymous LDAP bind failed: LDAP error #1: error_name/, "checkpw_ldap prints correct warning if LDAP anonymous bind fails"; is( $ret, 0, "checkpw_ldap returns 0 if LDAP anonymous bind fails"); @@ -108,14 +108,14 @@ subtest "checkpw_ldap tests" => sub { $desired_count_result = 0; # user auth problem $non_anonymous_bind_result = 'success'; reload_ldap_module(); - is ( C4::Auth_with_ldap::checkpw_ldap( $dbh, 'hola', password => 'hey' ), + is ( C4::Auth_with_ldap::checkpw_ldap( 'hola', password => 'hey' ), 0, "checkpw_ldap returns 0 if user lookup returns 0"); $non_anonymous_bind_result = 'error'; reload_ldap_module(); warning_like { $ret = C4::Auth_with_ldap::checkpw_ldap( - $dbh, 'hola', password => 'hey' ) } + 'hola', password => 'hey' ) } qr/LDAP bind failed as kohauser hola: LDAP error #1: error_name/, "checkpw_ldap prints correct warning if LDAP bind fails"; is ( $ret, -1, "checkpw_ldap returns -1 LDAP bind fails for user (Bug 8148)"); @@ -130,7 +130,7 @@ subtest "checkpw_ldap tests" => sub { reload_ldap_module(); warning_like { $ret = C4::Auth_with_ldap::checkpw_ldap( - $dbh, 'hola', password => 'hey' ) } + 'hola', password => 'hey' ) } qr/LDAP bind failed as kohauser hola: LDAP error #1: error_name/, "checkpw_ldap prints correct warning if LDAP bind fails"; is ( $ret, 0, "checkpw_ldap returns 0 LDAP bind fails for user (Bug 12831)"); @@ -150,7 +150,7 @@ subtest "checkpw_ldap tests" => sub { reload_ldap_module(); warning_like { $ret = C4::Auth_with_ldap::checkpw_ldap( - $dbh, 'hola', password => 'hey' ) } + 'hola', password => 'hey' ) } qr/LDAP bind failed as ldapuser cn=Manager,dc=metavore,dc=com: LDAP error #1: error_name/, "checkpw_ldap prints correct warning if LDAP bind fails"; is ( $ret, 0, "checkpw_ldap returns 0 if bind fails"); @@ -162,7 +162,7 @@ subtest "checkpw_ldap tests" => sub { reload_ldap_module(); warning_like { $ret = C4::Auth_with_ldap::checkpw_ldap( - $dbh, 'hola', password => 'hey' ) } + 'hola', password => 'hey' ) } qr/LDAP Auth rejected : invalid password for user 'hola'. LDAP error #1: error_name/, "checkpw_ldap prints correct warning if LDAP bind fails"; is ( $ret, -1, "checkpw_ldap returns -1 if bind fails (Bug 8148)"); @@ -175,7 +175,7 @@ subtest "checkpw_ldap tests" => sub { reload_ldap_module(); warning_like { $ret = C4::Auth_with_ldap::checkpw_ldap( - $dbh, 'hola', password => 'hey' ) } + 'hola', password => 'hey' ) } qr/LDAP bind failed as ldapuser cn=Manager,dc=metavore,dc=com: LDAP error #1: error_name/, "checkpw_ldap prints correct warning if LDAP bind fails"; is ( $ret, 0, "checkpw_ldap returns 0 if bind fails"); @@ -187,7 +187,7 @@ subtest "checkpw_ldap tests" => sub { reload_ldap_module(); warning_like { $ret = C4::Auth_with_ldap::checkpw_ldap( - $dbh, 'hola', password => 'hey' ) } + 'hola', password => 'hey' ) } qr/LDAP Auth rejected : invalid password for user 'hola'. LDAP error #1: error_name/, "checkpw_ldap prints correct warning if LDAP bind fails"; is ( $ret, -1, "checkpw_ldap returns -1 if bind fails (Bug 8148)"); --- a/t/db_dependent/Koha/Borrower.t +++ a/t/db_dependent/Koha/Borrower.t @@ -0,0 +1,55 @@ +#!/usr/bin/perl + +# Copyright 2015 Open Source Freedom Fighters +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Test::More; #Please don't set the test count here. It is nothing but trouble when rebasing against master and is of dubious help. + +use Koha::Borrower; + + + +testIsSuperuser(); + + + + + +################################################################################ +#### Define test subroutines here ############################################## +################################################################################ + +=head testIsSuperuser +@UNIT_TEST +Tests Koha::Borrower->isSuperuser() +=cut + +sub testIsSuperuser { + my $borrower = Koha::Borrower->new(); + ok((not(defined($borrower->isSuperuser()))), "isSuperuser(): By default user is not defined as superuser."); + ok(($borrower->isSuperuser(1) == 1), "isSuperuser(): Setting user as superuser returns 1."); + ok(($borrower->isSuperuser() == 1), "isSuperuser(): Getting superuser status from a superuser returns 1."); + ok((not(defined($borrower->isSuperuser(0)))), "isSuperuser(): Removing superuser status from a superuser OK and returns undef"); + ok((not(defined($borrower->isSuperuser()))), "isSuperuser(): Ex-superuser superuser status is undef"); +} + + + + +####################### +done_testing(); #YAY!! +####################### --