Bug 28786: Two-factor authentication for staff client - TOTP
authorJonathan Druart <jonathan.druart@bugs.koha-community.org>
Thu, 29 Jul 2021 14:08:25 +0000 (16:08 +0200)
committerFridolin Somers <fridolin.somers@biblibre.com>
Thu, 21 Apr 2022 06:43:15 +0000 (20:43 -1000)
This patchset introduces the Two-factor authentication (2FA) idea in
Koha.

It is far for complete, and only implement one way of doing it, but at
least it's a first step.
The idea here is to offer the librarian user the ability to
enable/disable 2FA when logging in to Koha.

It will use time-based, one-time passwords (TOTP) as the second factor,
an application to handle that will be required.

https://en.wikipedia.org/wiki/Time-based_One-Time_Password

More developements are possible on top of this:
* Send a notice (sms or email) with the code
* Force 2FA for librarians
* Implementation for OPAC
* WebAuthn, FIDO2, etc. - https://fidoalliance.org/category/intro-fido/

Test plan:
 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
10. Confirm that you are stuck on the second auth form (ie. you cannot
access other Koha pages)
11. Click logout => First login form
12. Enter the credential and the pincode provided by the app

Sponsored-by: Orex Digital
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Fridolin Somers <fridolin.somers@biblibre.com>
C4/Auth.pm
Koha/Auth/TwoFactorAuth.pm [new file with mode: 0644]
cpanfile
koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc
koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt
koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt
t/db_dependent/selenium/authentication_2fa.t [new file with mode: 0755]
t/lib/TestBuilder.pm

index 0714861..9ebc3ae 100644 (file)
@@ -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 (file)
index 0000000..c08736f
--- /dev/null
@@ -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 <http://www.gnu.org/licenses>.
+
+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;
index c9b4c51..636e303 100644 (file)
--- 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';
index 9a16f32..0466779 100644 (file)
@@ -53,7 +53,7 @@
                 [% END %]
 
                 [% IF Koha.Preference('TwoFactorAuthentication') && logged_in_user.borrowernumber == patron.borrowernumber %]
-                    <li><a id="twofa" href="/cgi-bin/koha/members/two_factor_auth.pl">Enable Two-Factor Authentication</a></li>
+                    <li><a id="twofa" href="/cgi-bin/koha/members/two_factor_auth.pl">Manage two-factor authentication</a></li>
                 [% END %]
 
                 [% IF CAN_user_borrowers_edit_borrowers && useDischarge %]
index 499c5bd..9449424 100644 (file)
@@ -8,6 +8,7 @@
 [% SET footerjs = 1 %]
 [% INCLUDE 'doc-head-open.inc' %]
 <title>
+    [% 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" />
             [% 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 ) %]
index 999f6f9..d067c2d 100644 (file)
@@ -3,7 +3,7 @@
 [% USE Asset %]
 [% SET footerjs = 1 %]
 [% INCLUDE 'doc-head-open.inc' %]
-<title>Two-Factor Authentication &rsaquo; Patrons &rsaquo; Koha</title>
+<title>Two-factor authentication &rsaquo; Patrons &rsaquo; Koha</title>
 [% INCLUDE 'doc-head-close.inc' %]
 </head>
 <body id="pat_two_factor_auth" class="pat">
@@ -34,7 +34,7 @@
                 [% INCLUDE 'members-toolbar.inc' %]
 
                 [% IF op == 'register' %]
-                    <h1>Register Two-Factor Authenticator</h1>
+                    <h1>Register two-factor authenticator</h1>
                     <div class="dialog message">
                         <p>We recommend cloud-based mobile authenticator apps such as Authy, Duo Mobile, and LastPass. They can restore access if you lose your hardware device.</p>
                         <p>Can't scan the code?</p>
                         </fieldset>
                     </form>
                 [% ELSE %]
-                    <h1>Two-Factor Authentication</h1>
+                    <h1>Two-factor authentication</h1>
                     [% IF patron.auth_method == "two-factor" %]
                         <div class="two-factor-status">Status: Enabled</div>
 
                         <form id="two-factor-auth" action="/cgi-bin/koha/members/two_factor_auth.pl" method="post">
                             <input type="hidden" name="csrf_token" value="[% csrf_token | html %]" />
                             <input type="hidden" name="op" value="disable-2FA" />
-                            <input type="submit" value="Disable Two-Factor Authentication" />
+                            <input type="submit" value="Disable two-factor authentication" />
                         </form>
                     [% ELSE %]
                         <div class="two-factor-status">Status: Disabled</div>
@@ -84,7 +84,7 @@
                         <form id="two-factor-auth" action="/cgi-bin/koha/members/two_factor_auth.pl" method="post">
                             <input type="hidden" name="csrf_token" value="[% csrf_token | html %]" />
                             <input type="hidden" name="op" value="enable-2FA" />
-                            <input type="submit" value="Enable Two-Factor Authentication" />
+                            <input type="submit" value="Enable two-factor authentication" />
                         </form>
 
                     [% END %]
diff --git a/t/db_dependent/selenium/authentication_2fa.t b/t/db_dependent/selenium/authentication_2fa.t
new file mode 100755 (executable)
index 0000000..ebc0c7f
--- /dev/null
@@ -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 <http://www.gnu.org/licenses>.
+
+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;
+}
index 87b97c9..59d923e 100644 (file)
@@ -553,6 +553,7 @@ sub _gen_default_values {
             lost           => undef,
             debarred       => undef,
             borrowernotes  => '',
+            secret         => undef,
         },
         Item => {
             notforloan         => 0,