Passed
Pull Request — 4 (#8209)
by Ingo
09:07
created

SecurityTest   A

Complexity

Total Complexity 37

Size/Duplication

Total Lines 777
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
dl 0
loc 777
rs 9.263
c 0
b 0
f 0
wmc 37

25 Methods

Rating   Name   Duplication   Size   Complexity  
A testExpiredPassword() 0 38 1
A doTestLoginForm() 0 14 1
A testUnsuccessfulLoginAttempts() 0 23 1
A testSecurityControllerSendsRobotsTagHeader() 0 6 1
A testChangePasswordFromLostPassword() 0 45 1
A testExternalBackUrlRedirectionDisallowed() 0 47 1
A testSuccessfulLoginAttempts() 0 14 1
A testLoginUsernamePersists() 0 29 1
A doTestChangepasswordForm() 0 10 1
A testChangePasswordForLoggedInUsers() 0 23 1
A testAutomaticRedirectionOnLogin() 0 42 2
A testAccessingAuthenticatedPageRedirectsToLoginForm() 0 17 1
A setUp() 0 14 1
A assertHasMessage() 0 11 3
A testAlternatingRepeatedLoginAttempts() 0 30 1
A testGetResponseController() 0 21 2
A testMemberIDInSessionDoesntExistInDatabaseHasToLogin() 0 19 1
A testDoNotSendEmptyRobotsHeaderIfNotDefined() 0 6 1
A testLogInAsSomeoneElse() 0 29 1
A getValidationResult() 0 7 2
A testDatabaseIsReadyWithInsufficientMemberColumns() 0 18 1
A getRecursive() 0 8 4
B testRepeatedLoginAttemptsLockingPeopleOut() 0 77 4
A testLogout() 0 51 2
B testPermissionFailureSetsCorrectFormMessages() 0 74 1
1
<?php
2
3
namespace SilverStripe\Security\Tests;
4
5
use Page;
0 ignored issues
show
Bug introduced by
The type Page was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
6
use SilverStripe\Control\Controller;
7
use SilverStripe\Control\Director;
8
use SilverStripe\Control\HTTPRequest;
9
use SilverStripe\Control\HTTPResponse;
10
use SilverStripe\Control\Session;
11
use SilverStripe\Core\Config\Config;
12
use SilverStripe\Core\Convert;
13
use SilverStripe\Dev\FunctionalTest;
14
use SilverStripe\i18n\i18n;
15
use SilverStripe\ORM\DataObject;
16
use SilverStripe\ORM\DB;
17
use SilverStripe\ORM\FieldType\DBClassName;
18
use SilverStripe\ORM\FieldType\DBDatetime;
19
use SilverStripe\ORM\FieldType\DBField;
20
use SilverStripe\ORM\ValidationResult;
21
use SilverStripe\Security\LoginAttempt;
22
use SilverStripe\Security\Member;
23
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
24
use SilverStripe\Security\Security;
25
use SilverStripe\Security\SecurityToken;
26
27
/**
28
 * Test the security class, including log-in form, change password form, etc
29
 *
30
 * @skipUpgrade
31
 */
32
class SecurityTest extends FunctionalTest
33
{
34
    protected static $fixture_file = 'MemberTest.yml';
35
36
    protected $autoFollowRedirection = false;
37
38
    protected static $extra_controllers = [
39
        SecurityTest\NullController::class,
40
        SecurityTest\SecuredController::class,
41
    ];
42
43
    protected function setUp()
44
    {
45
        // Set to an empty array of authenticators to enable the default
46
        Config::modify()->set(MemberAuthenticator::class, 'authenticators', []);
47
        Config::modify()->set(MemberAuthenticator::class, 'default_authenticator', MemberAuthenticator::class);
48
49
        /**
50
         * @skipUpgrade
51
         */
52
        Member::config()->set('unique_identifier_field', 'Email');
53
54
        parent::setUp();
55
56
        Director::config()->set('alternate_base_url', '/');
57
    }
58
59
    public function testAccessingAuthenticatedPageRedirectsToLoginForm()
60
    {
61
        $this->autoFollowRedirection = false;
62
63
        $response = $this->get('SecurityTest_SecuredController');
64
        $this->assertEquals(302, $response->getStatusCode());
65
        $this->assertContains(
66
            Config::inst()->get(Security::class, 'login_url'),
67
            $response->getHeader('Location')
68
        );
69
70
        $this->logInWithPermission('ADMIN');
71
        $response = $this->get('SecurityTest_SecuredController');
72
        $this->assertEquals(200, $response->getStatusCode());
73
        $this->assertContains('Success', $response->getBody());
74
75
        $this->autoFollowRedirection = true;
76
    }
77
78
    public function testPermissionFailureSetsCorrectFormMessages()
79
    {
80
        // Controller that doesn't attempt redirections
81
        $controller = new SecurityTest\NullController();
82
        $controller->setRequest(Controller::curr()->getRequest());
83
        $controller->setResponse(new HTTPResponse());
84
85
        $session = Controller::curr()->getRequest()->getSession();
86
        Security::permissionFailure($controller, array('default' => 'Oops, not allowed'));
87
        $this->assertEquals('Oops, not allowed', $session->get('Security.Message.message'));
88
89
        // Test that config values are used correctly
90
        Config::modify()->set(Security::class, 'default_message_set', 'stringvalue');
91
        Security::permissionFailure($controller);
92
        $this->assertEquals(
93
            'stringvalue',
94
            $session->get('Security.Message.message'),
95
            'Default permission failure message value was not present'
96
        );
97
98
        Config::modify()->remove(Security::class, 'default_message_set');
99
        Config::modify()->merge(Security::class, 'default_message_set', array('default' => 'arrayvalue'));
100
        Security::permissionFailure($controller);
101
        $this->assertEquals(
102
            'arrayvalue',
103
            $session->get('Security.Message.message'),
104
            'Default permission failure message value was not present'
105
        );
106
107
        // Test that non-default messages work.
108
        // NOTE: we inspect the response body here as the session message has already
109
        // been fetched and output as part of it, so has been removed from the session
110
        $this->logInWithPermission('EDITOR');
111
112
        Config::modify()->set(
113
            Security::class,
114
            'default_message_set',
115
            array('default' => 'default', 'alreadyLoggedIn' => 'You are already logged in!')
116
        );
117
        Security::permissionFailure($controller);
118
        $this->assertContains(
119
            'You are already logged in!',
120
            $controller->getResponse()->getBody(),
121
            'Custom permission failure message was ignored'
122
        );
123
124
        Security::permissionFailure(
125
            $controller,
126
            array('default' => 'default', 'alreadyLoggedIn' => 'One-off failure message')
127
        );
128
        $this->assertContains(
129
            'One-off failure message',
130
            $controller->getResponse()->getBody(),
131
            "Message set passed to Security::permissionFailure() didn't override Config values"
132
        );
133
134
        // Test DBField cast messages work
135
        Security::permissionFailure(
136
            $controller,
137
            DBField::create_field('HTMLFragment', '<p>Custom HTML &amp; Message</p>')
138
        );
139
        $this->assertContains(
140
            '<p>Custom HTML &amp; Message</p>',
141
            $controller->getResponse()->getBody()
142
        );
143
144
        // Plain text DBText
145
        Security::permissionFailure(
146
            $controller,
147
            DBField::create_field('Text', 'Safely escaped & message')
148
        );
149
        $this->assertContains(
150
            'Safely escaped &amp; message',
151
            $controller->getResponse()->getBody()
152
        );
153
    }
154
155
    /**
156
     * Follow all redirects recursively
157
     *
158
     * @param  string $url
159
     * @param  int    $limit Max number of requests
160
     * @return HTTPResponse
161
     */
162
    protected function getRecursive($url, $limit = 10)
163
    {
164
        $this->cssParser = null;
165
        $response = $this->mainSession->get($url);
166
        while (--$limit > 0 && $response instanceof HTTPResponse && $response->getHeader('Location')) {
167
            $response = $this->mainSession->followRedirection();
168
        }
169
        return $response;
170
    }
171
172
    public function testAutomaticRedirectionOnLogin()
173
    {
174
        // BackURL with permission error (not authenticated) should not redirect
175
        if ($member = Security::getCurrentUser()) {
0 ignored issues
show
Unused Code introduced by
The assignment to $member is dead and can be removed.
Loading history...
176
            Security::setCurrentUser(null);
177
        }
178
        $response = $this->getRecursive('SecurityTest_SecuredController');
179
        $this->assertContains(Convert::raw2xml("That page is secured."), $response->getBody());
180
        $this->assertContains('<input type="submit" name="action_doLogin"', $response->getBody());
181
182
        // Non-logged in user should not be redirected, but instead shown the login form
183
        // No message/context is available as the user has not attempted to view the secured controller
184
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
185
        $this->assertNotContains(Convert::raw2xml("That page is secured."), $response->getBody());
186
        $this->assertNotContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
187
        $this->assertContains('<input type="submit" name="action_doLogin"', $response->getBody());
188
189
        // BackURL with permission error (wrong permissions) should not redirect
190
        $this->logInAs('grouplessmember');
191
        $response = $this->getRecursive('SecurityTest_SecuredController');
192
        $this->assertContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
193
        $this->assertContains(
194
            '<input type="submit" name="action_logout" value="Log in as someone else"',
195
            $response->getBody()
196
        );
197
198
        // Directly accessing this page should attempt to follow the BackURL, but stop when it encounters the error
199
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
200
        $this->assertContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
201
        $this->assertContains(
202
            '<input type="submit" name="action_logout" value="Log in as someone else"',
203
            $response->getBody()
204
        );
205
206
        // Check correctly logged in admin doesn't generate the same errors
207
        $this->logInAs('admin');
208
        $response = $this->getRecursive('SecurityTest_SecuredController');
209
        $this->assertContains(Convert::raw2xml("Success"), $response->getBody());
210
211
        // Directly accessing this page should attempt to follow the BackURL and succeed
212
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
213
        $this->assertContains(Convert::raw2xml("Success"), $response->getBody());
214
    }
215
216
    public function testLogInAsSomeoneElse()
217
    {
218
        $member = DataObject::get_one(Member::class);
219
220
        /* Log in with any user that we can find */
221
        Security::setCurrentUser($member);
222
223
        /* View the Security/login page */
224
        $this->get(Config::inst()->get(Security::class, 'login_url'));
225
226
        $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.action');
227
228
        /* We have only 1 input, one to allow the user to log in as someone else */
229
        $this->assertEquals(count($items), 1, 'There is 1 input, allowing the user to log in as someone else.');
230
231
        /* Submit the form, using only the logout action and a hidden field for the authenticator */
232
        $response = $this->submitForm(
233
            'MemberLoginForm_LoginForm',
234
            null,
235
            array(
236
                'action_logout' => 1,
237
            )
238
        );
239
240
        /* We get a good response */
241
        $this->assertEquals($response->getStatusCode(), 302, 'We have a redirection response');
242
243
        /* Log the user out */
244
        Security::setCurrentUser(null);
245
    }
246
247
    public function testMemberIDInSessionDoesntExistInDatabaseHasToLogin()
248
    {
249
        /* Log in with a Member ID that doesn't exist in the DB */
250
        $this->session()->set('loggedInAs', 500);
251
252
        $this->autoFollowRedirection = true;
253
254
        /* Attempt to get into the admin section */
255
        $this->get(Config::inst()->get(Security::class, 'login_url'));
256
257
        $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.text');
258
259
        /* We have 2 text inputs - one for email, and another for the password */
260
        $this->assertEquals(count($items), 2, 'There are 2 inputs - one for email, another for password');
261
262
        $this->autoFollowRedirection = false;
263
264
        /* Log the user out */
265
        $this->session()->set('loggedInAs', null);
266
    }
267
268
    public function testLoginUsernamePersists()
269
    {
270
        // Test that username does not persist
271
        $this->session()->set('SessionForms.MemberLoginForm.Email', '[email protected]');
272
        Security::config()->set('remember_username', false);
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->assertEmpty((string)$items[0]->attributes()->value);
279
        $this->assertEquals('off', (string)$items[0]->attributes()->autocomplete);
280
        $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm');
281
        $this->assertEquals(1, count($form));
282
        $this->assertEquals('off', (string)$form[0]->attributes()->autocomplete);
283
284
        // Test that username does persist when necessary
285
        $this->session()->set('SessionForms.MemberLoginForm.Email', '[email protected]');
286
        Security::config()->set('remember_username', true);
287
        $this->get(Config::inst()->get(Security::class, 'login_url'));
288
        $items = $this
289
            ->cssParser()
290
            ->getBySelector('#MemberLoginForm_LoginForm #MemberLoginForm_LoginForm_Email');
291
        $this->assertEquals(1, count($items));
292
        $this->assertEquals('[email protected]', (string)$items[0]->attributes()->value);
293
        $this->assertNotEquals('off', (string)$items[0]->attributes()->autocomplete);
294
        $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm');
295
        $this->assertEquals(1, count($form));
296
        $this->assertNotEquals('off', (string)$form[0]->attributes()->autocomplete);
297
    }
298
299
    public function testLogout()
300
    {
301
        /* Enable SecurityToken */
302
        $securityTokenWasEnabled = SecurityToken::is_enabled();
303
        SecurityToken::enable();
304
305
        $member = DataObject::get_one(Member::class);
306
307
        /* Log in with any user that we can find */
308
        Security::setCurrentUser($member);
309
310
        /* Visit the Security/logout page with a test referer, but without a security token */
311
        $this->get(
312
            Config::inst()->get(Security::class, 'logout_url'),
313
            null,
314
            ['Referer' => Director::absoluteBaseURL() . 'testpage']
315
        );
316
317
        /* Make sure the user is still logged in */
318
        $this->assertNotNull(Security::getCurrentUser(), 'User is still logged in.');
319
320
        $token = $this->cssParser()->getBySelector('#LogoutForm_Form #LogoutForm_Form_SecurityID');
321
        $actions = $this->cssParser()->getBySelector('#LogoutForm_Form input.action');
322
323
        /* We have a security token, and an action to allow the user to log out */
324
        $this->assertCount(1, $token, 'There is a hidden field containing a security token.');
325
        $this->assertCount(1, $actions, 'There is 1 action, allowing the user to log out.');
326
327
        /* Submit the form, using the logout action */
328
        $response = $this->submitForm(
329
            'LogoutForm_Form',
330
            null,
331
            array(
332
                'action_doLogout' => 1,
333
            )
334
        );
335
336
        /* We get a good response */
337
        $this->assertEquals(302, $response->getStatusCode());
338
        $this->assertRegExp(
339
            '/testpage/',
340
            $response->getHeader('Location'),
341
            "Logout form redirects to back to referer."
342
        );
343
344
        /* User is logged out successfully */
345
        $this->assertNull(Security::getCurrentUser(), 'User is logged out.');
346
347
        /* Re-disable SecurityToken */
348
        if (!$securityTokenWasEnabled) {
349
            SecurityToken::disable();
350
        }
351
    }
352
353
    public function testExternalBackUrlRedirectionDisallowed()
354
    {
355
        // Test internal relative redirect
356
        $response = $this->doTestLoginForm('[email protected]', '1nitialPassword', 'testpage');
357
        $this->assertEquals(302, $response->getStatusCode());
358
        $this->assertRegExp(
359
            '/testpage/',
360
            $response->getHeader('Location'),
361
            "Internal relative BackURLs work when passed through to login form"
362
        );
363
        // Log the user out
364
        $this->session()->set('loggedInAs', null);
365
366
        // Test internal absolute redirect
367
        $response = $this->doTestLoginForm(
368
            '[email protected]',
369
            '1nitialPassword',
370
            Director::absoluteBaseURL() . 'testpage'
371
        );
372
        // for some reason the redirect happens to a relative URL
373
        $this->assertRegExp(
374
            '/^' . preg_quote(Director::absoluteBaseURL(), '/') . 'testpage/',
375
            $response->getHeader('Location'),
376
            "Internal absolute BackURLs work when passed through to login form"
377
        );
378
        // Log the user out
379
        $this->session()->set('loggedInAs', null);
380
381
        // Test external redirect
382
        $response = $this->doTestLoginForm('[email protected]', '1nitialPassword', 'http://myspoofedhost.com');
383
        $this->assertNotRegExp(
384
            '/^' . preg_quote('http://myspoofedhost.com', '/') . '/',
385
            (string)$response->getHeader('Location'),
386
            "Redirection to external links in login form BackURL gets prevented as a measure against spoofing attacks"
387
        );
388
389
        // Test external redirection on ChangePasswordForm
390
        $this->get('Security/changepassword?BackURL=http://myspoofedhost.com');
391
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
392
        $this->assertNotRegExp(
393
            '/^' . preg_quote('http://myspoofedhost.com', '/') . '/',
394
            (string)$changedResponse->getHeader('Location'),
395
            "Redirection to external links in change password form BackURL gets prevented to stop spoofing attacks"
396
        );
397
398
        // Log the user out
399
        $this->session()->set('loggedInAs', null);
400
    }
401
402
    /**
403
     * Test that the login form redirects to the change password form after logging in with an expired password
404
     */
405
    public function testExpiredPassword()
406
    {
407
        /* BAD PASSWORDS ARE LOCKED OUT */
408
        $badResponse = $this->doTestLoginForm('[email protected]', 'badpassword');
409
        $this->assertEquals(302, $badResponse->getStatusCode());
410
        $this->assertRegExp('/Security\/login/', $badResponse->getHeader('Location'));
411
        $this->assertNull($this->session()->get('loggedInAs'));
412
413
        /* UNEXPIRED PASSWORD GO THROUGH WITHOUT A HITCH */
414
        $goodResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
415
        $this->assertEquals(302, $goodResponse->getStatusCode());
416
        $this->assertEquals(
417
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
418
            $goodResponse->getHeader('Location')
419
        );
420
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->get('loggedInAs'));
421
422
        $this->logOut();
423
424
        /* EXPIRED PASSWORDS ARE SENT TO THE CHANGE PASSWORD FORM */
425
        $expiredResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
426
        $this->assertEquals(302, $expiredResponse->getStatusCode());
427
        $this->assertEquals(
428
            Director::absoluteURL('Security/changepassword') . '?BackURL=test%2Flink',
429
            Director::absoluteURL($expiredResponse->getHeader('Location'))
430
        );
431
        $this->assertEquals(
432
            $this->idFromFixture(Member::class, 'expiredpassword'),
433
            $this->session()->get('loggedInAs')
434
        );
435
436
        // Make sure it redirects correctly after the password has been changed
437
        $this->mainSession->followRedirection();
438
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
439
        $this->assertEquals(302, $changedResponse->getStatusCode());
440
        $this->assertEquals(
441
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
442
            $changedResponse->getHeader('Location')
443
        );
444
    }
445
446
    public function testChangePasswordForLoggedInUsers()
447
    {
448
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
449
450
        // Change the password
451
        $this->get('Security/changepassword?BackURL=test/back');
452
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
453
        $this->assertEquals(302, $changedResponse->getStatusCode());
454
        $this->assertEquals(
455
            Controller::join_links(Director::absoluteBaseURL(), 'test/back'),
456
            $changedResponse->getHeader('Location')
457
        );
458
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->get('loggedInAs'));
459
460
        // Check if we can login with the new password
461
        $this->logOut();
462
        $goodResponse = $this->doTestLoginForm('[email protected]', 'changedPassword');
463
        $this->assertEquals(302, $goodResponse->getStatusCode());
464
        $this->assertEquals(
465
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
466
            $goodResponse->getHeader('Location')
467
        );
468
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->get('loggedInAs'));
469
    }
