diff --git a/C4/Auth.pm b/C4/Auth.pm index 0714861115..9ebc3aee43 100644 --- a/C4/Auth.pm +++ b/C4/Auth.pm @@ -35,6 +35,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; @@ -857,6 +858,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, @@ -889,13 +893,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'} ) ) { @@ -905,29 +939,10 @@ sub checkauth { $anon_search_history = $session->param('search_history'); $session->delete(); $session->flush; - C4::Context::_unset_userenv($sessionID); - $sessionID = undef; - } - 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; $cookie = $cookie_mgr->clear_unless( $query->cookie, @$cookie ); C4::Context::_unset_userenv($sessionID); $sessionID = undef; - - 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 = $cookie_mgr->replace_in_list( $cookie, $query->cookie( -name => 'CGISESSID', @@ -955,10 +970,36 @@ sub checkauth { } } - unless ( $loggedin ) { + if ( ( !$loggedin && !$additional_auth_needed ) || $logout ) { + $sessionID = 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); + $cookie = $cookie_mgr->clear_unless( $query->cookie, @$cookie ); + + 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); + } + + $session = undef; + $additional_auth_needed = 0; + } + unless ( $userid ) { #we initiate a session prior to checking for a username to allow for anonymous sessions... if( !$session or !$sessionID ) { # if we cleared sessionID, we need a new session @@ -1260,9 +1301,18 @@ sub checkauth { $session->flush; } # 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 = $cookie_mgr->replace_in_list( $cookie, $query->cookie( @@ -1297,16 +1347,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; @@ -1354,6 +1405,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; @@ -1463,6 +1520,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 @@ -1740,6 +1799,9 @@ sub check_cookie_auth { $session->param('desk_id'), $session->param('desk_name'), $session->param('register_id'), $session->param('register_name') ); + return ( "additional-auth-needed", $session ) + if $session->param('waiting-for-2FA'); + return ( "ok", $session ); } else { $session->delete(); diff --git a/Koha/Auth/TwoFactorAuth.pm b/Koha/Auth/TwoFactorAuth.pm new file mode 100644 index 0000000000..c08736f506 --- /dev/null +++ b/Koha/Auth/TwoFactorAuth.pm @@ -0,0 +1,65 @@ +package Koha::Auth::TwoFactorAuth; + +# 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 Auth::GoogleAuth; + +use base qw( Auth::GoogleAuth ); + +=head1 NAME + +Koha::Auth::TwoFactorAuth- Koha class deal with Two factor authentication + +=head1 SYNOPSIS + +use Koha::Auth::TwoFactorAuth; + +my $secret = Koha::AuthUtils::generate_salt( 'weak', 16 ); +my $auth = Koha::Auth::TwoFactorAuth->new( + { patron => $patron, secret => $secret } ); +my $secret32 = $auth->generate_secret32; +my $ok = $auth->verify($pin_code, 1, $secret32); + +It's based on Auth::GoogleAuth + +=cut + +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; diff --git a/cpanfile b/cpanfile index c9b4c5117d..636e303e98 100644 --- a/cpanfile +++ b/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'; diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc index 9a16f32712..0466779a60 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc +++ b/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 %] -
  • Enable Two-Factor Authentication
  • +
  • Manage two-factor authentication
  • [% END %] [% IF CAN_user_borrowers_edit_borrowers && useDischarge %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt index 499c5bdbd3..944942498b 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt @@ -8,6 +8,7 @@ [% SET footerjs = 1 %] [% INCLUDE 'doc-head-open.inc' %] + [% IF TwoFA_prompt %]Two-factor authentication[% END %] [% IF ( loginprompt ) %]Log in to Koha[% END %] [% IF too_many_login_attempts %]This account has been locked. [% ELSIF invalid_username_or_password %]Invalid username or password[% END %] @@ -61,7 +62,7 @@ <p>If you have a shibboleth account, please <a href="[% shibbolethLoginUrl | $raw %]">click here</a> to login.</p> [% END %] -[% UNLESS Koha.Preference('staffShibOnly') %] +[% IF !TwoFA_prompt && !Koha.Preference('staffShibOnly') %] <!-- login prompt time--> <form action="[% script_name | html %]" method="post" name="loginform" id="loginform"> <input type="hidden" name="koha_login_context" value="intranet" /> @@ -135,6 +136,22 @@ [% END %] [% END %] [% END %] +[% ELSIF TwoFA_prompt %] + <form action="[% script_name | html %]" method="post" name="loginform" id="loginform"> + <input type="hidden" name="koha_login_context" value="intranet" /> + [% FOREACH INPUT IN INPUTS %] + <input type="hidden" name="[% INPUT.name | html %]" value="[% INPUT.value | html %]" /> + [% END %] + [% IF invalid_otp_token %] + <div class="dialog error">Invalid two-factor code</div> + [% END %] + + <p><label for="otp_token">Two-factor authentication code:</label> + <input type="text" name="otp_token" id="otp_token" class="input focus" value="" size="20" tabindex="1" /> + <p class="submit"><input id="submit-button" type="submit" value="Verify code" /></p> + <a class="logout" id="logout" href="/cgi-bin/koha/mainpage.pl?logout.x=1">Log out</a> + + </form> [% END %] [% IF ( nopermission ) %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt index 999f6f9fb6..d067c2dcde 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt +++ b/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' %] -<title>Two-Factor Authentication › Patrons › Koha +Two-factor authentication › Patrons › Koha [% INCLUDE 'doc-head-close.inc' %] @@ -34,7 +34,7 @@ [% INCLUDE 'members-toolbar.inc' %] [% IF op == 'register' %] -

    Register Two-Factor Authenticator

    +

    Register two-factor authenticator

    We recommend cloud-based mobile authenticator apps such as Authy, Duo Mobile, and LastPass. They can restore access if you lose your hardware device.

    Can't scan the code?

    @@ -69,14 +69,14 @@ [% ELSE %] -

    Two-Factor Authentication

    +

    Two-factor authentication

    [% IF patron.auth_method == "two-factor" %]
    Status: Enabled
    - +
    [% ELSE %]
    Status: Disabled
    @@ -84,7 +84,7 @@
    - +
    [% END %] diff --git a/t/db_dependent/selenium/authentication_2fa.t b/t/db_dependent/selenium/authentication_2fa.t new file mode 100755 index 0000000000..ebc0c7f991 --- /dev/null +++ b/t/db_dependent/selenium/authentication_2fa.t @@ -0,0 +1,178 @@ +#!/usr/bin/perl + +# 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 Test::More tests => 2; + +use C4::Context; +use Koha::AuthUtils; +use Koha::Auth::TwoFactorAuth; +use t::lib::Mocks; +use t::lib::Selenium; +use t::lib::TestBuilder; + +my @data_to_cleanup; +my $pref_value = C4::Context->preference('TwoFactorAuthentication'); + +SKIP: { + eval { require Selenium::Remote::Driver; }; + skip "Selenium::Remote::Driver is needed for selenium tests.", 2 if $@; + + my $builder = t::lib::TestBuilder->new; + + my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => 1 }}); + $patron->flags(1)->store; # superlibrarian permission + my $password = Koha::AuthUtils::generate_password($patron->category); + t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 ); + $patron->set_password({ password => $password }); + + push @data_to_cleanup, $patron, $patron->category, $patron->library; + + my $s = t::lib::Selenium->new({ login => $patron->userid, password => $password }); + my $driver = $s->driver; + + subtest 'Setup' => sub { + plan tests => 10; + + my $mainpage = $s->base_url . q|mainpage.pl|; + $driver->get($mainpage); + like( $driver->get_title, qr(Log in to Koha), 'Hitting the main page should redirect to the login form'); + + fill_login_form($s); + like( $driver->get_title, qr(Koha staff interface), 'Patron with flags superlibrarian should be able to login' ); + + C4::Context->set_preference('TwoFactorAuthentication', 0); + $driver->get($s->base_url . q|members/two_factor_auth.pl|); + like( $driver->get_title, qr(Error 404), 'Must be redirected to 404 is the pref is off' ); + + C4::Context->set_preference('TwoFactorAuthentication', 1); + $driver->get($s->base_url . q|members/two_factor_auth.pl|); + like( $driver->get_title, qr(Two-factor authentication), 'Must be on the page with the pref on' ); + + is( $driver->find_element('//div[@class="two-factor-status"]')->get_text(), 'Status: Disabled', '2FA is disabled' ); + + $driver->find_element('//form[@id="two-factor-auth"]//input[@type="submit"]')->click; + ok($driver->find_element('//img[@id="qr_code"]'), 'There is a QR code'); + + $s->fill_form({pin_code => 'wrong_code'}); + $s->submit_form; + ok($driver->find_element('//div[@class="dialog error"][contains(text(), "Invalid pin code")]')); + is( $patron->get_from_storage->secret, undef, 'secret is not set in DB yet' ); + + my $secret32 = $driver->find_element('//form[@id="two-factor-auth"]//input[@name="secret32"]')->get_value(); + my $auth = Koha::Auth::TwoFactorAuth->new({patron => $patron, secret32 => $secret32}); + my $code = $auth->code(); + $s->fill_form({pin_code => $code}); + $s->submit_form; + is( $driver->find_element('//div[@class="two-factor-status"]')->get_text(), 'Status: Enabled', '2FA is enabled' ); + $patron = $patron->get_from_storage; + is( $patron->secret, $secret32, 'secret is set in DB' ); + + }; + + subtest 'Login' => sub { + plan tests => 19; + + my $mainpage = $s->base_url . q|mainpage.pl|; + + { # ok first try + $driver->get($mainpage . q|?logout.x=1|); + $driver->get($s->base_url . q|circ/circulation.pl?borrowernumber=|.$patron->borrowernumber); + like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' ); + $driver->capture_screenshot('selenium_failure_2.png'); + fill_login_form($s); + like( $driver->get_title, qr(Two-factor authentication), 'Must be on the second auth screen' ); + is( login_error($s), undef ); + + my $auth = Koha::Auth::TwoFactorAuth->new({patron => $patron}); + my $code = $auth->code(); + $auth->clear; + $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys($code); + $driver->find_element('//input[@type="submit"]')->click; + like( $driver->get_title, qr(Checking out to ), 'Must be redirected to the original page' ); + } + + { # second try and logout + $driver->get($mainpage . q|?logout.x=1|); + $driver->get($s->base_url . q|circ/circulation.pl?borrowernumber=|.$patron->borrowernumber); + like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' ); + fill_login_form($s); + like( $driver->get_title, qr(Two-factor authentication), 'Must be on the second auth screen' ); + is( login_error($s), undef ); + $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys('wrong_code'); + $driver->find_element('//input[@type="submit"]')->click; + ok($driver->find_element('//div[@class="dialog error"][contains(text(), "Invalid two-factor code")]')); + is( login_error($s), undef ); + + $driver->get($mainpage); + like( $driver->get_title, qr(Two-factor authentication), 'Must still be on the second auth screen' ); + is( login_error($s), undef ); + $driver->find_element('//a[@id="logout"]')->click(); + like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' ); + is( login_error($s), undef ); + } + + { # second try and success + + $driver->get($mainpage . q|?logout.x=1|); + $driver->get($s->base_url . q|circ/circulation.pl?borrowernumber=|.$patron->borrowernumber); + like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' ); + like( login_error($s), qr(Session timed out) ); + fill_login_form($s); + like( $driver->get_title, qr(Two-factor authentication), 'Must be on the second auth screen' ); + is( login_error($s), undef ); + $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys('wrong_code'); + $driver->find_element('//input[@type="submit"]')->click; + ok($driver->find_element('//div[@class="dialog error"][contains(text(), "Invalid two-factor code")]')); + + my $auth = Koha::Auth::TwoFactorAuth->new({patron => $patron}); + my $code = $auth->code(); + $auth->clear; + $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys($code); + $driver->find_element('//input[@type="submit"]')->click; + like( $driver->get_title, qr(Checking out to ), 'Must be redirected to the original page' ); + } + }; + + $driver->quit(); +}; + +END { + $_->delete for @data_to_cleanup; + C4::Context->set_preference('TwoFactorAuthentication', $pref_value); +}; + + +sub login_error { + my ( $s ) = @_; + my $driver = $s->driver; + + $s->remove_error_handler; + my $login_error = eval { + my $elt = $driver->find_element('//div[@id="login_error"]'); + return $elt->get_text if $elt && $elt->id; + }; + $s->add_error_handler; + return $login_error; +} + +# Don't use the usual t::lib::Selenium->auth as we don't want the ->get($mainpage) to test the redirect +sub fill_login_form { + my ( $s ) = @_; + $s->fill_form({ userid => $s->login, password => $s->password }); + $s->driver->find_element('//input[@id="submit-button"]')->click; +} diff --git a/t/lib/TestBuilder.pm b/t/lib/TestBuilder.pm index 87b97c927e..59d923e5e7 100644 --- a/t/lib/TestBuilder.pm +++ b/t/lib/TestBuilder.pm @@ -553,6 +553,7 @@ sub _gen_default_values { lost => undef, debarred => undef, borrowernotes => '', + secret => undef, }, Item => { notforloan => 0,