Completed
Push — master ( 4ad6bd...3873e4 )
by Ingo
11:53
created

SecurityTest   C

Complexity

Total Complexity 33

Size/Duplication

Total Lines 698
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 21

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 0
loc 698
rs 5.7444
wmc 33
lcom 1
cbo 21
1
<?php
2
3
namespace SilverStripe\Security\Tests;
4
5
use SilverStripe\Control\Controller;
6
use SilverStripe\Control\Director;
7
use SilverStripe\Control\HTTPResponse;
8
use SilverStripe\Core\Config\Config;
9
use SilverStripe\Core\Convert;
10
use SilverStripe\Dev\FunctionalTest;
11
use SilverStripe\i18n\i18n;
12
use SilverStripe\ORM\DataObject;
13
use SilverStripe\ORM\DB;
14
use SilverStripe\ORM\FieldType\DBClassName;
15
use SilverStripe\ORM\FieldType\DBDatetime;
16
use SilverStripe\ORM\ValidationResult;
17
use SilverStripe\Security\LoginAttempt;
18
use SilverStripe\Security\Member;
19
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
20
use SilverStripe\Security\Security;
21
use SilverStripe\Security\SecurityToken;
22
use SilverStripe\Core\Config\Config;
0 ignored issues
show
Bug introduced by
This code did not parse for me. Apparently, there is an error somewhere around this line:

Cannot use SilverStripe\Core\Config\Config as Config because the name is already in use
Loading history...
23
use SilverStripe\Core\Convert;
24
use SilverStripe\Dev\FunctionalTest;
25
use SilverStripe\Dev\TestOnly;
26
use SilverStripe\Control\HTTPResponse;
27
use SilverStripe\Control\Session;
28
use SilverStripe\Control\Director;
29
use SilverStripe\Control\Controller;
30
use SilverStripe\i18n\i18n;
31
32
/**
33
 * Test the security class, including log-in form, change password form, etc
34
 */
