Bug 32710: Try to prevent UI/Form/Builder/Item.t to fail randomly
[koha.git] / t / db_dependent / selenium / authentication_2fa.t
1 #!/usr/bin/perl
2
3 # This file is part of Koha.
4 #
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.
9 #
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.
14 #
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>.
17
18 use Modern::Perl;
19 use Test::More tests => 5;
20
21 use C4::Context;
22 use Koha::AuthUtils;
23 use Koha::Auth::TwoFactorAuth;
24 use t::lib::Mocks;
25 use t::lib::Selenium;
26 use t::lib::TestBuilder;
27
28 my @data_to_cleanup;
29 my $pref_value = C4::Context->preference('TwoFactorAuthentication');
30
31 SKIP: {
32     eval { require Selenium::Remote::Driver; };
33     skip "Selenium::Remote::Driver is needed for selenium tests.", 2 if $@;
34
35     my $builder  = t::lib::TestBuilder->new;
36
37     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => 1 }});
38     $patron->flags(1)->store; # superlibrarian permission
39     my $password = Koha::AuthUtils::generate_password($patron->category);
40     t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
41     $patron->set_password({ password => $password });
42
43     push @data_to_cleanup, $patron, $patron->category, $patron->library;
44
45     my $s        = t::lib::Selenium->new({ login => $patron->userid, password => $password });
46     my $driver   = $s->driver;
47
48     subtest 'Setup' => sub {
49         plan tests => 12;
50
51         my $mainpage = $s->base_url . q|mainpage.pl|;
52         $driver->get($mainpage);
53         like( $driver->get_title, qr(Log in to Koha), 'Hitting the main page should redirect to the login form');
54
55         C4::Context->set_preference('TwoFactorAuthentication', 'disabled');
56
57         fill_login_form($s);
58         like( $driver->get_title, qr(Koha staff interface), 'Patron with flags superlibrarian should be able to login' );
59
60         $driver->get($s->base_url . q|members/two_factor_auth.pl|);
61         like( $driver->get_title, qr(Error 404), 'Must be redirected to 404 is the pref is off' );
62
63         C4::Context->set_preference('TwoFactorAuthentication', 'enabled');
64         $driver->get($s->base_url . q|members/two_factor_auth.pl|);
65         like( $driver->get_title, qr(Two-factor authentication), 'Must be on the page with the pref on' );
66
67         is(
68             $driver->find_element( '//div[@id="registration-status-disabled"]/div[@class="two-factor-status"]' )->get_text,
69             'Status: Disabled',
70             '2FA is disabled'
71         );
72
73         is(
74             $driver->find_element( '//div[@id="registration-status-enabled"]/div[@class="two-factor-status"]' )->get_text,
75             '', # 'Status: Enabled' is not shown
76             '2FA is disabled'
77         );
78
79         $driver->find_element('//*[@id="enable-2FA"]')->click;
80         $s->wait_for_ajax;
81         ok($driver->find_element('//img[@id="qr_code"]'), 'There is a QR code');
82
83         $driver->find_element('//*[@id="pin_code"]')->send_keys('wrong_code');
84         $driver->find_element('//*[@id="register-2FA"]')->click;
85         $s->wait_for_ajax;
86         ok($driver->find_element('//div[@class="dialog error"][contains(text(), "Invalid PIN code")]'));
87         is( $patron->get_from_storage->secret, undef, 'secret is not set in DB yet' );
88
89         my $secret32 = $driver->find_element('//*[@id="secret32"]')->get_value();
90         my $auth = Koha::Auth::TwoFactorAuth->new({patron => $patron, secret32 => $secret32});
91         my $code = $auth->code();
92         $driver->find_element('//*[@id="pin_code"]')->clear;
93         $driver->find_element('//*[@id="pin_code"]')->send_keys($code);
94         $driver->find_element('//*[@id="register-2FA"]')->click;
95         # Wait for the response then go to the page, don't wait for the redirect
96         $s->wait_for_ajax;
97         $driver->get($s->base_url . q|members/two_factor_auth.pl|);
98         is(
99             $driver->find_element( '//div[@id="registration-status-disabled"]/div[@class="two-factor-status"]' )->get_text,
100             '', # 'Status: Disabled' is not shown
101             '2FA is enabled'
102         );
103
104         is(
105             $driver->find_element( '//div[@id="registration-status-enabled"]/div[@class="two-factor-status"]' )->get_text,
106             'Status: Enabled',
107             '2FA is enabled'
108         );
109
110         $patron = $patron->get_from_storage;
111         is( $patron->decoded_secret, $secret32, 'encrypted secret is set in DB' );
112
113     };
114
115     subtest 'Login' => sub {
116         plan tests => 18;
117
118         my $mainpage = $s->base_url . q|mainpage.pl|;
119
120         my $secret32 = $patron->decoded_secret;
121         { # ok first try
122             $driver->get($mainpage . q|?logout.x=1|);
123             $driver->get($s->base_url . q|circ/circulation.pl?borrowernumber=|.$patron->borrowernumber);
124             like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' );
125             fill_login_form($s);
126             like( $driver->get_title, qr(Two-factor authentication), 'Must be on the second auth screen' );
127             is( login_error($s), undef );
128
129             my $auth = Koha::Auth::TwoFactorAuth->new(
130                 { patron => $patron, secret32 => $secret32 } );
131             my $code = $auth->code();
132             $auth->clear;
133             $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys($code);
134             $driver->find_element('//input[@type="submit"]')->click;
135             like( $driver->get_title, qr(Checking out to ), 'Must be redirected to the original page' );
136         }
137
138         { # second try and logout
139             $driver->get($mainpage . q|?logout.x=1|);
140             $driver->get($s->base_url . q|circ/circulation.pl?borrowernumber=|.$patron->borrowernumber);
141             like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' );
142             fill_login_form($s);
143             like( $driver->get_title, qr(Two-factor authentication), 'Must be on the second auth screen' );
144             is( login_error($s), undef );
145             $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys('wrong_code');
146             $driver->find_element('//input[@type="submit"]')->click;
147             is( login_error($s), "Invalid two-factor code" );
148
149             $driver->get($mainpage);
150             like( $driver->get_title, qr(Two-factor authentication), 'Must still be on the second auth screen' );
151             is( login_error($s), undef );
152             $driver->find_element('//a[@id="logout"]')->click();
153             like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' );
154             is( login_error($s), undef );
155         }
156
157         { # second try and success
158
159             $driver->get($mainpage . q|?logout.x=1|);
160             $driver->get($s->base_url . q|circ/circulation.pl?borrowernumber=|.$patron->borrowernumber);
161             like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' );
162             like( login_error($s), qr(Session timed out) );
163             fill_login_form($s);
164             like( $driver->get_title, qr(Two-factor authentication), 'Must be on the second auth screen' );
165             is( login_error($s), undef );
166             $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys('wrong_code');
167             $driver->find_element('//input[@type="submit"]')->click;
168             is( login_error($s), "Invalid two-factor code" );
169
170             my $auth = Koha::Auth::TwoFactorAuth->new(
171                 { patron => $patron, secret32 => $secret32 } );
172             my $code = $auth->code();
173             $auth->clear;
174             $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')->send_keys($code);
175             $driver->find_element('//input[@type="submit"]')->click;
176             like( $driver->get_title, qr(Checking out to ), 'Must be redirected to the original page' );
177         }
178     };
179
180     subtest "Send OTP code" => sub {
181         plan tests => 3;
182
183         # Make sure the send won't fail because of invalid email addresses
184         $patron->library->set(
185             {
186                 branchemail      => 'from@example.org',
187                 branchreturnpath => undef,
188                 branchreplyto    => undef,
189             }
190         )->store;
191         $patron->auth_method('two-factor');
192         $patron->email(undef);
193         $patron->store;
194
195         my $mainpage = $s->base_url . q|mainpage.pl|;
196         $driver->get( $mainpage . q|?logout.x=1| );
197         like(
198             $driver->get_title,
199             qr(Log in to Koha),
200             'Must be on the first auth screen'
201         );
202         fill_login_form($s);
203         like(
204             $driver->get_title,
205             qr(Two-factor authentication),
206             'Must be on the second auth screen'
207         );
208         $driver->find_element('//a[@id="send_otp"]')->click;
209         $s->wait_for_ajax;
210         my $error = $driver->find_element('//div[@id="email_error"]')->get_text;
211         like(
212             $error,
213             qr{Email not sent},
214             'Email not sent will display an error'
215         );
216
217         # This test will only pass if an SMTP server is defined
218         # It cannot be mocked from selenium tests
219         #$patron->email('test@example.org');
220         #$patron->store;
221         #$driver->find_element('//a[@id="send_otp"]')->click;
222         #$s->wait_for_ajax;
223         #my $message =
224         #  $driver->find_element('//div[@id="email_success"]')->get_text;
225         #is(
226         #    $message,
227         #    "The code has been sent by email, please check your inbox.",
228         #    'The email must have been sent correctly'
229         #);
230     };
231
232     subtest "Enforce 2FA setup on first login" => sub {
233         plan tests => 7;
234
235         C4::Context->set_preference( 'TwoFactorAuthentication', 'enforced' );
236
237         # Make sure the send won't fail because of invalid email addresses
238         $patron->library->set(
239             {
240                 branchemail      => 'from@example.org',
241                 branchreturnpath => undef,
242                 branchreplyto    => undef,
243             }
244         )->store;
245         $patron->auth_method('password');
246         $patron->email(undef);
247         $patron->store;
248
249         my $mainpage = $s->base_url . q|mainpage.pl|;
250         $driver->get( $mainpage . q|?logout.x=1| );
251         like(
252             $driver->get_title,
253             qr(Log in to Koha),
254             'Must be on the first auth screen'
255         );
256         fill_login_form($s);
257         like(
258             $driver->get_title,
259             qr(Two-factor authentication setup),
260             'Must be on the 2FA auth setup screen'
261         );
262
263         $s->wait_for_ajax; # There is an ajax request to populate the qr_code and the secret
264
265         isnt( $driver->find_element('//*[@id="qr_code"]')->get_attribute("src"), "" );
266         my $secret32 = $driver->find_element('//*[@id="secret32"]')->get_value;
267
268         my $auth = Koha::Auth::TwoFactorAuth->new(
269             { patron => $patron, secret32 => $secret32 } );
270         my $pin_code = $auth->code;
271
272         $driver->find_element('//*[@id="pin_code"]')->send_keys("wrong code");
273         $driver->find_element('//*[@id="register-2FA"]')->click;
274         $s->wait_for_ajax;
275         is( $driver->find_element('//*[@id="errors"]')->get_text,
276             "Invalid PIN code" );
277
278         $driver->find_element('//*[@id="pin_code"]')->clear;
279         $driver->find_element('//*[@id="pin_code"]')->send_keys($pin_code);
280         $driver->find_element('//*[@id="register-2FA"]')->click;
281         is( $s->get_next_alert_text,
282             "Two-factor authentication correctly configured. You will be redirected to the login screen."
283         );
284         $driver->accept_alert;
285         # FIXME How to test the redirect to the mainpage here
286
287         $patron = $patron->get_from_storage;
288         is( $patron->auth_method, 'two-factor', );
289         isnt( $patron->secret, undef, );
290     };
291
292     subtest "Disable" => sub {
293         plan tests => 6;
294
295         my $mainpage = $s->base_url . q|mainpage.pl|;
296         $driver->get( $mainpage . q|?logout.x=1| );
297         fill_login_form($s);
298         my $auth = Koha::Auth::TwoFactorAuth->new( { patron => $patron } );
299         my $code = $auth->code();
300         $auth->clear;
301         $driver->find_element('//form[@id="loginform"]//input[@id="otp_token"]')
302           ->send_keys($code);
303         $driver->find_element('//input[@type="submit"]')->click;
304
305         $driver->get( $s->base_url . q|members/two_factor_auth.pl| );
306
307         is(
308             $driver->find_element( '//div[@id="registration-status-disabled"]/div[@class="two-factor-status"]' )->get_text,
309             '', # 'Status: Disabled' is not shown
310             '2FA is enabled'
311         );
312
313         is(
314             $driver->find_element( '//div[@id="registration-status-enabled"]/div[@class="two-factor-status"]' )->get_text,
315             'Status: Enabled',
316             '2FA is enabled'
317         );
318
319         $driver->find_element('//form[@id="two-factor-auth"]//input[@type="submit"]')->click;
320
321         is(
322             $driver->find_element( '//div[@id="registration-status-disabled"]/div[@class="two-factor-status"]' )->get_text,
323             'Status: Disabled',
324             '2FA is disabled'
325         );
326
327         is(
328             $driver->find_element( '//div[@id="registration-status-enabled"]/div[@class="two-factor-status"]' )->get_text,
329             '', # 'Status: Enabled' is not shown
330             '2FA is disabled'
331         );
332
333         $patron = $patron->get_from_storage;
334         is( $patron->secret, undef, "Secret has been cleared" );
335         is( $patron->auth_method(), 'password', 'auth_method has been reset to "password"' );
336     };
337
338     $driver->quit();
339 };
340
341 END {
342     $_->delete for @data_to_cleanup;
343     C4::Context->set_preference('TwoFactorAuthentication', $pref_value);
344 };
345
346
347 sub login_error {
348     my ( $s ) = @_;
349     my $driver   = $s->driver;
350
351     $s->remove_error_handler;
352     my $login_error = eval {
353         my $elt = $driver->find_element('//div[@id="login_error"]');
354         return $elt->get_text if $elt && $elt->id;
355     };
356     $s->add_error_handler;
357     return $login_error;
358 }
359
360 # Don't use the usual t::lib::Selenium->auth as we don't want the ->get($mainpage) to test the redirect
361 sub fill_login_form {
362     my ( $s ) = @_;
363     $s->fill_form({ userid => $s->login, password => $s->password });
364     $s->driver->find_element('//input[@id="submit-button"]')->click;
365 }