Completed
Push — authenticator-refactor ( 3617c4...16f104 )
by Sam
05:36
created

SecurityTest::testChangePasswordForLoggedInUsers()   B

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 16
nc 1
nop 0
dl 0
loc 24
rs 8.9713
c 0
b 0
f 0
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'));
0 ignored issues
show
Unused Code introduced by
$response is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
235
236
        $items = $this->cssParser()->getBySelector('#LoginForm_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
            'LoginForm_LoginForm',
246
            null,
247
            array(
248
                'action_logout' => 1,
249
            )
250
        );
251
252
        /* We get a good response */
253
        $this->assertEquals($response->getStatusCode(), 200, 'We have a 200 OK response');
254
        $this->assertNotNull($response->getBody(), 'There is body content on the page');
255
256
        /* Log the user out */
257
        $this->session()->inst_set('loggedInAs', null);
258
    }
259
260
    public function testMemberIDInSessionDoesntExistInDatabaseHasToLogin()
261
    {
262
        /* Log in with a Member ID that doesn't exist in the DB */
263
        $this->session()->inst_set('loggedInAs', 500);
264
265
        $this->autoFollowRedirection = true;
266
267
        /* Attempt to get into the admin section */
268
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
0 ignored issues
show
Unused Code introduced by
$response is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
269
270
        $items = $this->cssParser()->getBySelector('#LoginForm_LoginForm input.text');
271
272
        /* We have 2 text inputs - one for email, and another for the password */
273
        $this->assertEquals(count($items), 2, 'There are 2 inputs - one for email, another for password');
274
275
        $this->autoFollowRedirection = false;
276
277
        /* Log the user out */
278
        $this->session()->inst_set('loggedInAs', null);
279
    }
280
281
    public function testLoginUsernamePersists()
282
    {
283
        // Test that username does not persist
284
        $this->session()->inst_set('SessionForms.MemberLoginForm.Email', '[email protected]');
285
        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...
286
        $this->get(Config::inst()->get(Security::class, 'login_url'));
287
        $items = $this
288
            ->cssParser()
289
            ->getBySelector('#LoginForm_LoginForm #LoginForm_LoginForm_Email');
290
        $this->assertEquals(1, count($items));
291
        $this->assertEmpty((string)$items[0]->attributes()->value);
292
        $this->assertEquals('off', (string)$items[0]->attributes()->autocomplete);
293
        $form = $this->cssParser()->getBySelector('#LoginForm_LoginForm');
294
        $this->assertEquals(1, count($form));
295
        $this->assertEquals('off', (string)$form[0]->attributes()->autocomplete);
296
297
        // Test that username does persist when necessary
298
        $this->session()->inst_set('SessionForms.MemberLoginForm.Email', '[email protected]');
299
        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...
300
        $this->get(Config::inst()->get(Security::class, 'login_url'));
301
        $items = $this
302
            ->cssParser()
303
            ->getBySelector('#LoginForm_LoginForm #LoginForm_LoginForm_Email');
304
        $this->assertEquals(1, count($items));
305
        $this->assertEquals('[email protected]', (string)$items[0]->attributes()->value);
306
        $this->assertNotEquals('off', (string)$items[0]->attributes()->autocomplete);
307
        $form = $this->cssParser()->getBySelector('#LoginForm_LoginForm');
308
        $this->assertEquals(1, count($form));
309
        $this->assertNotEquals('off', (string)$form[0]->attributes()->autocomplete);
310
    }
311
312
    public function testExternalBackUrlRedirectionDisallowed()