35
class SecurityTest extends FunctionalTest
36
{
37
    protected static $fixture_file = 'MemberTest.yml';
38
39
    protected $autoFollowRedirection = false;
40
41
    protected static $extra_controllers = [
42
        SecurityTest\NullController::class,
43
        SecurityTest\SecuredController::class,
44
    ];
45
46
    protected function setUp()
47
    {
48
        // Set to an empty array of authenticators to enable the default
49
        Config::modify()->set(MemberAuthenticator::class, 'authenticators', []);
50
        Config::modify()->set(MemberAuthenticator::class, 'default_authenticator', MemberAuthenticator::class);
51
52
        /**
53
         * @skipUpgrade
54
         */
55
        Member::config()->set('unique_identifier_field', 'Email');
56
57
        parent::setUp();
58
59
        Director::config()->set('alternate_base_url', '/');
60
    }
61
62
    public function testAccessingAuthenticatedPageRedirectsToLoginForm()
63
    {
64
        $this->autoFollowRedirection = false;
65
66
        $response = $this->get('SecurityTest_SecuredController');
67
        $this->assertEquals(302, $response->getStatusCode());
68
        $this->assertContains(
69
            Config::inst()->get(Security::class, 'login_url'),
70
            $response->getHeader('Location')
71
        );
72
73
        $this->logInWithPermission('ADMIN');
74
        $response = $this->get('SecurityTest_SecuredController');
75
        $this->assertEquals(200, $response->getStatusCode());
76
        $this->assertContains('Success', $response->getBody());
77
78
        $this->autoFollowRedirection = true;
79
    }
80
81
    public function testPermissionFailureSetsCorrectFormMessages()
82
    {
83
        // Controller that doesn't attempt redirections
84
        $controller = new SecurityTest\NullController();
85
        $controller->setRequest(Controller::curr()->getRequest());
86
        $controller->setResponse(new HTTPResponse());
87
88
        $session = Controller::curr()->getRequest()->getSession();
89
        Security::permissionFailure($controller, array('default' => 'Oops, not allowed'));
90
        $this->assertEquals('Oops, not allowed', $session->get('Security.Message.message'));
91
92
        // Test that config values are used correctly
93
        Config::modify()->set(Security::class, 'default_message_set', 'stringvalue');
94
        Security::permissionFailure($controller);
95
        $this->assertEquals(
96
            'stringvalue',
97
            $session->get('Security.Message.message'),
98
            'Default permission failure message value was not present'
99
        );
100
101
        Config::modify()->remove(Security::class, 'default_message_set');
102
        Config::modify()->merge(Security::class, 'default_message_set', array('default' => 'arrayvalue'));
103
        Security::permissionFailure($controller);
104
        $this->assertEquals(
105
            'arrayvalue',
106
            $session->get('Security.Message.message'),
107
            'Default permission failure message value was not present'
108
        );
109
110
        // Test that non-default messages work.
111
        // NOTE: we inspect the response body here as the session message has already
112
        // been fetched and output as part of it, so has been removed from the session
113
        $this->logInWithPermission('EDITOR');
114
115
        Config::modify()->set(
116
            Security::class,
117
            'default_message_set',
118
            array('default' => 'default', 'alreadyLoggedIn' => 'You are already logged in!')
119
        );
120
        Security::permissionFailure($controller);
121
        $this->assertContains(
122
            'You are already logged in!',
123
            $controller->getResponse()->getBody(),
124
            'Custom permission failure message was ignored'
125
        );
126
127
        Security::permissionFailure(
128
            $controller,
129
            array('default' => 'default', 'alreadyLoggedIn' => 'One-off failure message')
130
        );
131
        $this->assertContains(
132
            'One-off failure message',
133
            $controller->getResponse()->getBody(),
134
            "Message set passed to Security::permissionFailure() didn't override Config values"
135
        );
136
    }
137
138
    /**
139
     * Follow all redirects recursively
140
     *
141
     * @param  string $url
142
     * @param  int    $limit Max number of requests
143
     * @return HTTPResponse
144
     */
145
    protected function getRecursive($url, $limit = 10)
146
    {
147
        $this->cssParser = null;
148
        $response = $this->mainSession->get($url);
149
        while (--$limit > 0 && $response instanceof HTTPResponse && $response->getHeader('Location')) {
150
            $response = $this->mainSession->followRedirection();
151
        }
152
        return $response;
153
    }
154
155
    public function testAutomaticRedirectionOnLogin()
156
    {
157
        // BackURL with permission error (not authenticated) should not redirect
158
        if ($member = Security::getCurrentUser()) {
159
            Security::setCurrentUser(null);
160
        }
161
        $response = $this->getRecursive('SecurityTest_SecuredController');
162
        $this->assertContains(Convert::raw2xml("That page is secured."), $response->getBody());
163
        $this->assertContains('<input type="submit" name="action_doLogin"', $response->getBody());
164
165
        // Non-logged in user should not be redirected, but instead shown the login form
166
        // No message/context is available as the user has not attempted to view the secured controller
167
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
168
        $this->assertNotContains(Convert::raw2xml("That page is secured."), $response->getBody());
169
        $this->assertNotContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
170
        $this->assertContains('<input type="submit" name="action_doLogin"', $response->getBody());
171
172
        // BackURL with permission error (wrong permissions) should not redirect
173
        $this->logInAs('grouplessmember');
174
        $response = $this->getRecursive('SecurityTest_SecuredController');
175
        $this->assertContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
176
        $this->assertContains(
177
            '<input type="submit" name="action_logout" value="Log in as someone else"',
178
            $response->getBody()
179
        );
180
181
        // Directly accessing this page should attempt to follow the BackURL, but stop when it encounters the error
182
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
183
        $this->assertContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
184
        $this->assertContains(
185
            '<input type="submit" name="action_logout" value="Log in as someone else"',
186
            $response->getBody()
187
        );
188
189
        // Check correctly logged in admin doesn't generate the same errors
190
        $this->logInAs('admin');
191
        $response = $this->getRecursive('SecurityTest_SecuredController');
192
        $this->assertContains(Convert::raw2xml("Success"), $response->getBody());
193
194
        // Directly accessing this page should attempt to follow the BackURL and succeed
195
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
196
        $this->assertContains(Convert::raw2xml("Success"), $response->getBody());
197
    }
198
199
    public function testLogInAsSomeoneElse()
200
    {
201
        $member = DataObject::get_one(Member::class);
202
203
        /* Log in with any user that we can find */
204
        Security::setCurrentUser($member);
205
206
        /* View the Security/login page */
207
        $this->get(Config::inst()->get(Security::class, 'login_url'));
208
209
        $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.action');
210
211
        /* We have only 1 input, one to allow the user to log in as someone else */
212
        $this->assertEquals(count($items), 1, 'There is 1 input, allowing the user to log in as someone else.');
213
214
        $this->autoFollowRedirection = true;
215
216
        /* Submit the form, using only the logout action and a hidden field for the authenticator */
217
        $response = $this->submitForm(
218
            'MemberLoginForm_LoginForm',
219
            null,
220
            array(
221
                'action_logout' => 1,
222
            )
223
        );
224
225
        /* We get a good response */
226
        $this->assertEquals($response->getStatusCode(), 200, 'We have a 200 OK response');
227
        $this->assertNotNull($response->getBody(), 'There is body content on the page');
228
229
        /* Log the user out */
230
        Security::setCurrentUser(null);
231
    }
232
233
    public function testMemberIDInSessionDoesntExistInDatabaseHasToLogin()
234
    {
235
        /* Log in with a Member ID that doesn't exist in the DB */
236
        $this->session()->set('loggedInAs', 500);
237
238
        $this->autoFollowRedirection = true;
239
240
        /* Attempt to get into the admin section */
241
        $this->get(Config::inst()->get(Security::class, 'login_url'));
242
243
        $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.text');
244
245
        /* We have 2 text inputs - one for email, and another for the password */
246
        $this->assertEquals(count($items), 2, 'There are 2 inputs - one for email, another for password');
247
248
        $this->autoFollowRedirection = false;
249
250
        /* Log the user out */
251
        $this->session()->set('loggedInAs', null);
252
    }
253
254
    public function testLoginUsernamePersists()
255
    {
256
        // Test that username does not persist
257
        $this->session()->set('SessionForms.MemberLoginForm.Email', '[email protected]');
258
        Security::config()->set('remember_username', false);
259
        $this->get(Config::inst()->get(Security::class, 'login_url'));
260
        $items = $this
261
            ->cssParser()
262
            ->getBySelector('#MemberLoginForm_LoginForm #MemberLoginForm_LoginForm_Email');
263
        $this->assertEquals(1, count($items));
264
        $this->assertEmpty((string)$items[0]->attributes()->value);
265
        $this->assertEquals('off', (string)$items[0]->attributes()->autocomplete);
266
        $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm');
267
        $this->assertEquals(1, count($form));
268
        $this->assertEquals('off', (string)$form[0]->attributes()->autocomplete);
269
270
        // Test that username does persist when necessary
271
        $this->session()->set('SessionForms.MemberLoginForm.Email', '[email protected]');
272
        Security::config()->set('remember_username', true);
273
        $this->get(Config::inst()->get(Security::class, 'login_url'));
274
        $items = $this
275
            ->cssParser()
276
            ->getBySelector('#MemberLoginForm_LoginForm #MemberLoginForm_LoginForm_Email');
277
        $this->assertEquals(1, count($items));
278
        $this->assertEquals('[email protected]', (string)$items[0]->attributes()->value);
279
        $this->assertNotEquals('off', (string)$items[0]->attributes()->autocomplete);
280
        $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm');
281
        $this->assertEquals(1, count($form));
282
        $this->assertNotEquals('off', (string)$form[0]->attributes()->autocomplete);
283
    }
284
285
    public function testLogout()
286
    {
287
        /* Enable SecurityToken */
288
        $securityTokenWasEnabled = SecurityToken::is_enabled();
289
        SecurityToken::enable();
290
291
        $member = DataObject::get_one(Member::class);
292
293
        /* Log in with any user that we can find */
294
        Security::setCurrentUser($member);
295
296
        /* Visit the Security/logout page with a test referer, but without a security token */
297
        $response = $this->get(
298
            Config::inst()->get(Security::class, 'logout_url'),
299
            null,
300
            ['Referer' => Director::absoluteBaseURL() . 'testpage']
301
        );
302
303
        /* Make sure the user is still logged in */
304
        $this->assertNotNull(Security::getCurrentUser(), 'User is still logged in.');
305
306
        $token = $this->cssParser()->getBySelector('#LogoutForm_Form #LogoutForm_Form_SecurityID');
307
        $actions = $this->cssParser()->getBySelector('#LogoutForm_Form input.action');
308
309
        /* We have a security token, and an action to allow the user to log out */
310
        $this->assertCount(1, $token, 'There is a hidden field containing a security token.');
311
        $this->assertCount(1, $actions, 'There is 1 action, allowing the user to log out.');
312
313
        /* Submit the form, using the logout action */
314
        $response = $this->submitForm(
315
            'LogoutForm_Form',
316
            null,
317
            array(
318
                'action_doLogout' => 1,
319
            )
320
        );
321
322
        /* We get a good response */
323
        $this->assertEquals(302, $response->getStatusCode());
324
        $this->assertRegExp(
325
            '/testpage/',
326
            $response->getHeader('Location'),
327
            "Logout form redirects to back to referer."
328
        );
329
330
        /* User is logged out successfully */
331
        $this->assertNull(Security::getCurrentUser(), 'User is logged out.');
332
333
        /* Re-disable SecurityToken */
334
        if (!$securityTokenWasEnabled) {
335
            SecurityToken::disable();
336
        }
337
    }
338
339
    public function testExternalBackUrlRedirectionDisallowed()
340
    {
341
        // Test internal relative redirect
342
        $response = $this->doTestLoginForm('[email protected]', '1nitialPassword', 'testpage');
343
        $this->assertEquals(302, $response->getStatusCode());
344
        $this->assertRegExp(
345
            '/testpage/',
346
            $response->getHeader('Location'),
347
            "Internal relative BackURLs work when passed through to login form"
348
        );
349
        // Log the user out
350
        $this->session()->set('loggedInAs', null);
351
352
        // Test internal absolute redirect
353
        $response = $this->doTestLoginForm(
354
            '[email protected]',
355
            '1nitialPassword',
356
            Director::absoluteBaseURL() . 'testpage'
357
        );
358
        // for some reason the redirect happens to a relative URL
359
        $this->assertRegExp(
360
            '/^' . preg_quote(Director::absoluteBaseURL(), '/') . 'testpage/',
361
            $response->getHeader('Location'),
362
            "Internal absolute BackURLs work when passed through to login form"
363
        );
364
        // Log the user out
365
        $this->session()->set('loggedInAs', null);
366
367
        // Test external redirect
368
        $response = $this->doTestLoginForm('[email protected]', '1nitialPassword', 'http://myspoofedhost.com');
369
        $this->assertNotRegExp(
370
            '/^' . preg_quote('http://myspoofedhost.com', '/') . '/',
371
            (string)$response->getHeader('Location'),
372
            "Redirection to external links in login form BackURL gets prevented as a measure against spoofing attacks"
373
        );
374
375
        // Test external redirection on ChangePasswordForm
376
        $this->get('Security/changepassword?BackURL=http://myspoofedhost.com');
377
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
378
        $this->assertNotRegExp(
379
            '/^' . preg_quote('http://myspoofedhost.com', '/') . '/',
380
            (string)$changedResponse->getHeader('Location'),
381
            "Redirection to external links in change password form BackURL gets prevented to stop spoofing attacks"
382
        );
383
384
        // Log the user out
385
        $this->session()->set('loggedInAs', null);
386
    }
387
388
    /**
389
     * Test that the login form redirects to the change password form after logging in with an expired password
390
     */
391
    public function testExpiredPassword()
392
    {
393
        /* BAD PASSWORDS ARE LOCKED OUT */
394
        $badResponse = $this->doTestLoginForm('[email protected]', 'badpassword');
395
        $this->assertEquals(302, $badResponse->getStatusCode());
396
        $this->assertRegExp('/Security\/login/', $badResponse->getHeader('Location'));
397
        $this->assertNull($this->session()->get('loggedInAs'));
398
399
        /* UNEXPIRED PASSWORD GO THROUGH WITHOUT A HITCH */
400
        $goodResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
401
        $this->assertEquals(302, $goodResponse->getStatusCode());
402
        $this->assertEquals(
403
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
404
            $goodResponse->getHeader('Location')
405
        );
406
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->get('loggedInAs'));
407
408
        $this->logOut();
409
410
        /* EXPIRED PASSWORDS ARE SENT TO THE CHANGE PASSWORD FORM */
411
        $expiredResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
412
        $this->assertEquals(302, $expiredResponse->getStatusCode());
413
        $this->assertEquals(
414
            Director::absoluteURL('Security/changepassword').'?BackURL=test%2Flink',
415
            Director::absoluteURL($expiredResponse->getHeader('Location'))
416
        );
417
        $this->assertEquals(
418
            $this->idFromFixture(Member::class, 'expiredpassword'),
419
            $this->session()->get('loggedInAs')
420
        );
421
422
        // Make sure it redirects correctly after the password has been changed
423
        $this->mainSession->followRedirection();
424
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
425
        $this->assertEquals(302, $changedResponse->getStatusCode());
426
        $this->assertEquals(
427
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
428
            $changedResponse->getHeader('Location')
429
        );
430
    }