470
471
    public function testChangePasswordFromLostPassword()
472
    {
473
        /** @var Member $admin */
474
        $admin = $this->objFromFixture(Member::class, 'test');
475
        $admin->FailedLoginCount = 99;
476
        $admin->LockedOutUntil = DBDatetime::now()->getValue();
477
        $admin->write();
478
479
        $this->assertNull($admin->AutoLoginHash, 'Hash is empty before lost password');
480
481
        // Request new password by email
482
        $this->get('Security/lostpassword');
483
        $this->post('Security/lostpassword/LostPasswordForm', array('Email' => '[email protected]'));
484
485
        $this->assertEmailSent('[email protected]');
486
487
        // Load password link from email
488
        $admin = DataObject::get_by_id(Member::class, $admin->ID);
489
        $this->assertNotNull($admin->AutoLoginHash, 'Hash has been written after lost password');
0 ignored issues
show
Bug Best Practice introduced by
The property AutoLoginHash does not exist on SilverStripe\ORM\DataObject. Since you implemented __get, consider adding a @property annotation.
Loading history...
490
491
        // We don't have access to the token - generate a new token and hash pair.
492
        $token = $admin->generateAutologinTokenAndStoreHash();
0 ignored issues
show
Bug introduced by
The method generateAutologinTokenAndStoreHash() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

492
        /** @scrutinizer ignore-call */ 
493
        $token = $admin->generateAutologinTokenAndStoreHash();
Loading history...
493
494
        // Check.
495
        $response = $this->get('Security/changepassword/?m=' . $admin->ID . '&t=' . $token);
496
        $this->assertEquals(302, $response->getStatusCode());
497
        $this->assertEquals(
498
            Director::absoluteURL('Security/changepassword'),
499
            Director::absoluteURL($response->getHeader('Location'))
500
        );
501
502
        // Follow redirection to form without hash in GET parameter
503
        $this->get('Security/changepassword');
504
        $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
505
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->get('loggedInAs'));
506
507
        // Check if we can login with the new password
