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;
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,
{ 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'} )
) {
$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',
}
}
- 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
$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(
#
#
+ 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;
$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;
=item "restricted" -- The IP has changed (if SessionRestrictionByIP)
+=item "additional-auth-needed -- User is in an authentication process that is not finished
+
=back
=cut
$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();
--- /dev/null
+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;
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';
[% 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 %]
[% 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 %]
<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 ) %]
[% USE Asset %]
[% SET footerjs = 1 %]
[% INCLUDE 'doc-head-open.inc' %]
-<title>Two-Factor Authentication › Patrons › Koha</title>
+<title>Two-factor authentication › Patrons › Koha</title>
[% INCLUDE 'doc-head-close.inc' %]
</head>
<body id="pat_two_factor_auth" class="pat">
[% 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>
<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 %]
--- /dev/null
+#!/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;
+}
lost => undef,
debarred => undef,
borrowernotes => '',
+ secret => undef,
},
Item => {
notforloan => 0,