431
432
    public function testChangePasswordForLoggedInUsers()
433
    {
434
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
435
436
        // Change the password
437
        $this->get('Security/changepassword?BackURL=test/back');
438
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
439
        $this->assertEquals(302, $changedResponse->getStatusCode());
440
        $this->assertEquals(
441
            Controller::join_links(Director::absoluteBaseURL(), 'test/back'),
442
            $changedResponse->getHeader('Location')
443
        );
444
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->get('loggedInAs'));
445
446
        // Check if we can login with the new password
447
        $this->logOut();
448
        $goodResponse = $this->doTestLoginForm('[email protected]', 'changedPassword');
449
        $this->assertEquals(302, $goodResponse->getStatusCode());
450
        $this->assertEquals(
451
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
452
            $goodResponse->getHeader('Location')
453
        );
454
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->get('loggedInAs'));
455
    }
456
457
    public function testChangePasswordFromLostPassword()
458
    {
459
        /** @var Member $admin */
460
        $admin = $this->objFromFixture(Member::class, 'test');
461
        $admin->FailedLoginCount = 99;
462
        $admin->LockedOutUntil = DBDatetime::now()->getValue();
463
        $admin->write();
464
465
        $this->assertNull($admin->AutoLoginHash, 'Hash is empty before lost password');
466
467
        // Request new password by email
468
        $this->get('Security/lostpassword');
469
        $this->post('Security/lostpassword/LostPasswordForm', array('Email' => '[email protected]'));
470
471
        $this->assertEmailSent('[email protected]');
472
473
        // Load password link from email
474
        $admin = DataObject::get_by_id(Member::class, $admin->ID);
475
        $this->assertNotNull($admin->AutoLoginHash, 'Hash has been written after lost password');
476
477
        // We don't have access to the token - generate a new token and hash pair.
478
        $token = $admin->generateAutologinTokenAndStoreHash();
479
480
        // Check.
481
        $response = $this->get('Security/changepassword/?m='.$admin->ID.'&t=' . $token);
482
        $this->assertEquals(302, $response->getStatusCode());
483
        $this->assertEquals(
484
            Director::absoluteURL('Security/changepassword'),
485
            Director::absoluteURL($response->getHeader('Location'))
486
        );
487
488
        // Follow redirection to form without hash in GET parameter
489
        $this->get('Security/changepassword');
490
        $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
491
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->get('loggedInAs'));
492
493
        // Check if we can login with the new password