313
    {
314
        // Test internal relative redirect
315
        $response = $this->doTestLoginForm('[email protected]', '1nitialPassword', 'testpage');
316
        $this->assertEquals(302, $response->getStatusCode());
317
        $this->assertRegExp(
318
            '/testpage/',
319
            $response->getHeader('Location'),
320
            "Internal relative BackURLs work when passed through to login form"
321
        );
322
        // Log the user out
323
        $this->session()->inst_set('loggedInAs', null);
324
325
        // Test internal absolute redirect
326
        $response = $this->doTestLoginForm(
327
            '[email protected]',
328
            '1nitialPassword',
329
            Director::absoluteBaseURL() . 'testpage'
330
        );
331
        // for some reason the redirect happens to a relative URL
332
        $this->assertRegExp(
333
            '/^' . preg_quote(Director::absoluteBaseURL(), '/') . 'testpage/',
334
            $response->getHeader('Location'),
335
            "Internal absolute BackURLs work when passed through to login form"
336
        );
337
        // Log the user out
338
        $this->session()->inst_set('loggedInAs', null);
339
340
        // Test external redirect
341
        $response = $this->doTestLoginForm('[email protected]', '1nitialPassword', 'http://myspoofedhost.com');
342
        $this->assertNotRegExp(
343
            '/^' . preg_quote('http://myspoofedhost.com', '/') . '/',
344
            (string)$response->getHeader('Location'),
345
            "Redirection to external links in login form BackURL gets prevented as a measure against spoofing attacks"
346
        );
347
348
        // Test external redirection on ChangePasswordForm
349
        $this->get('Security/changepassword?BackURL=http://myspoofedhost.com');
350
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
351
        $this->assertNotRegExp(
352
            '/^' . preg_quote('http://myspoofedhost.com', '/') . '/',
353
            (string)$changedResponse->getHeader('Location'),
354
            "Redirection to external links in change password form BackURL gets prevented to stop spoofing attacks"
355
        );
356
357
        // Log the user out
358
        $this->session()->inst_set('loggedInAs', null);
359
    }
360
361
    /**
362
     * Test that the login form redirects to the change password form after logging in with an expired password
363
     */
364
    public function testExpiredPassword()
365
    {
366
        /* BAD PASSWORDS ARE LOCKED OUT */
367
        $badResponse = $this->doTestLoginForm('[email protected]', 'badpassword');
368
        $this->assertEquals(302, $badResponse->getStatusCode());
369
        $this->assertRegExp('/Security\/login/', $badResponse->getHeader('Location'));
370
        $this->assertNull($this->session()->inst_get('loggedInAs'));
371
372
        /* UNEXPIRED PASSWORD GO THROUGH WITHOUT A HITCH */
373
        $goodResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
374
        $this->assertEquals(302, $goodResponse->getStatusCode());
375
        $this->assertEquals(
376
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
377
            $goodResponse->getHeader('Location')
378
        );
379
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
380
381
        $this->logOut();
382
383
        /* EXPIRED PASSWORDS ARE SENT TO THE CHANGE PASSWORD FORM */
384
        $expiredResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
385
        $this->assertEquals(302, $expiredResponse->getStatusCode());
386
        $this->assertEquals(
387
            Director::absoluteURL('Security/changepassword').'?BackURL=test%2Flink',
388
            Director::absoluteURL($expiredResponse->getHeader('Location'))
389
        );
390
        $this->assertEquals(
391
            $this->idFromFixture(Member::class, 'expiredpassword'),
392
            $this->session()->inst_get('loggedInAs')
393
        );
394
395
        // Make sure it redirects correctly after the password has been changed
396
        $this->mainSession->followRedirection();
397
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
398
        $this->assertEquals(302, $changedResponse->getStatusCode());
399
        $this->assertEquals(
400
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
401
            $changedResponse->getHeader('Location')
402
        );
403
    }
404
405
    public function testChangePasswordForLoggedInUsers()
406
    {
407
        $goodResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
0 ignored issues
show
Unused Code introduced by
$goodResponse is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
408
409
        // Change the password
410
        $this->get('Security/changepassword?BackURL=test/back');
411
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
412
        $this->assertEquals(302, $changedResponse->getStatusCode());
413
        $this->assertEquals(
414
            Controller::join_links(Director::absoluteBaseURL(), 'test/back'),
415
            $changedResponse->getHeader('Location')
416
        );
417
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
418
419
        // Check if we can login with the new password
420
        $this->logOut();
421
        $goodResponse = $this->doTestLoginForm('[email protected]', 'changedPassword');
422
        $this->assertEquals(302, $goodResponse->getStatusCode());
423
        $this->assertEquals(
424
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
425
            $goodResponse->getHeader('Location')
426
        );
427
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
428
    }
