Bug 31908: Add a test to show issue
[koha.git] / t / db_dependent / Auth.t
1 #!/usr/bin/perl
2 #
3 # This Koha test module is a stub!  
4 # Add more tests here!!!
5
6 use Modern::Perl;
7
8 use CGI qw ( -utf8 );
9
10 use Test::MockObject;
11 use Test::MockModule;
12 use List::MoreUtils qw/all any none/;
13 use Test::More tests => 16;
14 use Test::Warn;
15 use t::lib::Mocks;
16 use t::lib::TestBuilder;
17
18 use C4::Auth;
19 use C4::Members;
20 use Koha::AuthUtils qw/hash_password/;
21 use Koha::Database;
22 use Koha::Patrons;
23 use Koha::Auth::TwoFactorAuth;
24
25 BEGIN {
26     use_ok('C4::Auth', qw( checkauth haspermission track_login_daily checkpw get_template_and_user checkpw_hash ));
27 }
28
29 my $schema  = Koha::Database->schema;
30 my $builder = t::lib::TestBuilder->new;
31 my $dbh     = C4::Context->dbh;
32
33 # FIXME: SessionStorage defaults to mysql, but it seems to break transaction
34 # handling
35 t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' );
36 t::lib::Mocks::mock_preference( 'GDPR_Policy', '' ); # Disabled
37
38 # To silence useless warnings
39 $ENV{REMOTE_ADDR} = '127.0.0.1';
40
41 $schema->storage->txn_begin;
42
43 subtest 'checkauth() tests' => sub {
44
45     plan tests => 7;
46
47     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => undef } });
48
49     # Mock a CGI object with real userid param
50     my $cgi = Test::MockObject->new();
51     $cgi->mock(
52         'param',
53         sub {
54             my $var = shift;
55             if ( $var eq 'userid' ) { return $patron->userid; }
56         }
57     );
58     $cgi->mock( 'cookie', sub { return; } );
59     $cgi->mock( 'request_method', sub { return 'POST' } );
60
61     my $authnotrequired = 1;
62     my ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, $authnotrequired );
63
64     is( $userid, undef, 'checkauth() returns undef for userid if no logged in user (Bug 18275)' );
65
66     my $db_user_id = C4::Context->config('user');
67     my $db_user_pass = C4::Context->config('pass');
68     $cgi = Test::MockObject->new();
69     $cgi->mock( 'cookie', sub { return; } );
70     $cgi->mock( 'param', sub {
71             my ( $self, $param ) = @_;
72             if ( $param eq 'userid' ) { return $db_user_id; }
73             elsif ( $param eq 'password' ) { return $db_user_pass; }
74             else { return; }
75         });
76     $cgi->mock( 'request_method', sub { return 'POST' } );
77     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, $authnotrequired );
78     is ( $userid, undef, 'If DB user is used, it should not be logged in' );
79
80     my $is_allowed = C4::Auth::haspermission( $db_user_id, { can_do => 'everything' } );
81
82     # FIXME This belongs to t/db_dependent/Auth/haspermission.t but we do not want to c/p the pervious mock statements
83     ok( !$is_allowed, 'DB user should not have any permissions');
84
85     subtest 'Prevent authentication when sending credential via GET' => sub {
86
87         plan tests => 2;
88
89         my $patron = $builder->build_object(
90             { class => 'Koha::Patrons', value => { flags => 1 } } );
91         my $password = 'password';
92         t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
93         $patron->set_password( { password => $password } );
94         $cgi = Test::MockObject->new();
95         $cgi->mock( 'cookie', sub { return; } );
96         $cgi->mock(
97             'param',
98             sub {
99                 my ( $self, $param ) = @_;
100                 if    ( $param eq 'userid' )   { return $patron->userid; }
101                 elsif ( $param eq 'password' ) { return $password; }
102                 else                           { return; }
103             }
104         );
105
106         $cgi->mock( 'request_method', sub { return 'POST' } );
107         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
108         is( $userid, $patron->userid, 'If librarian user is used and password with POST, they should be logged in' );
109
110         $cgi->mock( 'request_method', sub { return 'GET' } );
111         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
112         is( $userid, undef, 'If librarian user is used and password with GET, they should not be logged in' );
113     };
114
115     subtest 'Template params tests (password_expired)' => sub {
116
117         plan tests => 1;
118
119         my $password_expired;
120
121         my $patron_class = Test::MockModule->new('Koha::Patron');
122         $patron_class->mock( 'password_expired', sub { return $password_expired; } );
123
124         my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => 1 } });
125         my $password = 'password';
126         t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
127         $patron->set_password( { password => $password } );
128
129         my $cgi_mock = Test::MockModule->new('CGI')->mock( 'request_method', 'POST' );
130         my $cgi = CGI->new;
131         $cgi->param( -name => 'userid',   -value => $patron->userid );
132         $cgi->param( -name => 'password', -value => $password );
133
134         my $auth = Test::MockModule->new( 'C4::Auth' );
135         # Tests will fail if we hit safe_exit
136         $auth->mock( 'safe_exit', sub { return } );
137
138         my ( $userid, $cookie, $sessionID, $flags );
139
140         {
141             t::lib::Mocks::mock_preference( 'DumpTemplateVarsOpac', 1 );
142             # checkauth will redirect and safe_exit if not authenticated and not authorized
143             local *STDOUT;
144             my $stdout;
145             open STDOUT, '>', \$stdout;
146
147             # Password has expired
148             $password_expired = 1;
149             C4::Auth::checkauth( $cgi, 0, { catalogue => 1 } );
150             like( $stdout, qr{'password_has_expired' => 1}, 'password_has_expired is set to 1' );
151
152             close STDOUT;
153         };
154     };
155
156     subtest 'While still logged in, relogin with another user' => sub {
157         plan tests => 4;
158         my $patron = $builder->build_object({ class => 'Koha::Patrons', value => {} });
159         my $patron2 = $builder->build_object({ class => 'Koha::Patrons', value => {} });
160         # Create 'former' session
161         my $session = C4::Auth::get_session();
162         $session->param( 'number',       $patron->id );
163         $session->param( 'id',           $patron->userid );
164         $session->param( 'ip',           '1.2.3.4' );
165         $session->param( 'lasttime',     time() );
166         $session->param( 'interface',    'opac' );
167         $session->flush;
168         my $sessionID = $session->id;
169         C4::Context->_new_userenv($sessionID);
170
171         my ( $return ) = C4::Auth::check_cookie_auth( $sessionID, undef, { skip_version_check => 1, remote_addr => '1.2.3.4' } );
172         is( $return, 'ok', 'Former session in shape now' );
173
174         my $mock1 = Test::MockModule->new('C4::Auth')->mock( 'safe_exit', sub {} );
175         my $mock2 = Test::MockModule->new('CGI')     ->mock( 'request_method', 'POST' )
176                                                      ->mock( 'cookie', sub { return $sessionID; } ); # oversimplified..
177         my $cgi = CGI->new;
178         my $password = 'Incr3d1blyZtr@ng93$';
179         $patron2->set_password({ password => $password });
180         $cgi->param( -name => 'userid',             -value => $patron2->userid );
181         $cgi->param( -name => 'password',           -value => $password );
182         $cgi->param( -name => 'koha_login_context', -value => 1 );
183         my @return;
184         {
185             local *STDOUT;
186             local %ENV;
187             $ENV{REMOTE_ADDR} = '1.2.3.4';
188             my $stdout;
189             open STDOUT, '>', \$stdout;
190             @return = C4::Auth::checkauth( $cgi, 0, {} );
191             close STDOUT;
192         }
193         # Note: We can test return values from checkauth here since we mocked the safe_exit after the Redirect 303
194         is( $return[0], $patron2->userid, 'Login of patron2 approved' );
195         isnt( $return[2], $sessionID, 'Did not return previous session ID' );
196         ok( $return[2], 'New session ID not empty' );
197     };
198
199     subtest 'Two-factor authentication' => sub {
200
201         my $patron = $builder->build_object(
202             { class => 'Koha::Patrons', value => { flags => 1 } } );
203         my $password = 'password';
204         $patron->set_password( { password => $password } );
205         $cgi = Test::MockObject->new();
206
207         my $otp_token;
208         our ( $logout, $sessionID, $verified );
209         $cgi->mock(
210             'param',
211             sub {
212                 my ( $self, $param ) = @_;
213                 if    ( $param eq 'userid' )    { return $patron->userid; }
214                 elsif ( $param eq 'password' )  { return $password; }
215                 elsif ( $param eq 'otp_token' ) { return $otp_token; }
216                 elsif ( $param eq 'logout.x' )  { return $logout; }
217                 else                            { return; }
218             }
219         );
220         $cgi->mock( 'request_method', sub { return 'POST' } );
221         $cgi->mock( 'cookie', sub { return $sessionID } );
222
223         my $two_factor_auth = Test::MockModule->new( 'Koha::Auth::TwoFactorAuth' );
224         $two_factor_auth->mock( 'verify', sub {$verified} );
225
226         my ( $userid, $cookie, $flags );
227         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
228
229         sub logout {
230             my $cgi = shift;
231             $logout = 1;
232             undef $sessionID;
233             C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
234             $logout = 0;
235         }
236
237         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 0 );
238         $patron->auth_method('password')->store;
239         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
240         is( $userid, $patron->userid, 'Succesful login' );
241         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'Second auth not required' );
242         logout($cgi);
243
244         $patron->auth_method('two-factor')->store;
245         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
246         is( $userid, $patron->userid, 'Succesful login' );
247         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'Second auth not required' );
248         logout($cgi);
249
250         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 1 );
251         t::lib::Mocks::mock_config('encryption_key', '1234tH1s=t&st');
252         $patron->auth_method('password')->store;
253         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
254         is( $userid, $patron->userid, 'Succesful login' );
255         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'Second auth not required' );
256         logout($cgi);
257
258         $patron->encode_secret('one_secret');
259         $patron->auth_method('two-factor');
260         $patron->store;
261         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
262         is( $userid, $patron->userid, 'Succesful login' );
263         my $session = C4::Auth::get_session($sessionID);
264         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), 1, 'Second auth required' );
265
266         # Wrong OTP token
267         $otp_token = "wrong";
268         $verified = 0;
269         $patron->auth_method('two-factor')->store;
270         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
271         is( $userid, $patron->userid, 'Succesful login' );
272         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), 1, 'Second auth still required after wrong OTP token' );
273
274         $otp_token = "good";
275         $verified = 1;
276         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
277         is( $userid, $patron->userid, 'Succesful login' );
278         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), 0, 'Second auth no longer required if OTP token has been verified' );
279
280         logout($cgi);
281         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'opac' );
282         is( $userid, $patron->userid, 'Succesful login at the OPAC' );
283         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'No second auth required at the OPAC' );
284
285         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 0 );
286     };
287
288     C4::Context->_new_userenv; # For next tests
289
290 };
291
292 subtest 'track_login_daily tests' => sub {
293
294     plan tests => 5;
295
296     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
297     my $userid = $patron->userid;
298
299     $patron->lastseen( undef );
300     $patron->store();
301
302     my $cache     = Koha::Caches->get_instance();
303     my $cache_key = "track_login_" . $patron->userid;
304     $cache->clear_from_cache($cache_key);
305
306     t::lib::Mocks::mock_preference( 'TrackLastPatronActivity', '1' );
307
308     is( $patron->lastseen, undef, 'Patron should have not last seen when newly created' );
309
310     C4::Auth::track_login_daily( $userid );
311     $patron->_result()->discard_changes();
312     isnt( $patron->lastseen, undef, 'Patron should have last seen set when TrackLastPatronActivity = 1' );
313
314     sleep(1); # We need to wait a tiny bit to make sure the timestamp will be different
315     my $last_seen = $patron->lastseen;
316     C4::Auth::track_login_daily( $userid );
317     $patron->_result()->discard_changes();
318     is( $patron->lastseen, $last_seen, 'Patron last seen should still be unchanged' );
319
320     $cache->clear_from_cache($cache_key);
321     C4::Auth::track_login_daily( $userid );
322     $patron->_result()->discard_changes();
323     isnt( $patron->lastseen, $last_seen, 'Patron last seen should be changed if we cleared the cache' );
324
325     t::lib::Mocks::mock_preference( 'TrackLastPatronActivity', '0' );
326     $patron->lastseen( undef )->store;
327     $cache->clear_from_cache($cache_key);
328     C4::Auth::track_login_daily( $userid );
329     $patron->_result()->discard_changes();
330     is( $patron->lastseen, undef, 'Patron should still have last seen unchanged when TrackLastPatronActivity = 0' );
331
332 };
333
334 subtest 'no_set_userenv parameter tests' => sub {
335
336     plan tests => 7;
337
338     my $library = $builder->build_object( { class => 'Koha::Libraries' } );
339     my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
340     my $password = 'password';
341
342     t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
343     $patron->set_password({ password => $password });
344
345     ok( checkpw( $dbh, $patron->userid, $password, undef, undef, 1 ), 'checkpw returns true' );
346     is( C4::Context->userenv, undef, 'Userenv should be undef as required' );
347     C4::Context->_new_userenv('DUMMY SESSION');
348     C4::Context->set_userenv(0,0,0,'firstname','surname', $library->branchcode, 'Library 1', 0, '', '');
349     is( C4::Context->userenv->{branch}, $library->branchcode, 'Userenv gives correct branch' );
350     ok( checkpw( $dbh, $patron->userid, $password, undef, undef, 1 ), 'checkpw returns true' );
351     is( C4::Context->userenv->{branch}, $library->branchcode, 'Userenv branch is preserved if no_set_userenv is true' );
352     ok( checkpw( $dbh, $patron->userid, $password, undef, undef, 0 ), 'checkpw still returns true' );
353     isnt( C4::Context->userenv->{branch}, $library->branchcode, 'Userenv branch is overwritten if no_set_userenv is false' );
354 };
355
356 subtest 'checkpw lockout tests' => sub {
357
358     plan tests => 5;
359
360     my $library = $builder->build_object( { class => 'Koha::Libraries' } );
361     my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
362     my $password = 'password';
363     t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
364     t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 1 );
365     $patron->set_password({ password => $password });
366
367     my ( $checkpw, undef, undef ) = checkpw( $dbh, $patron->cardnumber, $password, undef, undef, 1 );
368     ok( $checkpw, 'checkpw returns true with right password when logging in via cardnumber' );
369     ( $checkpw, undef, undef ) = checkpw( $dbh, $patron->userid, "wrong_password", undef, undef, 1 );
370     is( $checkpw, 0, 'checkpw returns false when given wrong password' );
371     $patron = $patron->get_from_storage;
372     is( $patron->account_locked, 1, "Account is locked from failed login");
373     ( $checkpw, undef, undef ) = checkpw( $dbh, $patron->userid, $password, undef, undef, 1 );
374     is( $checkpw, undef, 'checkpw returns undef with right password when account locked' );
375     ( $checkpw, undef, undef ) = checkpw( $dbh, $patron->cardnumber, $password, undef, undef, 1 );
376     is( $checkpw, undef, 'checkpw returns undefwith right password when logging in via cardnumber if account locked' );
377
378 };
379
380 # get_template_and_user tests
381
382 subtest 'get_template_and_user' => sub {   # Tests for the language URL parameter
383
384     sub MockedCheckauth {
385         my ($query,$authnotrequired,$flagsrequired,$type) = @_;
386         # return vars
387         my $userid = 'cobain';
388         my $sessionID = 234;
389         # we don't need to bother about permissions for this test
390         my $flags = {
391             superlibrarian    => 1, acquisition       => 0,
392             borrowers         => 0,
393             catalogue         => 1, circulate         => 0,
394             coursereserves    => 0, editauthorities   => 0,
395             editcatalogue     => 0,
396             parameters        => 0, permissions       => 0,
397             plugins           => 0, reports           => 0,
398             reserveforothers  => 0, serials           => 0,
399             staffaccess       => 0, tools             => 0,
400             updatecharges     => 0
401         };
402
403         my $session_cookie = $query->cookie(
404             -name => 'CGISESSID',
405             -value    => 'nirvana',
406             -HttpOnly => 1
407         );
408
409         return ( $userid, [ $session_cookie ], $sessionID, $flags );
410     }
411
412     # Mock checkauth, build the scenario
413     my $auth = Test::MockModule->new( 'C4::Auth' );
414     $auth->mock( 'checkauth', \&MockedCheckauth );
415
416     # Make sure 'EnableOpacSearchHistory' is set
417     t::lib::Mocks::mock_preference('EnableOpacSearchHistory',1);
418     # Enable es-ES for the OPAC and staff interfaces
419     t::lib::Mocks::mock_preference('OPACLanguages','en,es-ES');
420     t::lib::Mocks::mock_preference('language','en,es-ES');
421
422     # we need a session cookie
423     $ENV{"SERVER_PORT"} = 80;
424     $ENV{"HTTP_COOKIE"} = 'CGISESSID=nirvana';
425
426     my $query = CGI->new;
427     $query->param('language','es-ES');
428
429     my ( $template, $loggedinuser, $cookies ) = get_template_and_user(
430         {
431             template_name   => "about.tt",
432             query           => $query,
433             type            => "opac",
434             authnotrequired => 1,
435             flagsrequired   => { catalogue => 1 },
436             debug           => 1
437         }
438     );
439
440     ok ( ( all { ref($_) eq 'CGI::Cookie' } @$cookies ),
441             'BZ9735: the cookies array is flat' );
442
443     # new query, with non-existent language (we only have en and es-ES)
444     $query->param('language','tomas');
445
446     ( $template, $loggedinuser, $cookies ) = get_template_and_user(
447         {
448             template_name   => "about.tt",
449             query           => $query,
450             type            => "opac",
451             authnotrequired => 1,
452             flagsrequired   => { catalogue => 1 },
453             debug           => 1
454         }
455     );
456
457     ok( ( none { $_->name eq 'KohaOpacLanguage' and $_->value eq 'tomas' } @$cookies ),
458         'BZ9735: invalid language, it is not set');
459
460     ok( ( any { $_->name eq 'KohaOpacLanguage' and $_->value eq 'en' } @$cookies ),
461         'BZ9735: invalid language, then default to en');
462
463     for my $template_name (
464         qw(
465             ../../../../../../../../../../../../../../../etc/passwd
466             test/../../../../../../../../../../../../../../etc/passwd
467             /etc/passwd
468             test/does_not_finished_by_tt_t
469         )
470     ) {
471         eval {
472             ( $template, $loggedinuser, $cookies ) = get_template_and_user(
473                 {
474                     template_name   => $template_name,
475                     query           => $query,
476                     type            => "intranet",
477                     authnotrequired => 1,
478                     flagsrequired   => { catalogue => 1 },
479                 }
480             );
481         };
482         like ( $@, qr(bad template path), "The file $template_name should not be accessible" );
483     }
484     ( $template, $loggedinuser, $cookies ) = get_template_and_user(
485         {
486             template_name   => 'errors/errorpage.tt',
487             query           => $query,
488             type            => "intranet",
489             authnotrequired => 1,
490             flagsrequired   => { catalogue => 1 },
491         }
492     );
493     my $file_exists = ( -f $template->{filename} ) ? 1 : 0;
494     is ( $file_exists, 1, 'The file errors/errorpage.tt should be accessible (contains integers)' );
495
496     # Regression test for env opac search limit override
497     $ENV{"OPAC_SEARCH_LIMIT"} = "branch:CPL";
498     $ENV{"OPAC_LIMIT_OVERRIDE"} = 1;
499
500     ( $template, $loggedinuser, $cookies) = get_template_and_user(
501         {
502             template_name => 'opac-main.tt',
503             query => $query,
504             type => 'opac',
505             authnotrequired => 1,
506         }
507     );
508     is($template->{VARS}->{'opac_name'}, "CPL", "Opac name was set correctly");
509     is($template->{VARS}->{'opac_search_limit'}, "branch:CPL", "Search limit was set correctly");
510
511     $ENV{"OPAC_SEARCH_LIMIT"} = "branch:multibranch-19";
512
513     ( $template, $loggedinuser, $cookies) = get_template_and_user(
514         {
515             template_name => 'opac-main.tt',
516             query => $query,
517             type => 'opac',
518             authnotrequired => 1,
519         }
520     );
521     is($template->{VARS}->{'opac_name'}, "multibranch-19", "Opac name was set correctly");
522     is($template->{VARS}->{'opac_search_limit'}, "branch:multibranch-19", "Search limit was set correctly");
523
524     delete $ENV{"HTTP_COOKIE"};
525 };
526
527 # Check that there is always an OPACBaseURL set.
528 my $input = CGI->new();
529 my ( $template1, $borrowernumber, $cookie );
530 ( $template1, $borrowernumber, $cookie ) = get_template_and_user(
531     {
532         template_name => "opac-detail.tt",
533         type => "opac",
534         query => $input,
535         authnotrequired => 1,
536     }
537 );
538
539 ok( ( any { 'OPACBaseURL' eq $_ } keys %{$template1->{VARS}} ),
540     'OPACBaseURL is in OPAC template' );
541
542 my ( $template2 );
543 ( $template2, $borrowernumber, $cookie ) = get_template_and_user(
544     {
545         template_name => "catalogue/detail.tt",
546         type => "intranet",
547         query => $input,
548         authnotrequired => 1,
549     }
550 );
551
552 ok( ( any { 'OPACBaseURL' eq $_ } keys %{$template2->{VARS}} ),
553     'OPACBaseURL is in Staff template' );
554
555 my $hash1 = hash_password('password');
556 my $hash2 = hash_password('password');
557
558 ok(C4::Auth::checkpw_hash('password', $hash1), 'password validates with first hash');
559 ok(C4::Auth::checkpw_hash('password', $hash2), 'password validates with second hash');
560
561 subtest 'Check value of login_attempts in checkpw' => sub {
562     plan tests => 11;
563
564     t::lib::Mocks::mock_preference('FailedLoginAttempts', 3);
565
566     # Only interested here in regular login
567     $C4::Auth::cas  = 0;
568     $C4::Auth::ldap = 0;
569
570     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
571     $patron->login_attempts(2);
572     $patron->password('123')->store; # yes, deliberately not hashed
573
574     is( $patron->account_locked, 0, 'Patron not locked' );
575     my @test = checkpw( $dbh, $patron->userid, '123', undef, 'opac', 1 );
576         # Note: 123 will not be hashed to 123 !
577     is( $test[0], 0, 'checkpw should have failed' );
578     $patron->discard_changes; # refresh
579     is( $patron->login_attempts, 3, 'Login attempts increased' );
580     is( $patron->account_locked, 1, 'Check locked status' );
581
582     # And another try to go over the limit: different return value!
583     @test = checkpw( $dbh, $patron->userid, '123', undef, 'opac', 1 );
584     is( @test, 0, 'checkpw failed again and returns nothing now' );
585     $patron->discard_changes; # refresh
586     is( $patron->login_attempts, 3, 'Login attempts not increased anymore' );
587
588     # Administrative lockout cannot be undone?
589     # Pass the right password now (or: add a nice mock).
590     my $auth = Test::MockModule->new( 'C4::Auth' );
591     $auth->mock( 'checkpw_hash', sub { return 1; } ); # not for production :)
592     $patron->login_attempts(0)->store;
593     @test = checkpw( $dbh, $patron->userid, '123', undef, 'opac', 1 );
594     is( $test[0], 1, 'Build confidence in the mock' );
595     $patron->login_attempts(-1)->store;
596     is( $patron->account_locked, 1, 'Check administrative lockout' );
597     @test = checkpw( $dbh, $patron->userid, '123', undef, 'opac', 1 );
598     is( @test, 0, 'checkpw gave red' );
599     $patron->discard_changes; # refresh
600     is( $patron->login_attempts, -1, 'Still locked out' );
601     t::lib::Mocks::mock_preference('FailedLoginAttempts', ''); # disable
602     is( $patron->account_locked, 1, 'Check administrative lockout without pref' );
603 };
604
605 subtest 'Check value of login_attempts in checkpw' => sub {
606     plan tests => 2;
607
608     t::lib::Mocks::mock_preference('FailedLoginAttempts', 3);
609     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
610     $patron->set_password({ password => '123', skip_validation => 1 });
611
612     my @test = checkpw( $dbh, $patron->userid, '123', undef, 'opac', 1 );
613     is( $test[0], 1, 'Patron authenticated correctly' );
614
615     $patron->password_expiration_date('2020-01-01')->store;
616     @test = checkpw( $dbh, $patron->userid, '123', undef, 'opac', 1 );
617     is( $test[0], -2, 'Patron returned as expired correctly' );
618
619 };
620
621 subtest '_timeout_syspref' => sub {
622
623     plan tests => 6;
624
625     t::lib::Mocks::mock_preference('timeout', "100");
626     is( C4::Auth::_timeout_syspref, 100, );
627
628     t::lib::Mocks::mock_preference('timeout', "2d");
629     is( C4::Auth::_timeout_syspref, 2*86400, );
630
631     t::lib::Mocks::mock_preference('timeout', "2D");
632     is( C4::Auth::_timeout_syspref, 2*86400, );
633
634     t::lib::Mocks::mock_preference('timeout', "10h");
635     is( C4::Auth::_timeout_syspref, 10*3600, );
636
637     t::lib::Mocks::mock_preference('timeout', "10x");
638     warning_is
639         { is( C4::Auth::_timeout_syspref, 600, ); }
640         "The value of the system preference 'timeout' is not correct, defaulting to 600",
641         'Bad values throw a warning and fallback to 600';
642 };
643
644 subtest 'check_cookie_auth' => sub {
645     plan tests => 4;
646
647     t::lib::Mocks::mock_preference('timeout', "1d"); # back to default
648
649     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => 1 } });
650
651     # Mock a CGI object with real userid param
652     my $cgi = Test::MockObject->new();
653     $cgi->mock(
654         'param',
655         sub {
656             my $var = shift;
657             if ( $var eq 'userid' ) { return $patron->userid; }
658         }
659     );
660     $cgi->mock('multi_param', sub {return q{}} );
661     $cgi->mock( 'cookie', sub { return; } );
662     $cgi->mock( 'request_method', sub { return 'POST' } );
663
664     $ENV{REMOTE_ADDR} = '127.0.0.1';
665
666     # Setting authnotrequired=1 or we wont' hit the return but the end of the sub that prints headers
667     my ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 1 );
668
669     my ($auth_status, $session) = C4::Auth::check_cookie_auth($sessionID);
670     isnt( $auth_status, 'ok', 'check_cookie_auth should not return ok if the user has not been authenticated before if no permissions needed' );
671     is( $auth_status, 'anon', 'check_cookie_auth should return anon if the user has not been authenticated before and no permissions needed' );
672
673     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 1 );
674
675     ($auth_status, $session) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1});
676     isnt( $auth_status, 'ok', 'check_cookie_auth should not return ok if the user has not been authenticated before and permissions needed' );
677     is( $auth_status, 'anon', 'check_cookie_auth should return anon if the user has not been authenticated before and permissions needed' );
678
679     #FIXME We should have a test to cover 'failed' status when a user has logged in, but doesn't have permission
680 };
681
682 subtest 'checkauth & check_cookie_auth' => sub {
683     plan tests => 31;
684
685     # flags = 4 => { catalogue => 1 }
686     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => 4 } });
687     my $password = 'password';
688     t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
689     $patron->set_password( { password => $password } );
690
691     my $cgi_mock = Test::MockModule->new('CGI');
692     $cgi_mock->mock( 'request_method', sub { return 'POST' } );
693
694     my $cgi = CGI->new;
695
696     my $auth = Test::MockModule->new( 'C4::Auth' );
697     # Tests will fail if we hit safe_exit
698     $auth->mock( 'safe_exit', sub { return } );
699
700     my ( $userid, $cookie, $sessionID, $flags );
701     {
702         # checkauth will redirect and safe_exit if not authenticated and not authorized
703         local *STDOUT;
704         my $stdout;
705         open STDOUT, '>', \$stdout;
706         C4::Auth::checkauth($cgi, 0, {catalogue => 1});
707         like( $stdout, qr{<title>\s*Log in to your account} );
708         $sessionID = ( $stdout =~ m{Set-Cookie: CGISESSID=((\d|\w)+);} ) ? $1 : undef;
709         ok($sessionID);
710         close STDOUT;
711     };
712
713     my $first_sessionID = $sessionID;
714
715     $ENV{"HTTP_COOKIE"} = "CGISESSID=$sessionID";
716     # Not authenticated yet, checkauth didn't return the session
717     {
718         local *STDOUT;
719         my $stdout;
720         open STDOUT, '>', \$stdout;
721         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1} );
722         close STDOUT;
723     }
724     is( $sessionID, undef);
725     is( $userid, undef);
726
727     # Sending undefined fails obviously
728     my ( $auth_status, $session ) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1} );
729     is( $auth_status, 'failed' );
730     is( $session, undef );
731
732     # Simulating the login form submission
733     $cgi->param('userid', $patron->userid);
734     $cgi->param('password', $password);
735
736     # Logged in!
737     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
738     is( $sessionID, $first_sessionID );
739     is( $userid, $patron->userid );
740
741     ( $auth_status, $session ) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1});
742     is( $auth_status, 'ok' );
743     is( $session->id, $first_sessionID );
744
745     # Logging out!
746     $cgi->param('logout.x', 1);
747     $cgi->delete( 'userid', 'password' );
748     {
749         local *STDOUT;
750         my $stdout;
751         open STDOUT, '>', \$stdout;
752         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
753         close STDOUT;
754     }
755     is( $sessionID, undef );
756     is( $ENV{"HTTP_COOKIE"}, "CGISESSID=$first_sessionID", 'HTTP_COOKIE not unset' );
757     ( $auth_status, $session) = C4::Auth::check_cookie_auth( $first_sessionID, {catalogue => 1} );
758     is( $auth_status, "expired");
759     is( $session, undef );
760
761     {
762         # Trying to access without sessionID
763         $cgi = CGI->new;
764         ( $auth_status, $session) = C4::Auth::check_cookie_auth(undef, {catalogue => 1});
765         is( $auth_status, 'failed' );
766         is( $session, undef );
767
768         # This will fail on permissions
769         undef $ENV{"HTTP_COOKIE"};
770         {
771             local *STDOUT;
772             my $stdout;
773             open STDOUT, '>', \$stdout;
774             ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1} );
775             close STDOUT;
776         }
777         is( $userid, undef );
778         is( $sessionID, undef );
779     }
780
781     {
782         # First logging in
783         $cgi = CGI->new;
784         $cgi->param('userid', $patron->userid);
785         $cgi->param('password', $password);
786         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
787         is( $userid, $patron->userid );
788         $first_sessionID = $sessionID;
789
790         # Patron does not have the borrowers permission
791         # $ENV{"HTTP_COOKIE"} = "CGISESSID=$sessionID"; # not needed, we use $cgi here
792         {
793             local *STDOUT;
794             my $stdout;
795             open STDOUT, '>', \$stdout;
796             ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {borrowers => 1} );
797             close STDOUT;
798         }
799         is( $userid, undef );
800         is( $sessionID, undef );
801
802         # When calling check_cookie_auth, the session will be deleted
803         ( $auth_status, $session) = C4::Auth::check_cookie_auth( $first_sessionID, { borrowers => 1 } );
804         is( $auth_status, "failed" );
805         is( $session, undef );
806         ( $auth_status, $session) = C4::Auth::check_cookie_auth( $first_sessionID, { borrowers => 1 } );
807         is( $auth_status, 'expired', 'Session no longer exists' );
808
809         # NOTE: It is not what the UI is doing.
810         # From the UI we are allowed to hit an unauthorized page then reuse the session to hit back authorized area.
811         # It is because check_cookie_auth is ALWAYS called from checkauth WITHOUT $flagsrequired
812         # It then return "ok", when the previous called got "failed"
813
814         # Try reusing the deleted session: since it does not exist, we should get a new one now when passing correct permissions
815         $cgi->cookie( -name => 'CGISESSID', value => $first_sessionID );
816         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
817         is( $userid, $patron->userid );
818         isnt( $sessionID, undef, 'Check if we have a sessionID' );
819         isnt( $sessionID, $first_sessionID, 'New value expected' );
820         ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID, {catalogue => 1} );
821         is( $auth_status, "ok" );
822         is( $session->id, $sessionID, 'Same session' );
823         # Two additional tests on userenv
824         is( $C4::Context::context->{activeuser}, $session->id, 'Check if environment has been setup for session' );
825         is( C4::Context->userenv->{id}, $userid, 'Check userid in userenv' );
826     }
827 };
828
829 subtest 'Userenv clearing in check_cookie_auth' => sub {
830     # Note: We did already test userenv for a logged-in user in previous subtest
831     plan tests => 9;
832
833     t::lib::Mocks::mock_preference( 'timeout', 600 );
834     my $cgi = CGI->new;
835
836     # Create a new anonymous session by passing a fake session ID
837     $cgi->cookie( -name => 'CGISESSID', -value => 'fake_sessionID' );
838     my ($userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 1);
839     my ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID );
840     is( $auth_status, 'anon', 'Should be anonymous' );
841     is( $C4::Context::context->{activeuser}, $session->id, 'Check activeuser' );
842     is( defined C4::Context->userenv, 1, 'There should be a userenv' );
843     is(  C4::Context->userenv->{id}, q{}, 'userid should be empty string' );
844
845     # Make the session expire now, check_cookie_auth will delete it
846     $session->param('lasttime', time() - 1200 );
847     $session->flush;
848     ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID );
849     is( $auth_status, 'expired', 'Should be expired' );
850     is( C4::Context->userenv, undef, 'Environment should be cleared too' );
851
852     # Show that we clear the userenv again: set up env and check deleted session
853     C4::Context->_new_userenv( $sessionID );
854     C4::Context->set_userenv; # empty
855     is( defined C4::Context->userenv, 1, 'There should be an empty userenv again' );
856     ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID );
857     is( $auth_status, 'expired', 'Should be expired already' );
858     is( C4::Context->userenv, undef, 'Environment should be cleared again' );
859 };
860
861 $schema->storage->txn_rollback;