494
        $this->logOut();
495
        $goodResponse = $this->doTestLoginForm('[email protected]', 'changedPassword');
496
        $this->assertEquals(302, $goodResponse->getStatusCode());
497
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->get('loggedInAs'));
498
499
        $admin = DataObject::get_by_id(Member::class, $admin->ID, false);
500
        $this->assertNull($admin->LockedOutUntil);
501
        $this->assertEquals(0, $admin->FailedLoginCount);
502
    }
503
504
    public function testRepeatedLoginAttemptsLockingPeopleOut()
505
    {
506
        i18n::set_locale('en_US');
507
        Member::config()->set('lock_out_after_incorrect_logins', 5);
508
        Member::config()->set('lock_out_delay_mins', 15);
509
510
        // Login with a wrong password for more than the defined threshold
511
        for ($i = 1; $i <= 6; $i++) {
512
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
513
            /** @var Member $member */
514
            $member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
515
516
            if ($i < 5) {
517
                $this->assertNull(
518
                    $member->LockedOutUntil,
519
                    'User does not have a lockout time set if under threshold for failed attempts'
520
                );
521
                $this->assertHasMessage(
522
                    _t(
523
                        'SilverStripe\\Security\\Member.ERRORWRONGCRED',
524
                        'The provided details don\'t seem to be correct. Please try again.'
525
                    )
526
                );
527
            } else {
528
                // Fuzzy matching for time to avoid side effects from slow running tests
529
                $this->assertGreaterThan(
530
                    time() + 14*60,
531
                    strtotime($member->LockedOutUntil),
532
                    'User has a lockout time set after too many failed attempts'
533
                );
534
            }
535
        }
536
            $msg = _t(
537
                'SilverStripe\\Security\\Member.ERRORLOCKEDOUT2',
538
                'Your account has been temporarily disabled because of too many failed attempts at ' .
539
                'logging in. Please try again in {count} minutes.',
540
                null,
541
                array('count' => 15)
542
            );
543
                $this->assertHasMessage($msg);
544
545
546
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
547
        $this->assertNull(
548
            $this->session()->get('loggedInAs'),
549
            'The user can\'t log in after being locked out, even with the right password'
550
        );
551
552
        // (We fake this by re-setting LockedOutUntil)
553
        $member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
554
        $member->LockedOutUntil = date('Y-m-d H:i:s', time() - 30);
555
        $member->write();
556
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
557
        $this->assertEquals(
558
            $this->session()->get('loggedInAs'),
559
            $member->ID,
560
            'After lockout expires, the user can login again'
561
        );
562
563
        // Log the user out
564
        $this->logOut();
565
566
        // Login again with wrong password, but less attempts than threshold
567
        for ($i = 1; $i < 5; $i++) {
568
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
569
        }
570
        $this->assertNull($this->session()->get('loggedInAs'));
571
        $this->assertHasMessage(
572
            _t('SilverStripe\\Security\\Member.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.'),
573
            'The user can retry with a wrong password after the lockout expires'
574
        );
575
576
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
577
        $this->assertEquals(
578
            $this->session()->get('loggedInAs'),
579
            $member->ID,
580
            'The user can login successfully after lockout expires, if staying below the threshold'
581
        );
582
    }