429
430
    public function testChangePasswordFromLostPassword()
431
    {
432
        $admin = $this->objFromFixture(Member::class, 'test');
433
        $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...
434
        $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...
435
        $admin->write();
436
437
        $this->assertNull($admin->AutoLoginHash, 'Hash is empty before lost password');
438
439
        // Request new password by email
440
        $response = $this->get('Security/lostpassword');
0 ignored issues
show
Unused Code introduced by
$response is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
441
        $response = $this->post('Security/lostpassword/LostPasswordForm', array('Email' => '[email protected]'));
0 ignored issues
show
Unused Code introduced by
$response is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
442
443
        $this->assertEmailSent('[email protected]');
444
445
        // Load password link from email
446
        $admin = DataObject::get_by_id(Member::class, $admin->ID);
447
        $this->assertNotNull($admin->AutoLoginHash, 'Hash has been written after lost password');
448
449
        // We don't have access to the token - generate a new token and hash pair.
450
        $token = $admin->generateAutologinTokenAndStoreHash();
451
452
        // Check.
453
        $response = $this->get('Security/changepassword/?m='.$admin->ID.'&t=' . $token);
454
        $this->assertEquals(302, $response->getStatusCode());
455
        $this->assertEquals(
456
            Director::absoluteURL('Security/changepassword'),
457
            Director::absoluteURL($response->getHeader('Location'))
458
        );
459
460
        // Follow redirection to form without hash in GET parameter
461
        $response = $this->get('Security/changepassword');
0 ignored issues
show
Unused Code introduced by
$response is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
462
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
0 ignored issues
show
Unused Code introduced by
$changedResponse is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
463
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
464
465
        // Check if we can login with the new password
466
        $this->logOut();
467
        $goodResponse = $this->doTestLoginForm('[email protected]', 'changedPassword');
468
        $this->assertEquals(302, $goodResponse->getStatusCode());
469
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
470
471
        $admin = DataObject::get_by_id(Member::class, $admin->ID, false);
472
        $this->assertNull($admin->LockedOutUntil);
473
        $this->assertEquals(0, $admin->FailedLoginCount);
474
    }
475
476
    public function testRepeatedLoginAttemptsLockingPeopleOut()