508
        $this->logOut();
509
        $goodResponse = $this->doTestLoginForm('[email protected]', 'changedPassword');
510
        $this->assertEquals(302, $goodResponse->getStatusCode());
511
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->get('loggedInAs'));
512
513
        $admin = DataObject::get_by_id(Member::class, $admin->ID, false);
514
        $this->assertNull($admin->LockedOutUntil);
0 ignored issues
show
Bug Best Practice introduced by
The property LockedOutUntil does not exist on SilverStripe\ORM\DataObject. Since you implemented __get, consider adding a @property annotation.
Loading history...
515
        $this->assertEquals(0, $admin->FailedLoginCount);
0 ignored issues
show
Bug Best Practice introduced by
The property FailedLoginCount does not exist on SilverStripe\ORM\DataObject. Since you implemented __get, consider adding a @property annotation.
Loading history...
516
    }
517
518
    public function testRepeatedLoginAttemptsLockingPeopleOut()
519
    {
520
        i18n::set_locale('en_US');
521
        Member::config()->set('lock_out_after_incorrect_logins', 5);
522
        Member::config()->set('lock_out_delay_mins', 15);
523
        DBDatetime::set_mock_now('2017-05-22 00:00:00');
524
525
        // Login with a wrong password for more than the defined threshold
526
        /** @var Member $member */
527
        $member = null;
528
        for ($i = 1; $i <= 6; $i++) {
529
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
530
            /** @var Member $member */
531
            $member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
532
533
            if ($i < 5) {
534
                $this->assertNull(
535
                    $member->LockedOutUntil,
536
                    'User does not have a lockout time set if under threshold for failed attempts'
537
                );
538
                $this->assertHasMessage(
539
                    _t(
540
                        'SilverStripe\\Security\\Member.ERRORWRONGCRED',
541
                        'The provided details don\'t seem to be correct. Please try again.'
542
                    )
543
                );
544
            } else {
545
                // Lockout should be exactly 15 minutes from now
546
                /** @var DBDatetime $lockedOutUntilObj */
547
                $lockedOutUntilObj = $member->dbObject('LockedOutUntil');
548
                $this->assertEquals(
549
                    DBDatetime::now()->getTimestamp() + (15 * 60),
550
                    $lockedOutUntilObj->getTimestamp(),
551
                    'User has a lockout time set after too many failed attempts'
552
                );
553
            }
554
        }
555
        $msg = _t(
556
            'SilverStripe\\Security\\Member.ERRORLOCKEDOUT2',
557
            'Your account has been temporarily disabled because of too many failed attempts at ' . 'logging in. Please try again in {count} minutes.',
558
            null,
559
            array('count' => 15)
560
        );
561
        $this->assertHasMessage($msg);
562
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
563
        $this->assertNull(
564
            $this->session()->get('loggedInAs'),
565
            'The user can\'t log in after being locked out, even with the right password'
566
        );
567
568
        // Move into the future so we can login again
569
        DBDatetime::set_mock_now('2017-06-22 00:00:00');
570
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
571
        $this->assertEquals(
572
            $member->ID,
573
            $this->session()->get('loggedInAs'),
574
            'After lockout expires, the user can login again'
575
        );
576
577
        // Log the user out
578
        $this->logOut();
579
580
        // Login again with wrong password, but less attempts than threshold
581
        for ($i = 1; $i < 5; $i++) {
582
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
583
        }
584
        $this->assertNull($this->session()->get('loggedInAs'));
585
        $this->assertHasMessage(
586
            _t('SilverStripe\\Security\\Member.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.'),
587
            'The user can retry with a wrong password after the lockout expires'
588
        );
589
590
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
591
        $this->assertEquals(
592
            $this->session()->get('loggedInAs'),
593
            $member->ID,
594
            'The user can login successfully after lockout expires, if staying below the threshold'
595
        );
596
    }
