Completed
Push — master ( ff3ad6...5a7c6d )
by Sam
10:14
created

SecurityTest   C

Complexity

Total Complexity 35

Size/Duplication

Total Lines 731
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 21

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 35
lcom 2
cbo 21
dl 0
loc 731
rs 5.5
c 1
b 0
f 0

24 Methods

Rating   Name   Duplication   Size   Complexity  
A setUp() 0 22 1
A tearDown() 0 14 1
A testAccessingAuthenticatedPageRedirectsToLoginForm() 0 18 1
A testPermissionFailureSetsCorrectFormMessages() 0 58 1
A getRecursive() 0 9 4
B testAutomaticRedirectionOnLogin() 0 43 2
B testLogInAsSomeoneElse() 0 34 1
A testMemberIDInSessionDoesntExistInDatabaseHasToLogin() 0 20 1
B testLoginUsernamePersists() 0 30 1
A testExternalBackUrlRedirectionDisallowed() 0 48 1
B testExpiredPassword() 0 38 1
A testChangePasswordForLoggedInUsers() 0 23 1
B testChangePasswordFromLostPassword() 0 44 1
B testRepeatedLoginAttemptsLockingPeopleOut() 0 84 5
B testAlternatingRepeatedLoginAttempts() 0 29 1
B testUnsuccessfulLoginAttempts() 0 36 1
A testSuccessfulLoginAttempts() 0 23 1
A testDatabaseIsReadyWithInsufficientMemberColumns() 0 23 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 PhpConsole\Auth;
6
use SilverStripe\ORM\DataObject;
7
use SilverStripe\ORM\FieldType\DBDatetime;
8
use SilverStripe\ORM\FieldType\DBClassName;
9
use SilverStripe\ORM\DB;
10
use SilverStripe\ORM\ValidationResult;
11
use SilverStripe\Security\Authenticator;
12
use SilverStripe\Security\LoginAttempt;
13
use SilverStripe\Security\Member;
14
use SilverStripe\Security\MemberAuthenticator;
15
use SilverStripe\Security\Security;
16
use SilverStripe\Security\Permission;
17
use SilverStripe\Core\Config\Config;
18
use SilverStripe\Core\Convert;
19
use SilverStripe\Dev\FunctionalTest;
20
use SilverStripe\Dev\TestOnly;
21
use SilverStripe\Control\HTTPResponse;
22
use SilverStripe\Control\Session;
23
use SilverStripe\Control\Director;
24
use SilverStripe\Control\Controller;
25
use SilverStripe\i18n\i18n;
26
27
/**
28
 * Test the security class, including log-in form, change password form, etc
29
 */