583
584
    public function testAlternatingRepeatedLoginAttempts()
585
    {
586
        Member::config()->set('lock_out_after_incorrect_logins', 3);
587
588
        // ATTEMPTING LOG-IN TWICE WITH ONE ACCOUNT AND TWICE WITH ANOTHER SHOULDN'T LOCK ANYBODY OUT
589
590
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
591
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
592
593
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
594
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
595
596
        /** @var Member $member1 */
597
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
598
        /** @var Member $member2 */
599
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
600
601
        $this->assertNull($member1->LockedOutUntil);
602
        $this->assertNull($member2->LockedOutUntil);
603
604
        // BUT, DOING AN ADDITIONAL LOG-IN WITH EITHER OF THEM WILL LOCK OUT, SINCE THAT IS THE 3RD FAILURE IN
605
        // THIS SESSION
606
607
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
608
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
609
        $this->assertNotNull($member1->LockedOutUntil);
610
611
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
612
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
613
        $this->assertNotNull($member2->LockedOutUntil);
614
    }
615
616
    public function testUnsuccessfulLoginAttempts()
617
    {
618
        Security::config()->set('login_recording', true);
619
620
        /* UNSUCCESSFUL ATTEMPTS WITH WRONG PASSWORD FOR EXISTING USER ARE LOGGED */
621
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
622
        /** @var LoginAttempt $attempt */
623
        $attempt = DataObject::get_one(
624
            LoginAttempt::class,
625
            array(
626
            '"LoginAttempt"."Email"' => '[email protected]'
627
            )
628
        );
629
        $this->assertInstanceOf(LoginAttempt::class, $attempt);
630
        $member = DataObject::get_one(
631
            Member::class,
632
            array(
633
            '"Member"."Email"' => '[email protected]'
634
            )
635
        );
636
        $this->assertEquals($attempt->Status, 'Failure');
637
        $this->assertEquals($attempt->Email, '[email protected]');
638
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
639
640
        /* UNSUCCESSFUL ATTEMPTS WITH NONEXISTING USER ARE LOGGED */
641
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
642
        $attempt = DataObject::get_one(
643
            LoginAttempt::class,
644
            array(
645
            '"LoginAttempt"."Email"' => '[email protected]'
646
            )
647
        );
648
        $this->assertTrue(is_object($attempt));
649
        $this->assertEquals($attempt->Status, 'Failure');
650
        $this->assertEquals($attempt->Email, '[email protected]');
651
        $this->assertNotEmpty($this->getValidationResult()->getMessages(), 'An invalid email returns a message.');
652
    }