597
598
    public function testAlternatingRepeatedLoginAttempts()
599
    {
600
        Member::config()->set('lock_out_after_incorrect_logins', 3);
601
602
        // ATTEMPTING LOG-IN TWICE WITH ONE ACCOUNT AND TWICE WITH ANOTHER SHOULDN'T LOCK ANYBODY OUT
603
604
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
605
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
606
607
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
608
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
609
610
        /** @var Member $member1 */
611
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
612
        /** @var Member $member2 */
613
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
614
615
        $this->assertNull($member1->LockedOutUntil);
616
        $this->assertNull($member2->LockedOutUntil);
617
618
        // BUT, DOING AN ADDITIONAL LOG-IN WITH EITHER OF THEM WILL LOCK OUT, SINCE THAT IS THE 3RD FAILURE IN
619
        // THIS SESSION
620
621
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
622
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
623
        $this->assertNotNull($member1->LockedOutUntil);
0 ignored issues
show
Bug Best Practice introduced by
The property LockedOutUntil does not exist on SilverStripe\ORM\DataObject. Since you implemented __get, consider adding a @property annotation.
Loading history...
624
625
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
626
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
627
        $this->assertNotNull($member2->LockedOutUntil);
628
    }
629
630
    public function testUnsuccessfulLoginAttempts()