30
class SecurityTest extends FunctionalTest
31
{
32
    protected static $fixture_file = 'MemberTest.yml';
33
34
    protected $autoFollowRedirection = false;
35
36
    protected $priorAuthenticators = array();
37
38
    protected $priorDefaultAuthenticator = null;
39
40
    protected $priorUniqueIdentifierField = null;
41
42
    protected $priorRememberUsername = null;
43
44
    protected static $extra_controllers = [
45
        SecurityTest\NullController::class,
46
        SecurityTest\SecuredController::class,
47
    ];
48
49
    protected function setUp()
50
    {
51
        // This test assumes that MemberAuthenticator is present and the default
52
        $this->priorAuthenticators = Authenticator::get_authenticators();
53
        $this->priorDefaultAuthenticator = Authenticator::get_default_authenticator();
54
55
        // Set to an empty array of authenticators to enable the default
56
        Config::modify()->set(Authenticator::class, 'authenticators', []);
57
        Config::modify()->set(Authenticator::class, 'default_authenticator', MemberAuthenticator::class);
58
59
        // And that the unique identified field is 'Email'
60
        $this->priorUniqueIdentifierField = Member::config()->unique_identifier_field;
0 ignored issues
show
Documentation introduced by
The property unique_identifier_field does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
61
        $this->priorRememberUsername = Security::config()->remember_username;
0 ignored issues
show
Documentation introduced by
The property remember_username does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
62
        /**
63
         * @skipUpgrade
64
         */
65
        Member::config()->unique_identifier_field = 'Email';
0 ignored issues
show
Documentation introduced by
The property unique_identifier_field does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
66
67
        parent::setUp();
68
69
        Config::modify()->merge('SilverStripe\\Control\\Director', 'alternate_base_url', '/');
70
    }
71
72
    protected function tearDown()
73
    {
74
        // Restore selected authenticator
75
76
        // MemberAuthenticator might not actually be present
77
        Config::modify()->set(Authenticator::class, 'authenticators', $this->priorAuthenticators);
78
        Config::modify()->set(Authenticator::class, 'default_authenticator', $this->priorDefaultAuthenticator);
79
80
        // Restore unique identifier field
81
        Member::config()->unique_identifier_field = $this->priorUniqueIdentifierField;
0 ignored issues
show
Documentation introduced by
The property unique_identifier_field does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
82
        Security::config()->remember_username = $this->priorRememberUsername;
0 ignored issues
show
Documentation introduced by
The property remember_username does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
83
84
        parent::tearDown();
85
    }
86
87
    public function testAccessingAuthenticatedPageRedirectsToLoginForm()
88
    {
89
        $this->autoFollowRedirection = false;
90
91
        $response = $this->get('SecurityTest_SecuredController');
92
        $this->assertEquals(302, $response->getStatusCode());
93
        $this->assertContains(
94
            Config::inst()->get(Security::class, 'login_url'),
95
            $response->getHeader('Location')
96
        );
97
98
        $this->logInWithPermission('ADMIN');
99
        $response = $this->get('SecurityTest_SecuredController');
100
        $this->assertEquals(200, $response->getStatusCode());
101
        $this->assertContains('Success', $response->getBody());
102
103
        $this->autoFollowRedirection = true;
104
    }
105
106
    public function testPermissionFailureSetsCorrectFormMessages()
107
    {
108
        Config::nest();
109
110
        // Controller that doesn't attempt redirections
111
        $controller = new SecurityTest\NullController();
112
        $controller->setResponse(new HTTPResponse());
113
114
        Security::permissionFailure($controller, array('default' => 'Oops, not allowed'));
115
        $this->assertEquals('Oops, not allowed', Session::get('Security.Message.message'));
116
117
        // Test that config values are used correctly
118
        Config::inst()->update(Security::class, 'default_message_set', 'stringvalue');
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface SilverStripe\Config\Coll...nfigCollectionInterface as the method update() does only exist in the following implementations of said interface: SilverStripe\Config\Coll...\MemoryConfigCollection.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
119
        Security::permissionFailure($controller);
120
        $this->assertEquals(
121
            'stringvalue',
122
            Session::get('Security.Message.message'),
123
            'Default permission failure message value was not present'
124
        );
125
126
        Config::modify()->remove(Security::class, 'default_message_set');
127
        Config::modify()->merge(Security::class, 'default_message_set', array('default' => 'arrayvalue'));
128
        Security::permissionFailure($controller);
129
        $this->assertEquals(
130
            'arrayvalue',
131
            Session::get('Security.Message.message'),
132
            'Default permission failure message value was not present'
133
        );
134
135
        // Test that non-default messages work.
136
        // NOTE: we inspect the response body here as the session message has already
137
        // been fetched and output as part of it, so has been removed from the session
138
        $this->logInWithPermission('EDITOR');
139
140
        Config::inst()->update(
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface SilverStripe\Config\Coll...nfigCollectionInterface as the method update() does only exist in the following implementations of said interface: SilverStripe\Config\Coll...\MemoryConfigCollection.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
141
            Security::class,
142
            'default_message_set',
143
            array('default' => 'default', 'alreadyLoggedIn' => 'You are already logged in!')
144
        );
145
        Security::permissionFailure($controller);
146
        $this->assertContains(
147
            'You are already logged in!',
148
            $controller->getResponse()->getBody(),
149
            'Custom permission failure message was ignored'
150
        );
151
152
        Security::permissionFailure(
153
            $controller,
154
            array('default' => 'default', 'alreadyLoggedIn' => 'One-off failure message')
155
        );
156
        $this->assertContains(
157
            'One-off failure message',
158
            $controller->getResponse()->getBody(),
159
            "Message set passed to Security::permissionFailure() didn't override Config values"
160
        );
161
162
        Config::unnest();
163
    }
164
165
    /**
166
     * Follow all redirects recursively
167
     *
168
     * @param  string $url
169
     * @param  int    $limit Max number of requests
170
     * @return HTTPResponse
171
     */
172
    protected function getRecursive($url, $limit = 10)
173
    {
174
        $this->cssParser = null;
175
        $response = $this->mainSession->get($url);
176
        while (--$limit > 0 && $response instanceof HTTPResponse && $response->getHeader('Location')) {
177
            $response = $this->mainSession->followRedirection();
178
        }
179
        return $response;
180
    }
181
182
    public function testAutomaticRedirectionOnLogin()
183
    {
184
        // BackURL with permission error (not authenticated) should not redirect
185
        if ($member = Member::currentUser()) {
186
            $member->logOut();
187
        }
188
        $response = $this->getRecursive('SecurityTest_SecuredController');
189
        $this->assertContains(Convert::raw2xml("That page is secured."), $response->getBody());
190
        $this->assertContains('<input type="submit" name="action_dologin"', $response->getBody());
191
192
        // Non-logged in user should not be redirected, but instead shown the login form
193
        // No message/context is available as the user has not attempted to view the secured controller
194
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
195
        $this->assertNotContains(Convert::raw2xml("That page is secured."), $response->getBody());
196
        $this->assertNotContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
197
        $this->assertContains('<input type="submit" name="action_dologin"', $response->getBody());
198
199
        // BackURL with permission error (wrong permissions) should not redirect
200
        $this->logInAs('grouplessmember');
201
        $response = $this->getRecursive('SecurityTest_SecuredController');
202
        $this->assertContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
203
        $this->assertContains(
204
            '<input type="submit" name="action_logout" value="Log in as someone else"',
205
            $response->getBody()
206
        );
207
208
        // Directly accessing this page should attempt to follow the BackURL, but stop when it encounters the error
209
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
210
        $this->assertContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
211
        $this->assertContains(
212
            '<input type="submit" name="action_logout" value="Log in as someone else"',
213
            $response->getBody()
214
        );
215
216
        // Check correctly logged in admin doesn't generate the same errors
217
        $this->logInAs('admin');
218
        $response = $this->getRecursive('SecurityTest_SecuredController');
219
        $this->assertContains(Convert::raw2xml("Success"), $response->getBody());
220
221
        // Directly accessing this page should attempt to follow the BackURL and succeed
222
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
223
        $this->assertContains(Convert::raw2xml("Success"), $response->getBody());
224
    }
225
226
    public function testLogInAsSomeoneElse()
227
    {
228
        $member = DataObject::get_one(Member::class);
229
230
        /* Log in with any user that we can find */
231
        $this->session()->inst_set('loggedInAs', $member->ID);
232
233
        /* View the Security/login page */
234
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
235
236
        $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.action');
237
238
        /* We have only 1 input, one to allow the user to log in as someone else */
239
        $this->assertEquals(count($items), 1, 'There is 1 input, allowing the user to log in as someone else.');
240
241
        $this->autoFollowRedirection = true;
242
243
        /* Submit the form, using only the logout action and a hidden field for the authenticator */
244
        $response = $this->submitForm(
245
            'MemberLoginForm_LoginForm',
246
            null,
247
            array(
248
                'AuthenticationMethod' => MemberAuthenticator::class,
249
                'action_dologout' => 1,
250
            )
251
        );
252
253
        /* We get a good response */
254
        $this->assertEquals($response->getStatusCode(), 200, 'We have a 200 OK response');
255
        $this->assertNotNull($response->getBody(), 'There is body content on the page');
256
257
        /* Log the user out */
258
        $this->session()->inst_set('loggedInAs', null);
259
    }
260
261
    public function testMemberIDInSessionDoesntExistInDatabaseHasToLogin()
262
    {
263
        /* Log in with a Member ID that doesn't exist in the DB */
264
        $this->session()->inst_set('loggedInAs', 500);
265
266
        $this->autoFollowRedirection = true;
267
268
        /* Attempt to get into the admin section */
269
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
270
271
        $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.text');
272
273
        /* We have 2 text inputs - one for email, and another for the password */
274
        $this->assertEquals(count($items), 2, 'There are 2 inputs - one for email, another for password');
275
276
        $this->autoFollowRedirection = false;
277
278
        /* Log the user out */
279
        $this->session()->inst_set('loggedInAs', null);
280
    }
281
282
    public function testLoginUsernamePersists()
283
    {
284
        // Test that username does not persist
285
        $this->session()->inst_set('SessionForms.MemberLoginForm.Email', '[email protected]');
286
        Security::config()->remember_username = false;
0 ignored issues
show
Documentation introduced by
The property remember_username does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
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->assertEmpty((string)$items[0]->attributes()->value);
293
        $this->assertEquals('off', (string)$items[0]->attributes()->autocomplete);
294
        $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm');
295
        $this->assertEquals(1, count($form));
296
        $this->assertEquals('off', (string)$form[0]->attributes()->autocomplete);
297
298
        // Test that username does persist when necessary
299
        $this->session()->inst_set('SessionForms.MemberLoginForm.Email', '[email protected]');
300
        Security::config()->remember_username = true;
0 ignored issues
show
Documentation introduced by
The property remember_username does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
301
        $this->get(Config::inst()->get(Security::class, 'login_url'));
302
        $items = $this
303
            ->cssParser()
304
            ->getBySelector('#MemberLoginForm_LoginForm #MemberLoginForm_LoginForm_Email');
305
        $this->assertEquals(1, count($items));
306
        $this->assertEquals('[email protected]', (string)$items[0]->attributes()->value);
307
        $this->assertNotEquals('off', (string)$items[0]->attributes()->autocomplete);
308
        $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm');
309
        $this->assertEquals(1, count($form));
310
        $this->assertNotEquals('off', (string)$form[0]->attributes()->autocomplete);
311
    }
312
313
    public function testExternalBackUrlRedirectionDisallowed()
314
    {
315
        // Test internal relative redirect
316
        $response = $this->doTestLoginForm('[email protected]', '1nitialPassword', 'testpage');
317
        $this->assertEquals(302, $response->getStatusCode());
318
        $this->assertRegExp(
319
            '/testpage/',
320
            $response->getHeader('Location'),
321
            "Internal relative BackURLs work when passed through to login form"
322
        );
323
        // Log the user out
324
        $this->session()->inst_set('loggedInAs', null);
325
326
        // Test internal absolute redirect
327
        $response = $this->doTestLoginForm(
328
            '[email protected]',
329
            '1nitialPassword',
330
            Director::absoluteBaseURL() . 'testpage'
331
        );
332
        // for some reason the redirect happens to a relative URL
333
        $this->assertRegExp(
334
            '/^' . preg_quote(Director::absoluteBaseURL(), '/') . 'testpage/',
335
            $response->getHeader('Location'),
336
            "Internal absolute BackURLs work when passed through to login form"
337
        );
338
        // Log the user out
339
        $this->session()->inst_set('loggedInAs', null);
340
341
        // Test external redirect
342
        $response = $this->doTestLoginForm('[email protected]', '1nitialPassword', 'http://myspoofedhost.com');
343
        $this->assertNotRegExp(
344
            '/^' . preg_quote('http://myspoofedhost.com', '/') . '/',
345
            (string)$response->getHeader('Location'),
346
            "Redirection to external links in login form BackURL gets prevented as a measure against spoofing attacks"
347
        );
348
349
        // Test external redirection on ChangePasswordForm
350
        $this->get('Security/changepassword?BackURL=http://myspoofedhost.com');
351
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
352
        $this->assertNotRegExp(
353
            '/^' . preg_quote('http://myspoofedhost.com', '/') . '/',
354
            (string)$changedResponse->getHeader('Location'),
355
            "Redirection to external links in change password form BackURL gets prevented to stop spoofing attacks"
356
        );
357
358
        // Log the user out
359
        $this->session()->inst_set('loggedInAs', null);
360
    }
361
362
    /**
363
     * Test that the login form redirects to the change password form after logging in with an expired password
364
     */
365
    public function testExpiredPassword()
366
    {
367
        /* BAD PASSWORDS ARE LOCKED OUT */
368
        $badResponse = $this->doTestLoginForm('[email protected]', 'badpassword');
369
        $this->assertEquals(302, $badResponse->getStatusCode());
370
        $this->assertRegExp('/Security\/login/', $badResponse->getHeader('Location'));
371
        $this->assertNull($this->session()->inst_get('loggedInAs'));
372
373
        /* UNEXPIRED PASSWORD GO THROUGH WITHOUT A HITCH */
374
        $goodResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
375
        $this->assertEquals(302, $goodResponse->getStatusCode());
376
        $this->assertEquals(
377
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
378
            $goodResponse->getHeader('Location')
379
        );
380
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
381
382
        /* EXPIRED PASSWORDS ARE SENT TO THE CHANGE PASSWORD FORM */
383
        $expiredResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
384
        $this->assertEquals(302, $expiredResponse->getStatusCode());
385
        $this->assertEquals(
386
            Director::absoluteURL('Security/changepassword').'?BackURL=test%2Flink',
387
            Director::absoluteURL($expiredResponse->getHeader('Location'))
388
        );
389
        $this->assertEquals(
390
            $this->idFromFixture(Member::class, 'expiredpassword'),
391
            $this->session()->inst_get('loggedInAs')
392
        );
393
394
        // Make sure it redirects correctly after the password has been changed
395
        $this->mainSession->followRedirection();
396
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
397
        $this->assertEquals(302, $changedResponse->getStatusCode());
398
        $this->assertEquals(
399
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
400
            $changedResponse->getHeader('Location')
401
        );
402
    }
403
404
    public function testChangePasswordForLoggedInUsers()
405
    {
406
        $goodResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
407
408
        // Change the password
409
        $this->get('Security/changepassword?BackURL=test/back');
410
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
411
        $this->assertEquals(302, $changedResponse->getStatusCode());
412
        $this->assertEquals(
413
            Controller::join_links(Director::absoluteBaseURL(), 'test/back'),
414
            $changedResponse->getHeader('Location')
415
        );
416
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
417
418
        // Check if we can login with the new password
419
        $goodResponse = $this->doTestLoginForm('[email protected]', 'changedPassword');
420
        $this->assertEquals(302, $goodResponse->getStatusCode());
421
        $this->assertEquals(
422
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
423
            $goodResponse->getHeader('Location')
424
        );
425
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
426
    }
427
428
    public function testChangePasswordFromLostPassword()
429
    {
430
        $admin = $this->objFromFixture(Member::class, 'test');
431
        $admin->FailedLoginCount = 99;
0 ignored issues
show
Documentation introduced by
The property FailedLoginCount 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...
432
        $admin->LockedOutUntil = DBDatetime::now()->getValue();
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...
433
        $admin->write();
434
435
        $this->assertNull($admin->AutoLoginHash, 'Hash is empty before lost password');
436
437
        // Request new password by email
438
        $response = $this->get('Security/lostpassword');
439
        $response = $this->post('Security/LostPasswordForm', array('Email' => '[email protected]'));
440
441
        $this->assertEmailSent('[email protected]');
442
443
        // Load password link from email
444
        $admin = DataObject::get_by_id(Member::class, $admin->ID);
445
        $this->assertNotNull($admin->AutoLoginHash, 'Hash has been written after lost password');
446
447
        // We don't have access to the token - generate a new token and hash pair.
448
        $token = $admin->generateAutologinTokenAndStoreHash();
449
450
        // Check.
451
        $response = $this->get('Security/changepassword/?m='.$admin->ID.'&t=' . $token);
452
        $this->assertEquals(302, $response->getStatusCode());
453
        $this->assertEquals(
454
            Director::absoluteURL('Security/changepassword'),
455
            Director::absoluteURL($response->getHeader('Location'))
456
        );
457
458
        // Follow redirection to form without hash in GET parameter
459
        $response = $this->get('Security/changepassword');
460
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
461
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
462
463
        // Check if we can login with the new password
464
        $goodResponse = $this->doTestLoginForm('[email protected]', 'changedPassword');
465
        $this->assertEquals(302, $goodResponse->getStatusCode());
466
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
467
468
        $admin = DataObject::get_by_id(Member::class, $admin->ID, false);
469
        $this->assertNull($admin->LockedOutUntil);
470
        $this->assertEquals(0, $admin->FailedLoginCount);
471
    }
472
473
    public function testRepeatedLoginAttemptsLockingPeopleOut()
474
    {
475
        $local = i18n::get_locale();
476
        i18n::set_locale('en_US');
477
478
        Member::config()->lock_out_after_incorrect_logins = 5;
0 ignored issues
show
Documentation introduced by
The property lock_out_after_incorrect_logins does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
479
        Member::config()->lock_out_delay_mins = 15;
0 ignored issues
show
Documentation introduced by
The property lock_out_delay_mins does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
480
481
        // Login with a wrong password for more than the defined threshold
482
        for ($i = 1; $i <= Member::config()->lock_out_after_incorrect_logins+1; $i++) {
483
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
484
            $member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
485
486
            if ($i < Member::config()->lock_out_after_incorrect_logins) {
487
                $this->assertNull(
488
                    $member->LockedOutUntil,
489
                    'User does not have a lockout time set if under threshold for failed attempts'
490
                );
491
                $this->assertHasMessage(
492
                    _t(
493
                        'Member.ERRORWRONGCRED',
494
                        'The provided details don\'t seem to be correct. Please try again.'
495
                    )
496
                );
497
            } else {
498
                // Fuzzy matching for time to avoid side effects from slow running tests
499
                $this->assertGreaterThan(
500
                    time() + 14*60,
501
                    strtotime($member->LockedOutUntil),
502
                    'User has a lockout time set after too many failed attempts'
503
                );
504
            }
505
506
            $msg = _t(
507
                'Member.ERRORLOCKEDOUT2',
508
                'Your account has been temporarily disabled because of too many failed attempts at ' .
509
                'logging in. Please try again in {count} minutes.',
510
                null,
511
                array('count' => Member::config()->lock_out_delay_mins)
512
            );
513
            if ($i > Member::config()->lock_out_after_incorrect_logins) {
514
                $this->assertHasMessage($msg);
515
            }
516
        }
517
518
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
519
        $this->assertNull(
520
            $this->session()->inst_get('loggedInAs'),
521
            'The user can\'t log in after being locked out, even with the right password'
522
        );
523
524
        // (We fake this by re-setting LockedOutUntil)
525
        $member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
526
        $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...
527
        $member->write();
528
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
529
        $this->assertEquals(
530
            $this->session()->inst_get('loggedInAs'),
531
            $member->ID,
532
            'After lockout expires, the user can login again'
533
        );
534
535
        // Log the user out
536
        $this->session()->inst_set('loggedInAs', null);
537
538
        // Login again with wrong password, but less attempts than threshold
539
        for ($i = 1; $i < Member::config()->lock_out_after_incorrect_logins; $i++) {
540
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
541
        }
542
        $this->assertNull($this->session()->inst_get('loggedInAs'));
543
        $this->assertHasMessage(
544
            _t('Member.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.'),
545
            'The user can retry with a wrong password after the lockout expires'
546
        );
547
548
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
549
        $this->assertEquals(
550
            $this->session()->inst_get('loggedInAs'),
551
            $member->ID,
552
            'The user can login successfully after lockout expires, if staying below the threshold'
553
        );
554
555
        i18n::set_locale($local);
556
    }
557
558
    public function testAlternatingRepeatedLoginAttempts()
559
    {
560
        Member::config()->lock_out_after_incorrect_logins = 3;
0 ignored issues
show
Documentation introduced by
The property lock_out_after_incorrect_logins does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
561
562
        // ATTEMPTING LOG-IN TWICE WITH ONE ACCOUNT AND TWICE WITH ANOTHER SHOULDN'T LOCK ANYBODY OUT
563
564
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
565
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
566
567
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
568
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
569
570
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
571
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
572
573
        $this->assertNull($member1->LockedOutUntil);
574
        $this->assertNull($member2->LockedOutUntil);
575
576
        // BUT, DOING AN ADDITIONAL LOG-IN WITH EITHER OF THEM WILL LOCK OUT, SINCE THAT IS THE 3RD FAILURE IN
577
        // THIS SESSION
578
579
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
580
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
581
        $this->assertNotNull($member1->LockedOutUntil);
582
583
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
584
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
585
        $this->assertNotNull($member2->LockedOutUntil);
586
    }
587
588
    public function testUnsuccessfulLoginAttempts()
589
    {
590
        Security::config()->login_recording = true;
0 ignored issues
show
Documentation introduced by
The property login_recording does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
591
592
        /* UNSUCCESSFUL ATTEMPTS WITH WRONG PASSWORD FOR EXISTING USER ARE LOGGED */
593
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
594
        $attempt = DataObject::get_one(
595
            LoginAttempt::class,
596
            array(
597
            '"LoginAttempt"."Email"' => '[email protected]'
598
            )
599
        );
600
        $this->assertTrue(is_object($attempt));
601
        $member = DataObject::get_one(
602
            Member::class,
603
            array(
604
            '"Member"."Email"' => '[email protected]'
605
            )
606
        );
607
        $this->assertEquals($attempt->Status, 'Failure');
608
        $this->assertEquals($attempt->Email, '[email protected]');
609
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
610
611
        /* UNSUCCESSFUL ATTEMPTS WITH NONEXISTING USER ARE LOGGED */
612
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
613
        $attempt = DataObject::get_one(
614
            LoginAttempt::class,
615
            array(
616
            '"LoginAttempt"."Email"' => '[email protected]'
617
            )
618
        );
619
        $this->assertTrue(is_object($attempt));
620
        $this->assertEquals($attempt->Status, 'Failure');
621
        $this->assertEquals($attempt->Email, '[email protected]');
622
        $this->assertNotEmpty($this->getValidationResult()->getMessages(), 'An invalid email returns a message.');
623
    }
624
625
    public function testSuccessfulLoginAttempts()
626
    {
627
        Security::config()->login_recording = true;
0 ignored issues
show
Documentation introduced by
The property login_recording does not exist on object<SilverStripe\Core\Config\Config_ForClass>. 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...
628
629
        /* SUCCESSFUL ATTEMPTS ARE LOGGED */
630
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
631
        $attempt = DataObject::get_one(
632
            LoginAttempt::class,
633
            array(
634
            '"LoginAttempt"."Email"' => '[email protected]'
635
            )
636
        );
637
        $member = DataObject::get_one(
638
            Member::class,
639
            array(
640
            '"Member"."Email"' => '[email protected]'
641
            )
642
        );
643
        $this->assertTrue(is_object($attempt));
644
        $this->assertEquals($attempt->Status, 'Success');
645
        $this->assertEquals($attempt->Email, '[email protected]');
646
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
647
    }
648
649
    public function testDatabaseIsReadyWithInsufficientMemberColumns()
650
    {
651
        $old = Security::$force_database_is_ready;
0 ignored issues
show
Documentation introduced by
The property $force_database_is_ready is declared private in SilverStripe\Security\Security. Since you implemented __get(), maybe consider adding a @property or @property-read annotation. This makes it easier for IDEs to provide auto-completion.

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...
652
        Security::$force_database_is_ready = null;
0 ignored issues
show
Documentation introduced by
The property $force_database_is_ready is declared private in SilverStripe\Security\Security. Since you implemented __set(), maybe consider adding a @property or @property-write annotation. This makes it easier for IDEs to provide auto-completion.

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...
653
        Security::$database_is_ready = false;
0 ignored issues
show
Documentation introduced by
The property $database_is_ready is declared private in SilverStripe\Security\Security. Since you implemented __set(), maybe consider adding a @property or @property-write annotation. This makes it easier for IDEs to provide auto-completion.

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...
654
        DBClassName::clear_classname_cache();
655
656
        // Assumption: The database has been built correctly by the test runner,
657
        // and has all columns present in the ORM
658
        /**
659
         * @skipUpgrade
660
         */
661
        DB::get_schema()->renameField('Member', 'Email', 'Email_renamed');
662
663
        // Email column is now missing, which means we're not ready to do permission checks
664
        $this->assertFalse(Security::database_is_ready());
665
666
        // Rebuild the database (which re-adds the Email column), and try again
667
        static::resetDBSchema(true);
668
        $this->assertTrue(Security::database_is_ready());
669
670
        Security::$force_database_is_ready = $old;
0 ignored issues
show
Documentation introduced by
The property $force_database_is_ready is declared private in SilverStripe\Security\Security. Since you implemented __set(), maybe consider adding a @property or @property-write annotation. This makes it easier for IDEs to provide auto-completion.

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...
671
    }
672
673
    public function testSecurityControllerSendsRobotsTagHeader()
674
    {
675
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
676
        $robotsHeader = $response->getHeader('X-Robots-Tag');
677
        $this->assertNotNull($robotsHeader);
678
        $this->assertContains('noindex', $robotsHeader);
679
    }
680
681
    public function testDoNotSendEmptyRobotsHeaderIfNotDefined()
682
    {
683
        Config::inst()->remove(Security::class, 'robots_tag');
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface SilverStripe\Config\Coll...nfigCollectionInterface as the method remove() does only exist in the following implementations of said interface: SilverStripe\Config\Coll...\MemoryConfigCollection.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
684
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
685
        $robotsHeader = $response->getHeader('X-Robots-Tag');
686
        $this->assertNull($robotsHeader);
687
    }
688
689
    /**
690
     * Execute a log-in form using Director::test().
691
     * Helper method for the tests above
692
     */
693
    public function doTestLoginForm($email, $password, $backURL = 'test/link')
694
    {
695
        $this->get(Config::inst()->get(Security::class, 'logout_url'));
696
        $this->session()->inst_set('BackURL', $backURL);
697
        $this->get(Config::inst()->get(Security::class, 'login_url'));
698
699
        return $this->submitForm(
700
            "MemberLoginForm_LoginForm",
701
            null,
702
            array(
703
                'Email' => $email,
704
                'Password' => $password,
705
                'AuthenticationMethod' => MemberAuthenticator::class,
706
                'action_dologin' => 1,
707
            )
708
        );
709
    }
710
711
    /**
712
     * Helper method to execute a change password form
713
     */
714
    public function doTestChangepasswordForm($oldPassword, $newPassword)
715
    {
716
        return $this->submitForm(
717
            "ChangePasswordForm_ChangePasswordForm",
718
            null,
719
            array(
720
                'OldPassword' => $oldPassword,
721
                'NewPassword1' => $newPassword,
722
                'NewPassword2' => $newPassword,
723
                'action_doChangePassword' => 1,
724
            )
725
        );
726
    }
727
728
    /**
729
     * Assert this message is in the current login form errors
730
     *
731
     * @param string $expected
732
     * @param string $errorMessage
733
     */
734
    protected function assertHasMessage($expected, $errorMessage = null)
735
    {
736
        $messages = [];
737
        $result = $this->getValidationResult();
738
        if ($result) {
739
            foreach ($result->getMessages() as $message) {
740
                $messages[] = $message['message'];
741
            }
742
        }
743
744
        $this->assertContains($expected, $messages, $errorMessage);
745
    }
746
747
    /**
748
     * Get validation result from last login form submission
749
     *
750
     * @return ValidationResult
751
     */
752
    protected function getValidationResult()
753
    {
754
        $result = $this->session()->inst_get('FormInfo.MemberLoginForm_LoginForm.result');
755
        if ($result) {
756
            return unserialize($result);
757
        }
758
        return null;
759
    }
760
}
761