653
654
    public function testSuccessfulLoginAttempts()
655
    {
656
        Security::config()->set('login_recording', true);
657
658
        /* SUCCESSFUL ATTEMPTS ARE LOGGED */
659
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
660
        /** @var LoginAttempt $attempt */
661
        $attempt = DataObject::get_one(
662
            LoginAttempt::class,
663
            array(
664
            '"LoginAttempt"."Email"' => '[email protected]'
665
            )
666
        );
667
        /** @var Member $member */
668
        $member = DataObject::get_one(
669
            Member::class,
670
            array(
671
            '"Member"."Email"' => '[email protected]'
672
            )
673
        );
674
        $this->assertTrue(is_object($attempt));
675
        $this->assertEquals($attempt->Status, 'Success');
676
        $this->assertEquals($attempt->Email, '[email protected]');
677
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
678
    }
679
680
    public function testDatabaseIsReadyWithInsufficientMemberColumns()
681
    {
682
        Security::clear_database_is_ready();
683
        DBClassName::clear_classname_cache();
684
685
        // Assumption: The database has been built correctly by the test runner,
686
        // and has all columns present in the ORM
687
        /**
688
         * @skipUpgrade
689
         */
690
        DB::get_schema()->renameField('Member', 'Email', 'Email_renamed');
691
692
        // Email column is now missing, which means we're not ready to do permission checks
693
        $this->assertFalse(Security::database_is_ready());
694
695
        // Rebuild the database (which re-adds the Email column), and try again
696
        static::resetDBSchema(true);
697
        $this->assertTrue(Security::database_is_ready());
698
    }