631
    {
632
        Security::config()->set('login_recording', true);
633
634
        /* UNSUCCESSFUL ATTEMPTS WITH WRONG PASSWORD FOR EXISTING USER ARE LOGGED */
635
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
636
        /** @var LoginAttempt $attempt */
637
        $attempt = LoginAttempt::getByEmail('[email protected]')->first();
638
        $this->assertInstanceOf(LoginAttempt::class, $attempt);
639
        $member = Member::get()->filter('Email', '[email protected]')->first();
640
        $this->assertEquals($attempt->Status, 'Failure');
641
        $this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
642
        $this->assertEquals($attempt->EmailHashed, sha1('[email protected]'));
643
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
644
645
        /* UNSUCCESSFUL ATTEMPTS WITH NONEXISTING USER ARE LOGGED */
646
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
647
        $attempt = LoginAttempt::getByEmail('[email protected]')->first();
648
        $this->assertInstanceOf(LoginAttempt::class, $attempt);
649
        $this->assertEquals($attempt->Status, 'Failure');
0 ignored issues
show
Bug Best Practice introduced by
The property Status does not exist on SilverStripe\ORM\DataObject. Since you implemented __get, consider adding a @property annotation.
Loading history...
650
        $this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
0 ignored issues
show
Bug Best Practice introduced by
The property Email does not exist on SilverStripe\ORM\DataObject. Since you implemented __get, consider adding a @property annotation.
Loading history...
651
        $this->assertEquals($attempt->EmailHashed, sha1('[email protected]'));
0 ignored issues
show
Bug Best Practice introduced by
The property EmailHashed does not exist on SilverStripe\ORM\DataObject. Since you implemented __get, consider adding a @property annotation.
Loading history...
652
        $this->assertNotEmpty($this->getValidationResult()->getMessages(), 'An invalid email returns a message.');
653
    }