477
    {
478
        $local = i18n::get_locale();
479
        i18n::set_locale('en_US');
480
481
        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...
482
        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...
483
484
        // Login with a wrong password for more than the defined threshold
485
        for ($i = 1; $i <= Member::config()->lock_out_after_incorrect_logins+1; $i++) {
486
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
487
            $member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
488
489
            if ($i < Member::config()->lock_out_after_incorrect_logins) {
490
                $this->assertNull(
491
                    $member->LockedOutUntil,
492
                    'User does not have a lockout time set if under threshold for failed attempts'
493
                );
494
                $this->assertHasMessage(
495
                    _t(
496
                        'SilverStripe\\Security\\Member.ERRORWRONGCRED',
497
                        'The provided details don\'t seem to be correct. Please try again.'
498
                    )
499
                );
500
            } else {
501
                // Fuzzy matching for time to avoid side effects from slow running tests
502
                $this->assertGreaterThan(
503
                    time() + 14*60,
504
                    strtotime($member->LockedOutUntil),
505
                    'User has a lockout time set after too many failed attempts'
506
                );
507
            }
508
509
            $msg = _t(
510
                'SilverStripe\\Security\\Member.ERRORLOCKEDOUT2',
511
                'Your account has been temporarily disabled because of too many failed attempts at ' .
512
                'logging in. Please try again in {count} minutes.',
513
                null,
514
                array('count' => Member::config()->lock_out_delay_mins)
515
            );
516
            if ($i > Member::config()->lock_out_after_incorrect_logins) {
517
                $this->assertHasMessage($msg);
518
            }
519
        }
520
521
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
522
        $this->assertNull(
523
            $this->session()->inst_get('loggedInAs'),
524
            'The user can\'t log in after being locked out, even with the right password'
525
        );
526
527
        // (We fake this by re-setting LockedOutUntil)
528
        $member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
529
        $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...
530
        $member->write();
531
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
532
        $this->assertEquals(
533
            $this->session()->inst_get('loggedInAs'),
534
            $member->ID,
535
            'After lockout expires, the user can login again'
536
        );
537
538
        // Log the user out
539
        $this->logOut();
540
541
        // Login again with wrong password, but less attempts than threshold
542
        for ($i = 1; $i < Member::config()->lock_out_after_incorrect_logins; $i++) {
543
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
544
        }
545
        $this->assertNull($this->session()->inst_get('loggedInAs'));
546
        $this->assertHasMessage(
547
            _t('SilverStripe\\Security\\Member.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.'),
548
            'The user can retry with a wrong password after the lockout expires'
549
        );
550
551
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
552
        $this->assertEquals(
553
            $this->session()->inst_get('loggedInAs'),
554
            $member->ID,
555
            'The user can login successfully after lockout expires, if staying below the threshold'
556
        );
557
558
        i18n::set_locale($local);
559
    }
560
561
    public function testAlternatingRepeatedLoginAttempts()
562
    {
563
        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...
564
565
        // ATTEMPTING LOG-IN TWICE WITH ONE ACCOUNT AND TWICE WITH ANOTHER SHOULDN'T LOCK ANYBODY OUT
566
567
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
568
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
569
570
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
571
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
572
573
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
574
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
575
576
        $this->assertNull($member1->LockedOutUntil);
577
        $this->assertNull($member2->LockedOutUntil);
578
579
        // BUT, DOING AN ADDITIONAL LOG-IN WITH EITHER OF THEM WILL LOCK OUT, SINCE THAT IS THE 3RD FAILURE IN
580
        // THIS SESSION
581
582
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
583
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
584
        $this->assertNotNull($member1->LockedOutUntil);
585
586
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
587
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
588
        $this->assertNotNull($member2->LockedOutUntil);
589
    }
590
591
    public function testUnsuccessfulLoginAttempts()
592
    {
593
        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...
594
595
        /* UNSUCCESSFUL ATTEMPTS WITH WRONG PASSWORD FOR EXISTING USER ARE LOGGED */
596
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
597
        $attempt = DataObject::get_one(
598
            LoginAttempt::class,
599
            array(
600
            '"LoginAttempt"."Email"' => '[email protected]'
601
            )
602
        );
603
        $this->assertTrue(is_object($attempt));
604
        $member = DataObject::get_one(
605
            Member::class,
606
            array(
607
            '"Member"."Email"' => '[email protected]'
608
            )
609
        );
610
        $this->assertEquals($attempt->Status, 'Failure');
611
        $this->assertEquals($attempt->Email, '[email protected]');
612
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
613
614
        /* UNSUCCESSFUL ATTEMPTS WITH NONEXISTING USER ARE LOGGED */
615
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
616
        $attempt = DataObject::get_one(
617
            LoginAttempt::class,
618
            array(
619
            '"LoginAttempt"."Email"' => '[email protected]'
620
            )
621
        );
622
        $this->assertTrue(is_object($attempt));
623
        $this->assertEquals($attempt->Status, 'Failure');
624
        $this->assertEquals($attempt->Email, '[email protected]');
625
        $this->assertNotEmpty($this->getValidationResult()->getMessages(), 'An invalid email returns a message.');
626
    }
627
628
    public function testSuccessfulLoginAttempts()
