3 # This file is part of Koha.
5 # Koha is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
10 # Koha is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with Koha; if not, see <http://www.gnu.org/licenses>.
20 use Test::More tests => 5;
24 use Koha::Auth::TwoFactorAuth;
27 use t::lib::TestBuilder;
30 my $pref_value = C4::Context->preference('TwoFactorAuthentication');
33 eval { require Selenium::Remote::Driver; };
34 skip "Selenium::Remote::Driver is needed for selenium tests.", 2 if $@;
36 my $builder = t::lib::TestBuilder->new;
38 my $library_name = 'my ❤ library';
39 my $library = $builder->build_object( { class => 'Koha::Libraries', value => { branchname => $library_name } } );
40 my $patron = $builder->build_object( { class => 'Koha::Patrons', value => { flags => 1, branchcode => $library->branchcode } } );
41 $patron->flags(1)->store; # superlibrarian permission
42 my $password = Koha::AuthUtils::generate_password($patron->category);
43 t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
44 $patron->set_password({ password => $password });
46 push @data_to_cleanup, $patron, $patron->category, $patron->library;
48 my $s = t::lib::Selenium->new({ login => $patron->userid, password => $password });
49 my $driver = $s->driver;
51 subtest 'Setup' => sub {
54 my $mainpage = $s->base_url . q|mainpage.pl|;
55 $driver->get($mainpage);
56 like( $driver->get_title, qr(Log in to Koha), 'Hitting the main page should redirect to the login form');
58 C4::Context->set_preference('TwoFactorAuthentication', 'disabled');
61 like( $driver->get_title, qr(Koha staff interface), 'Patron with flags superlibrarian should be able to login' );
63 $driver->get($s->base_url . q|members/two_factor_auth.pl|);
64 like( $driver->get_title, qr(Error 404), 'Must be redirected to 404 is the pref is off' );
66 C4::Context->set_preference('TwoFactorAuthentication', 'enabled');
67 $driver->get($s->base_url . q|members/two_factor_auth.pl|);
68 like( $driver->get_title, qr(Two-factor authentication), 'Must be on the page with the pref on' );
71 $driver->find_element( '//div[@id="registration-status-disabled"]/div[@class="two-factor-status"]' )->get_text,
77 $driver->find_element( '//div[@id="registration-status-enabled"]/div[@class="two-factor-status"]' )->get_text,
78 '', # 'Status: Enabled' is not shown
82 $driver->find_element('//*[@id="enable-2FA"]')->click;
84 ok($driver->find_element('//img[@id="qr_code"]'), 'There is a QR code');
85 is($driver->find_element('//span[@id="issuer"]')->get_text, $library_name);
87 $driver->find_element('//*[@id="pin_code"]')->send_keys('wrong_code');
88 $driver->find_element('//*[@id="register-2FA"]')->click;
90 ok($driver->find_element('//div[@class="dialog error"][contains(text(), "Invalid PIN code")]'));
91 is( $patron->get_from_storage->secret, undef, 'secret is not set in DB yet' );
93 my $secret32 = $driver->find_element('//*[@id="secret32"]')->get_value();
94 my $auth = Koha::Auth::TwoFactorAuth->new({patron => $patron, secret32 => $secret32});
95 my $code = $auth->code();
96 $driver->find_element('//*[@id="pin_code"]')->clear;
97 $driver->find_element('//*[@id="pin_code"]')->send_keys($code);
98 $driver->find_element('//*[@id="register-2FA"]')->click;
99 # Wait for the response then go to the page, don't wait for the redirect
101 $driver->get($s->base_url . q|members/two_factor_auth.pl|);
103 $driver->find_element( '//div[@id="registration-status-disabled"]/div[@class="two-factor-status"]' )->get_text,
104 '', # 'Status: Disabled' is not shown
109 $driver->find_element( '//div[@id="registration-status-enabled"]/div[@class="two-factor-status"]' )->get_text,
114 $patron = $patron->get_from_storage;
115 is( $patron->decoded_secret, $secret32, 'encrypted secret is set in DB' );
119 subtest 'Login' => sub {
122 my $mainpage = $s->base_url . q|mainpage.pl|;
124 my $secret32 = $patron->decoded_secret;
126 $driver->get($mainpage . q|?logout.x=1|);
127 $driver->get($s->base_url . q|circ/circulation.pl?borrowernumber=|.$patron->borrowernumber);
128 like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' );
130 like( $driver->get_title, qr(Two-factor authentication), 'Must be on the second auth screen' );
131 is( login_error($s), undef );
133 my $auth = Koha::Auth::TwoFactorAuth->new(
134 { patron => $patron, secret32 => $secret32 } );
135 my $code = $auth->code();
137 $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys($code);
138 $driver->find_element('//input[@type="submit"]')->click;
139 like( $driver->get_title, qr(Checking out to ), 'Must be redirected to the original page' );
142 { # second try and logout
143 $driver->get($mainpage . q|?logout.x=1|);
144 $driver->get($s->base_url . q|circ/circulation.pl?borrowernumber=|.$patron->borrowernumber);
145 like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' );
147 like( $driver->get_title, qr(Two-factor authentication), 'Must be on the second auth screen' );
148 is( login_error($s), undef );
149 $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys('wrong_code');
150 $driver->find_element('//input[@type="submit"]')->click;
151 is( login_error($s), "Invalid two-factor code" );
153 $driver->get($mainpage);
154 like( $driver->get_title, qr(Two-factor authentication), 'Must still be on the second auth screen' );
155 is( login_error($s), undef );
156 $driver->find_element('//a[@id="logout"]')->click();
157 like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' );
158 is( login_error($s), undef );
161 { # second try and success
163 $driver->get($mainpage . q|?logout.x=1|);
164 $driver->get($s->base_url . q|circ/circulation.pl?borrowernumber=|.$patron->borrowernumber);
165 like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' );
166 like( login_error($s), qr(Session timed out) );
168 like( $driver->get_title, qr(Two-factor authentication), 'Must be on the second auth screen' );
169 is( login_error($s), undef );
170 $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys('wrong_code');
171 $driver->find_element('//input[@type="submit"]')->click;
172 is( login_error($s), "Invalid two-factor code" );
174 my $auth = Koha::Auth::TwoFactorAuth->new(
175 { patron => $patron, secret32 => $secret32 } );
176 my $code = $auth->code();
178 $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys($code);
179 $driver->find_element('//input[@type="submit"]')->click;
180 like( $driver->get_title, qr(Checking out to ), 'Must be redirected to the original page' );
184 subtest "Send OTP code" => sub {
187 # Make sure the send won't fail because of invalid email addresses
188 $patron->library->set(
190 branchemail => 'from@example.org',
191 branchreturnpath => undef,
192 branchreplyto => undef,
195 $patron->auth_method('two-factor');
196 $patron->email(undef);
199 my $mainpage = $s->base_url . q|mainpage.pl|;
200 $driver->get( $mainpage . q|?logout.x=1| );
204 'Must be on the first auth screen'
209 qr(Two-factor authentication),
210 'Must be on the second auth screen'
212 $driver->find_element('//a[@id="send_otp"]')->click;
214 my $error = $driver->find_element('//div[@id="email_error"]')->get_text;
218 'Email not sent will display an error'
221 # This test will only pass if an SMTP server is defined
222 # It cannot be mocked from selenium tests
223 #$patron->email('test@example.org');
225 #$driver->find_element('//a[@id="send_otp"]')->click;
228 # $driver->find_element('//div[@id="email_success"]')->get_text;
231 # "The code has been sent by email, please check your inbox.",
232 # 'The email must have been sent correctly'
236 subtest "Enforce 2FA setup on first login" => sub {
239 C4::Context->set_preference( 'TwoFactorAuthentication', 'enforced' );
241 # Make sure the send won't fail because of invalid email addresses
242 $patron->library->set(
244 branchemail => 'from@example.org',
245 branchreturnpath => undef,
246 branchreplyto => undef,
249 $patron->auth_method('password');
250 $patron->email(undef);
253 my $mainpage = $s->base_url . q|mainpage.pl|;
254 $driver->get( $mainpage . q|?logout.x=1| );
258 'Must be on the first auth screen'
263 qr(Two-factor authentication setup),
264 'Must be on the 2FA auth setup screen'
267 $s->wait_for_ajax; # There is an ajax request to populate the qr_code and the secret
269 isnt( $driver->find_element('//*[@id="qr_code"]')->get_attribute("src"), "" );
270 my $secret32 = $driver->find_element('//*[@id="secret32"]')->get_value;
272 my $auth = Koha::Auth::TwoFactorAuth->new(
273 { patron => $patron, secret32 => $secret32 } );
274 my $pin_code = $auth->code;
276 $driver->find_element('//*[@id="pin_code"]')->send_keys("wrong code");
277 $driver->find_element('//*[@id="register-2FA"]')->click;
279 is( $driver->find_element('//*[@id="errors"]')->get_text,
280 "Invalid PIN code" );
282 $driver->find_element('//*[@id="pin_code"]')->clear;
283 $driver->find_element('//*[@id="pin_code"]')->send_keys($pin_code);
284 $driver->find_element('//*[@id="register-2FA"]')->click;
285 is( $s->get_next_alert_text,
286 "Two-factor authentication correctly configured. You will be redirected to the login screen."
288 $driver->accept_alert;
289 # FIXME How to test the redirect to the mainpage here
291 $patron = $patron->get_from_storage;
292 is( $patron->auth_method, 'two-factor', );
293 isnt( $patron->secret, undef, );
296 subtest "Disable" => sub {
299 my $mainpage = $s->base_url . q|mainpage.pl|;
300 $driver->get( $mainpage . q|?logout.x=1| );
302 my $auth = Koha::Auth::TwoFactorAuth->new( { patron => $patron } );
303 my $code = $auth->code();
305 $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')
307 $driver->find_element('//input[@type="submit"]')->click;
309 $driver->get( $s->base_url . q|members/two_factor_auth.pl| );
312 $driver->find_element( '//div[@id="registration-status-disabled"]/div[@class="two-factor-status"]' )->get_text,
313 '', # 'Status: Disabled' is not shown
318 $driver->find_element( '//div[@id="registration-status-enabled"]/div[@class="two-factor-status"]' )->get_text,
323 $driver->find_element('//form[@id="two-factor-auth"]//input[@type="submit"]')->click;
326 $driver->find_element( '//div[@id="registration-status-disabled"]/div[@class="two-factor-status"]' )->get_text,
332 $driver->find_element( '//div[@id="registration-status-enabled"]/div[@class="two-factor-status"]' )->get_text,
333 '', # 'Status: Enabled' is not shown
337 $patron = $patron->get_from_storage;
338 is( $patron->secret, undef, "Secret has been cleared" );
339 is( $patron->auth_method(), 'password', 'auth_method has been reset to "password"' );
346 $_->delete for @data_to_cleanup;
347 C4::Context->set_preference('TwoFactorAuthentication', $pref_value);
353 my $driver = $s->driver;
355 $s->remove_error_handler;
356 my $login_error = eval {
357 my $elt = $driver->find_element('//div[@id="login_error"]');
358 return $elt->get_text if $elt && $elt->id;
360 $s->add_error_handler;
364 # Don't use the usual t::lib::Selenium->auth as we don't want the ->get($mainpage) to test the redirect
365 sub fill_login_form {
367 $s->fill_form({ userid => $s->login, password => $s->password });
368 $s->driver->find_element('//input[@id="submit-button"]')->click;