654
655
    public function testSuccessfulLoginAttempts()
656
    {
657
        Security::config()->set('login_recording', true);
658
659
        /* SUCCESSFUL ATTEMPTS ARE LOGGED */
660
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
661
        /** @var LoginAttempt $attempt */
662
        $attempt = LoginAttempt::getByEmail('[email protected]')->first();
663
        $member = Member::get()->filter('Email', '[email protected]')->first();
664
        $this->assertInstanceOf(LoginAttempt::class, $attempt);
665
        $this->assertEquals($attempt->Status, 'Success');
666
        $this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
667
        $this->assertEquals($attempt->EmailHashed, sha1('[email protected]'));
668
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
669
    }
670
671
    public function testDatabaseIsReadyWithInsufficientMemberColumns()
672
    {
673
        Security::clear_database_is_ready();
674
        DBClassName::clear_classname_cache();
675
676
        // Assumption: The database has been built correctly by the test runner,
677
        // and has all columns present in the ORM
678
        /**
679
         * @skipUpgrade
680
         */
681
        DB::get_schema()->renameField('Member', 'Email', 'Email_renamed');
682
683
        // Email column is now missing, which means we're not ready to do permission checks
684
        $this->assertFalse(Security::database_is_ready());
685
686
        // Rebuild the database (which re-adds the Email column), and try again
687
        static::resetDBSchema(true);
688
        $this->assertTrue(Security::database_is_ready());
689
    }