699
700
    public function testSecurityControllerSendsRobotsTagHeader()
701
    {
702
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
703
        $robotsHeader = $response->getHeader('X-Robots-Tag');
704
        $this->assertNotNull($robotsHeader);
705
        $this->assertContains('noindex', $robotsHeader);
706
    }
707
708
    public function testDoNotSendEmptyRobotsHeaderIfNotDefined()
709
    {
710
        Config::modify()->remove(Security::class, 'robots_tag');
711
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
712
        $robotsHeader = $response->getHeader('X-Robots-Tag');
713
        $this->assertNull($robotsHeader);
714
    }
715
716
    /**
717
     * Execute a log-in form using Director::test().
718
     * Helper method for the tests above
719
     *
720
     * @param string $email
721
     * @param string $password
722
     * @param string $backURL
723
     * @return HTTPResponse
724
     */
725
    public function doTestLoginForm($email, $password, $backURL = 'test/link')
726
    {
727
        $this->get(Config::inst()->get(Security::class, 'logout_url'));
728
        $this->session()->set('BackURL', $backURL);
729
        $this->get(Config::inst()->get(Security::class, 'login_url'));
730
731
        return $this->submitForm(
732
            "MemberLoginForm_LoginForm",
733
            null,
734
            array(
735
                'Email' => $email,
736
                'Password' => $password,
737
                'AuthenticationMethod' => MemberAuthenticator::class,
738
                'action_doLogin' => 1,
739
            )
740
        );
741
    }
742
743
    /**
744
     * Helper method to execute a change password form
745
     *
746
     * @param string $oldPassword
747
     * @param string $newPassword
748
     * @return HTTPResponse
749
     */
750
    public function doTestChangepasswordForm($oldPassword, $newPassword)
751
    {
752
        return $this->submitForm(
753
            "ChangePasswordForm_ChangePasswordForm",
754
            null,
755
            array(
756
                'OldPassword' => $oldPassword,
757
                'NewPassword1' => $newPassword,
758
                'NewPassword2' => $newPassword,
759
                'action_doChangePassword' => 1,
760
            )
761
        );
762
    }
763
764
    /**
765
     * Assert this message is in the current login form errors
766
     *
767
     * @param string $expected
768
     * @param string $errorMessage
769
     */
770
    protected function assertHasMessage($expected, $errorMessage = null)
771
    {
772
        $messages = [];
773
        $result = $this->getValidationResult();
774
        if ($result) {
775
            foreach ($result->getMessages() as $message) {
776
                $messages[] = $message['message'];
777
            }
778
        }
779
780
        $this->assertContains($expected, $messages, $errorMessage);
781
    }
782
783
    /**
784
     * Get validation result from last login form submission
785
     *
786
     * @return ValidationResult
787
     */
788
    protected function getValidationResult()
789
    {
790
        $result = $this->session()->get('FormInfo.MemberLoginForm_LoginForm.result');
791
        if ($result) {
792
            return unserialize($result);
793
        }
794
        return null;
795
    }
796
}
797