Completed
Push — master ( 3873e4...e592be )
by Ingo
11:14
created

SecurityTest   D

Complexity

Total Complexity 35

Size/Duplication

Total Lines 762
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 24

Importance

Changes 4
Bugs 0 Features 0
Metric Value
wmc 35
c 4
b 0
f 0
lcom 1
cbo 24
dl 0
loc 762
rs 4.7142

24 Methods

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

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
546
        $member->write();
547
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
548
        $this->assertEquals(
549
            $this->session()->get('loggedInAs'),
550
            $member->ID,
551
            'After lockout expires, the user can login again'
552
        );
553
554
        // Log the user out
555
        $this->logOut();
556
557
        // Login again with wrong password, but less attempts than threshold
558
        for ($i = 1; $i < 5; $i++) {
559
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
560
        }
561
        $this->assertNull($this->session()->get('loggedInAs'));
562
        $this->assertHasMessage(
563
            _t('SilverStripe\\Security\\Member.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.'),
564
            'The user can retry with a wrong password after the lockout expires'
565
        );
566
567
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
568
        $this->assertEquals(
569
            $this->session()->get('loggedInAs'),
570
            $member->ID,
571
            'The user can login successfully after lockout expires, if staying below the threshold'
572
        );
573
    }
574
575
    public function testAlternatingRepeatedLoginAttempts()
576
    {
577
        Member::config()->set('lock_out_after_incorrect_logins', 3);
578
579
        // ATTEMPTING LOG-IN TWICE WITH ONE ACCOUNT AND TWICE WITH ANOTHER SHOULDN'T LOCK ANYBODY OUT
580
581
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
582
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
583
584
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
585
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
586
587
        /** @var Member $member1 */
588
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
589
        /** @var Member $member2 */
590
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
591
592
        $this->assertNull($member1->LockedOutUntil);
593
        $this->assertNull($member2->LockedOutUntil);
594
595
        // BUT, DOING AN ADDITIONAL LOG-IN WITH EITHER OF THEM WILL LOCK OUT, SINCE THAT IS THE 3RD FAILURE IN
596
        // THIS SESSION
597
598
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
599
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
600
        $this->assertNotNull($member1->LockedOutUntil);
601
602
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
603
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
604
        $this->assertNotNull($member2->LockedOutUntil);
605
    }
606
607
    public function testUnsuccessfulLoginAttempts()
608
    {
609
        Security::config()->set('login_recording', true);
610
611
        /* UNSUCCESSFUL ATTEMPTS WITH WRONG PASSWORD FOR EXISTING USER ARE LOGGED */
612
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
613
        /** @var LoginAttempt $attempt */
614
        $attempt = DataObject::get_one(
615
            LoginAttempt::class,
616
            array(
617
            '"LoginAttempt"."Email"' => '[email protected]'
618
            )
619
        );
620
        $this->assertInstanceOf(LoginAttempt::class, $attempt);
621
        $member = DataObject::get_one(
622
            Member::class,
623
            array(
624
            '"Member"."Email"' => '[email protected]'
625
            )
626
        );
627
        $this->assertEquals($attempt->Status, 'Failure');
628
        $this->assertEquals($attempt->Email, '[email protected]');
629
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
630
631
        /* UNSUCCESSFUL ATTEMPTS WITH NONEXISTING USER ARE LOGGED */
632
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
633
        $attempt = DataObject::get_one(
634
            LoginAttempt::class,
635
            array(
636
            '"LoginAttempt"."Email"' => '[email protected]'
637
            )
638
        );
639
        $this->assertTrue(is_object($attempt));
640
        $this->assertEquals($attempt->Status, 'Failure');
641
        $this->assertEquals($attempt->Email, '[email protected]');
642
        $this->assertNotEmpty($this->getValidationResult()->getMessages(), 'An invalid email returns a message.');
643
    }
644
645
    public function testSuccessfulLoginAttempts()
646
    {
647
        Security::config()->set('login_recording', true);
648
649
        /* SUCCESSFUL ATTEMPTS ARE LOGGED */
650
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
651
        /** @var LoginAttempt $attempt */
652
        $attempt = DataObject::get_one(
653
            LoginAttempt::class,
654
            array(
655
            '"LoginAttempt"."Email"' => '[email protected]'
656
            )
657
        );
658
        /** @var Member $member */
659
        $member = DataObject::get_one(
660
            Member::class,
661
            array(
662
            '"Member"."Email"' => '[email protected]'
663
            )
664
        );
665
        $this->assertTrue(is_object($attempt));
666
        $this->assertEquals($attempt->Status, 'Success');
667
        $this->assertEquals($attempt->Email, '[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
    /**
708
     * Execute a log-in form using Director::test().
709
     * Helper method for the tests above
710
     *
711
     * @param string $email
712
     * @param string $password
713
     * @param string $backURL
714
     * @return HTTPResponse
715
     */
716
    public function doTestLoginForm($email, $password, $backURL = 'test/link')
717
    {
718
        $this->get(Config::inst()->get(Security::class, 'logout_url'));
719
        $this->session()->set('BackURL', $backURL);
720
        $this->get(Config::inst()->get(Security::class, 'login_url'));
721
722
        return $this->submitForm(
723
            "MemberLoginForm_LoginForm",
724
            null,
725
            array(
726
                'Email' => $email,
727
                'Password' => $password,
728
                'AuthenticationMethod' => MemberAuthenticator::class,
729
                'action_doLogin' => 1,
730
            )
731
        );
732
    }
733
734
    /**
735
     * Helper method to execute a change password form
736
     *
737
     * @param string $oldPassword
738
     * @param string $newPassword
739
     * @return HTTPResponse
740
     */
741
    public function doTestChangepasswordForm($oldPassword, $newPassword)
742
    {
743
        return $this->submitForm(
744
            "ChangePasswordForm_ChangePasswordForm",
745
            null,
746
            array(
747
                'OldPassword' => $oldPassword,
748
                'NewPassword1' => $newPassword,
749
                'NewPassword2' => $newPassword,
750
                'action_doChangePassword' => 1,
751
            )
752
        );
753
    }
754
755
    /**
756
     * Assert this message is in the current login form errors
757
     *
758
     * @param string $expected
759
     * @param string $errorMessage
760
     */
761
    protected function assertHasMessage($expected, $errorMessage = null)
762
    {
763
        $messages = [];
764
        $result = $this->getValidationResult();
765
        if ($result) {
766
            foreach ($result->getMessages() as $message) {
767
                $messages[] = $message['message'];
768
            }
769
        }
770
771
        $this->assertContains($expected, $messages, $errorMessage);
772
    }
773
774
    /**
775
     * Get validation result from last login form submission
776
     *
777
     * @return ValidationResult
778
     */
779
    protected function getValidationResult()
780
    {
781
        $result = $this->session()->get('FormInfo.MemberLoginForm_LoginForm.result');
782
        if ($result) {
783
            return unserialize($result);
784
        }
785
        return null;
786
    }
787
}
788