629
    {
630
        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...
631
632
        /* SUCCESSFUL ATTEMPTS ARE LOGGED */
633
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
634
        $attempt = DataObject::get_one(
635
            LoginAttempt::class,
636
            array(
637
            '"LoginAttempt"."Email"' => '[email protected]'
638
            )
639
        );
640
        $member = DataObject::get_one(
641
            Member::class,
642
            array(
643
            '"Member"."Email"' => '[email protected]'
644
            )
645
        );
646
        $this->assertTrue(is_object($attempt));
647
        $this->assertEquals($attempt->Status, 'Success');
648
        $this->assertEquals($attempt->Email, '[email protected]');
649
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
650
    }
651
652
    public function testDatabaseIsReadyWithInsufficientMemberColumns()
653
    {
654
        Security::clear_database_is_ready();
655
        DBClassName::clear_classname_cache();
656
657
        // Assumption: The database has been built correctly by the test runner,
658
        // and has all columns present in the ORM
659
        /**
660
         * @skipUpgrade
661
         */
662
        DB::get_schema()->renameField('Member', 'Email', 'Email_renamed');
663
664
        // Email column is now missing, which means we're not ready to do permission checks
665
        $this->assertFalse(Security::database_is_ready());
666
667
        // Rebuild the database (which re-adds the Email column), and try again
668
        static::resetDBSchema(true);
669
        $this->assertTrue(Security::database_is_ready());
670
    }
671
672
    public function testSecurityControllerSendsRobotsTagHeader()
673
    {
674
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
675
        $robotsHeader = $response->getHeader('X-Robots-Tag');
676
        $this->assertNotNull($robotsHeader);
677
        $this->assertContains('noindex', $robotsHeader);
678
    }
679
680
    public function testDoNotSendEmptyRobotsHeaderIfNotDefined()
681
    {
682
        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...
683
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
684
        $robotsHeader = $response->getHeader('X-Robots-Tag');
685
        $this->assertNull($robotsHeader);
686
    }
687
688
    /**
689
     * Execute a log-in form using Director::test().
690
     * Helper method for the tests above
691
     */
692
    public function doTestLoginForm($email, $password, $backURL = 'test/link')
693
    {
694
        $this->get(Config::inst()->get(Security::class, 'logout_url'));
695
        $this->session()->inst_set('BackURL', $backURL);
696
        $this->get(Config::inst()->get(Security::class, 'login_url'));
697
698
        return $this->submitForm(
699
            "LoginForm_LoginForm",
700
            null,
701
            array(
702
                'Email' => $email,
703
                'Password' => $password,
704
                'AuthenticationMethod' => MemberAuthenticator::class,
705
                'action_doLogin' => 1,
706
            )
707
        );
708
    }
709
710
    /**
711
     * Helper method to execute a change password form
712
     */
713
    public function doTestChangepasswordForm($oldPassword, $newPassword)
714
    {
715
        return $this->submitForm(
716
            "ChangePasswordForm_ChangePasswordForm",
717
            null,
718
            array(
719
                'OldPassword' => $oldPassword,
720
                'NewPassword1' => $newPassword,
721
                'NewPassword2' => $newPassword,
722
                'action_doChangePassword' => 1,
723
            )
724
        );
725
    }
726
727
    /**
728
     * Assert this message is in the current login form errors
729
     *
730
     * @param string $expected
731
     * @param string $errorMessage
732
     */
733
    protected function assertHasMessage($expected, $errorMessage = null)
734
    {
735
        $messages = [];
736
        $result = $this->getValidationResult();
737
        if ($result) {
738
            foreach ($result->getMessages() as $message) {
739
                $messages[] = $message['message'];
740
            }
741
        }
742
743
        $this->assertContains($expected, $messages, $errorMessage);
744
    }
745
746
    /**
747
     * Get validation result from last login form submission
748
     *
749
     * @return ValidationResult
750
     */
751
    protected function getValidationResult()
752
    {
753
        $result = $this->session()->inst_get('FormInfo.LoginForm_LoginForm.result');
754
        if ($result) {
755
            return unserialize($result);
756
        }
757
        return null;
758
    }
759
}
760