690
691
    public function testSecurityControllerSendsRobotsTagHeader()
692
    {
693
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
694
        $robotsHeader = $response->getHeader('X-Robots-Tag');
695
        $this->assertNotNull($robotsHeader);
696
        $this->assertContains('noindex', $robotsHeader);
697
    }
698
699
    public function testDoNotSendEmptyRobotsHeaderIfNotDefined()
700
    {
701
        Config::modify()->remove(Security::class, 'robots_tag');
702
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
703
        $robotsHeader = $response->getHeader('X-Robots-Tag');
704
        $this->assertNull($robotsHeader);
705
    }
706
707
    public function testGetResponseController()
708
    {
709
        if (!class_exists(Page::class)) {
710
            $this->markTestSkipped("This test requires CMS module");
711
        }
712
713
        $request = new HTTPRequest('GET', '/');
714
        $request->setSession(new Session([]));
715
        $security = new Security();
716
        $security->setRequest($request);
717
        $reflection = new \ReflectionClass($security);
718
        $method = $reflection->getMethod('getResponseController');
719
        $method->setAccessible(true);
720
        $result = $method->invoke($security, 'Page');
721
722
        // Ensure page shares the same controller as security
723
        $securityClass = Config::inst()->get(Security::class, 'page_class');
724
        /** @var Page $securityPage */
725
        $securityPage = new $securityClass();
726
        $this->assertInstanceOf($securityPage->getControllerName(), $result);
727
        $this->assertEquals($request, $result->getRequest());
728
    }
