@@ -, +, @@ 0. a. % apt install -y libauth-googleauth-perl && updatedatabase && restart_all b. To test this you will need an app to generate the TOTP token, you can use FreeOTP that is open source and easy to use. 1. Turn on TwoFactorAuthentication 2. Go to your account, click 'More' > 'Manage Two-Factor authentication' 3. Click Enable, scan the QR code with the app, insert the pin code and register 4. Your account now requires 2FA to login! 5. Notice that you can browse until you logout 6. Logout 7. Enter the credential and the pincode provided by the app 8. Logout 9. Enter the credential, no pincode --- C4/Auth.pm | 125 +++++++++--- Koha/Auth/TwoFactorAuth.pm | 31 +++ cpanfile | 1 + .../prog/en/includes/members-toolbar.inc | 2 +- .../intranet-tmpl/prog/en/modules/auth.tt | 19 +- .../en/modules/members/two_factor_auth.tt | 10 +- t/db_dependent/selenium/authentication_2fa.t | 178 ++++++++++++++++++ t/lib/TestBuilder.pm | 1 + 8 files changed, 330 insertions(+), 37 deletions(-) create mode 100644 Koha/Auth/TwoFactorAuth.pm create mode 100755 t/db_dependent/selenium/authentication_2fa.t --- a/C4/Auth.pm +++ a/C4/Auth.pm @@ -32,6 +32,7 @@ use Koha; use Koha::Logger; use Koha::Caches; use Koha::AuthUtils qw( get_script_name hash_password ); +use Koha::Auth::TwoFactorAuth; use Koha::Checkouts; use Koha::DateUtils qw( dt_from_string ); use Koha::Library::Groups; @@ -843,6 +844,9 @@ sub checkauth { my $q_userid = $query->param('userid') // ''; my $session; + my $invalid_otp_token; + my $require_2FA = ( C4::Context->preference('TwoFactorAuthentication') && $type ne "OPAC" ) ? 1 : 0; + my $auth_challenge_complete; # Basic authentication is incompatible with the use of Shibboleth, # as Shibboleth may return REMOTE_USER as a Shibboleth attribute, @@ -874,13 +878,43 @@ sub checkauth { { remote_addr => $ENV{REMOTE_ADDR}, skip_version_check => 1 } ); + if ( $return eq 'ok' || $return eq 'additional-auth-needed' ) { + $userid = $session->param('id'); + } + + $additional_auth_needed = ( $return eq 'additional-auth-needed' ) ? 1 : 0; + + # We are at the second screen if the waiting-for-2FA is set in session + # and otp_token param has been passed + if ( $require_2FA + && $additional_auth_needed + && ( my $otp_token = $query->param('otp_token') ) ) + { + my $patron = Koha::Patrons->find( { userid => $userid } ); + my $auth = Koha::Auth::TwoFactorAuth::get_auth( { patron => $patron } ); + my $verified = $auth->verify($otp_token); + $auth->clear; + if ( $verified ) { + # The token is correct, the user is fully logged in! + $additional_auth_needed = 0; + $session->param( 'waiting-for-2FA', 0 ); + $return = "ok"; + $auth_challenge_complete = 1; + + # This is an ugly trick to pass the test + # $query->param('koha_login_context') && ( $q_userid ne $userid ) + # few lines later + $q_userid = $userid; + } + else { + $invalid_otp_token = 1; + } + } + if ( $return eq 'ok' ) { Koha::Logger->get->debug(sprintf "AUTH_SESSION: (%s)\t%s %s - %s", map { $session->param($_) || q{} } qw(cardnumber firstname surname branch)); - my $s_userid = $session->param('id'); - $userid = $s_userid; - - if ( ( $query->param('koha_login_context') && ( $q_userid ne $s_userid ) ) + if ( ( $query->param('koha_login_context') && ( $q_userid ne $userid ) ) || ( $cas && $query->param('ticket') && !C4::Context->userenv->{'id'} ) || ( $shib && $shib_login && !$logout && !C4::Context->userenv->{'id'} ) ) { @@ -891,25 +925,7 @@ sub checkauth { $session->delete(); $session->flush; C4::Context->_unset_userenv($sessionID); - } - elsif ($logout) { - - # voluntary logout the user - # check wether the user was using their shibboleth session or a local one - my $shibSuccess = C4::Context->userenv->{'shibboleth'}; - $session->delete(); - $session->flush; - C4::Context->_unset_userenv($sessionID); - - if ($cas and $caslogout) { - logout_cas($query, $type); - } - - # If we are in a shibboleth session (shibboleth is enabled, a shibboleth match attribute is set and matches koha matchpoint) - if ( $shib and $shib_login and $shibSuccess) { - logout_shib($query); - } - } else { + } elsif (!$logout) { $cookie = $query->cookie( -name => 'CGISESSID', @@ -939,14 +955,41 @@ sub checkauth { } } - unless ( $loggedin ) { + if ( ( !$loggedin && !$additional_auth_needed ) || $logout ) { $sessionID = undef; + $session = undef; $userid = undef; } + if ($logout) { + + # voluntary logout the user + # check wether the user was using their shibboleth session or a local one + my $shibSuccess = C4::Context->userenv->{'shibboleth'}; + if ( $session ) { + $session->delete(); + $session->flush; + } + C4::Context->_unset_userenv($sessionID); + + if ($cas and $caslogout) { + logout_cas($query, $type); + } + + # If we are in a shibboleth session (shibboleth is enabled, a shibboleth match attribute is set and matches koha matchpoint) + if ( $shib and $shib_login and $shibSuccess) { + logout_shib($query); + } + + $sessionID = undef; + $session = undef; + $userid = undef; + $additional_auth_needed = 0; + } + unless ( $userid ) { #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()"; + $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 @@ -1233,9 +1276,18 @@ sub checkauth { } } # END unless ($userid) + if ( $require_2FA && ( $loggedin && !$auth_challenge_complete)) { + my $patron = Koha::Patrons->find({userid => $userid}); + if ( $patron->auth_method eq 'two-factor' ) { + # Ask for the OTP token + $additional_auth_needed = 1; + $session->param('waiting-for-2FA', 1); + %info = ();# We remove the warnings/errors we may have set incorrectly before + } + } + # finished authentification, now respond - if ( $loggedin || $authnotrequired ) - { + if ( ( $loggedin || $authnotrequired ) && !$additional_auth_needed ) { # successful login unless ($cookie) { $cookie = $query->cookie( @@ -1269,16 +1321,17 @@ sub checkauth { # # + my $patron = Koha::Patrons->find({ userid => $q_userid }); # Not necessary logged in! + # get the inputs from the incoming query my @inputs = (); + my @inputs_to_clean = qw( userid password ticket logout.x otp_token ); foreach my $name ( param $query) { - (next) if ( $name eq 'userid' || $name eq 'password' || $name eq 'ticket' ); + next if grep { $name eq $_ } @inputs_to_clean; my @value = $query->multi_param($name); push @inputs, { name => $name, value => $_ } for @value; } - my $patron = Koha::Patrons->find({ userid => $q_userid }); # Not necessary logged in! - my $LibraryNameTitle = C4::Context->preference("LibraryName"); $LibraryNameTitle =~ s/<(?:\/?)(?:br|p)\s*(?:\/?)>/ /sgi; $LibraryNameTitle =~ s/<(?:[^<>'"]|'(?:[^']*)'|"(?:[^"]*)")*>//sg; @@ -1328,6 +1381,12 @@ sub checkauth { $template->param( SCI_login => 1 ) if ( $query->param('sci_user_login') ); $template->param( OpacPublic => C4::Context->preference("OpacPublic") ); $template->param( loginprompt => 1 ) unless $info{'nopermission'}; + if ( $additional_auth_needed ) { + $template->param( + TwoFA_prompt => 1, + invalid_otp_token => $invalid_otp_token, + ); + } if ( $type eq 'opac' ) { require Koha::Virtualshelves; @@ -1435,6 +1494,8 @@ Possible return values in C<$status> are: =item "restricted" -- The IP has changed (if SessionRestrictionByIP) +=item "additional-auth-needed -- User is in an authentication process that is not finished + =back =cut @@ -1710,6 +1771,10 @@ sub check_cookie_auth { $session->param( 'lasttime', time() ); my $flags = defined($flagsrequired) ? haspermission( $userid, $flagsrequired ) : 1; if ($flags) { + + return ( "additional-auth-needed", $session ) + if $session->param('waiting-for-2FA'); + return ( "ok", $session ); } else { $session->delete(); --- a/Koha/Auth/TwoFactorAuth.pm +++ a/Koha/Auth/TwoFactorAuth.pm @@ -0,0 +1,31 @@ +package Koha::Auth::TwoFactorAuth; + +use Modern::Perl; +use Auth::GoogleAuth; + + +sub get_auth { + my ($params) = @_; + my $patron = $params->{patron}; + my $secret = $params->{secret}; + my $secret32 = $params->{secret32}; + + if (!$secret && !$secret32){ + $secret32 = $patron->secret; + } + + my $issuer = $patron->library->branchname; + my $key_id = sprintf "%s_%s", + $issuer, ( $patron->email || $patron->userid ); + + return Auth::GoogleAuth->new( + { + ( $secret ? ( secret => $secret ) : () ), + ( $secret32 ? ( secret32 => $secret32 ) : () ), + issuer => $issuer, + key_id => $key_id, + } + ); +} + +1; --- a/cpanfile +++ a/cpanfile @@ -1,6 +1,7 @@ requires 'Algorithm::CheckDigits', '0.5'; requires 'Array::Utils', '0.5'; requires 'Authen::CAS::Client', '0.05'; +requires 'Auth::GoogleAuth', '1.02'; requires 'Biblio::EndnoteStyle', '0.05'; requires 'Business::ISBN', '2.05'; requires 'Business::ISSN', '0.91'; --- a/koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc @@ -53,7 +53,7 @@ [% END %] [% IF Koha.Preference('TwoFactorAuthentication') && logged_in_user.borrowernumber == patron.borrowernumber %] -
If you have a shibboleth account, please click here to login.
[% END %] -[% UNLESS Koha.Preference('staffShibOnly') %] +[% IF !TwoFA_prompt && !Koha.Preference('staffShibOnly') %] [% END %] [% IF ( nopermission ) %] --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt @@ -3,7 +3,7 @@ [% USE Asset %] [% SET footerjs = 1 %] [% INCLUDE 'doc-head-open.inc' %] -