729
730
    /**
731
     * Execute a log-in form using Director::test().
732
     * Helper method for the tests above
733
     *
734
     * @param string $email
735
     * @param string $password
736
     * @param string $backURL
737
     * @return HTTPResponse
738
     */
739
    public function doTestLoginForm($email, $password, $backURL = 'test/link')
740
    {
741
        $this->get(Config::inst()->get(Security::class, 'logout_url'));
742
        $this->session()->set('BackURL', $backURL);
743
        $this->get(Config::inst()->get(Security::class, 'login_url'));
744
745
        return $this->submitForm(
746
            "MemberLoginForm_LoginForm",
747
            null,
748
            array(
749
                'Email' => $email,
750
                'Password' => $password,
751
                'AuthenticationMethod' => MemberAuthenticator::class,
752
                'action_doLogin' => 1,
753
            )
754
        );
755
    }
756
757
    /**
758
     * Helper method to execute a change password form
759
     *
760
     * @param string $oldPassword
761
     * @param string $newPassword
762
     * @return HTTPResponse
763
     */
764
    public function doTestChangepasswordForm($oldPassword, $newPassword)
765
    {
766
        return $this->submitForm(
767
            "ChangePasswordForm_ChangePasswordForm",
768
            null,
769
            array(
770
                'OldPassword' => $oldPassword,
771
                'NewPassword1' => $newPassword,
772
                'NewPassword2' => $newPassword,
773
                'action_doChangePassword' => 1,
774
            )
775
        );
776
    }
777
778
    /**
779
     * Assert this message is in the current login form errors
780
     *
781
     * @param string $expected
782
     * @param string $errorMessage
783
     */
784
    protected function assertHasMessage($expected, $errorMessage = null)
785
    {
786
        $messages = [];
787
        $result = $this->getValidationResult();
788
        if ($result) {
0 ignored issues
show
introduced by
$result is of type SilverStripe\ORM\ValidationResult, thus it always evaluated to true.
Loading history...
789
            foreach ($result->getMessages() as $message) {
790
                $messages[] = $message['message'];
791
            }
792
        }
793
794
        $this->assertContains($expected, $messages, $errorMessage);
795
    }
796
797
    /**
798
     * Get validation result from last login form submission
799
     *
800
     * @return ValidationResult
801
     */
802
    protected function getValidationResult()
803
    {
804
        $result = $this->session()->get('FormInfo.MemberLoginForm_LoginForm.result');
805
        if ($result) {
806
            return unserialize($result);
807
        }
808
        return null;
809
    }
810
}
811