1 | <?php |
||
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; |
||
|
|||
61 | $this->priorRememberUsername = Security::config()->remember_username; |
||
62 | /** |
||
63 | * @skipUpgrade |
||
64 | */ |
||
65 | Member::config()->unique_identifier_field = 'Email'; |
||
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; |
||
82 | Security::config()->remember_username = $this->priorRememberUsername; |
||
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'); |
||
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( |
||
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('#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')); |
||
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; |
||
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; |
||
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'); |
||
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; |
||
434 | $admin->LockedOutUntil = DBDatetime::now()->getValue(); |
||
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'); |
||
441 | $response = $this->post('Security/lostpassword/LostPasswordForm', array('Email' => '[email protected]')); |
||
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'); |
||
462 | $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword'); |
||
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; |
||
482 | Member::config()->lock_out_delay_mins = 15; |
||
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); |
||
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() |
||
590 | |||
591 | public function testUnsuccessfulLoginAttempts() |
||
592 | { |
||
593 | Security::config()->login_recording = true; |
||
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() |
||
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 | |||
671 | |||
672 | public function testSecurityControllerSendsRobotsTagHeader() |
||
679 | |||
680 | public function testDoNotSendEmptyRobotsHeaderIfNotDefined() |
||
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') |
||
709 | |||
710 | /** |
||
711 | * Helper method to execute a change password form |
||
712 | */ |
||
713 | public function doTestChangepasswordForm($oldPassword, $newPassword) |
||
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) |
||
745 | |||
746 | /** |
||
747 | * Get validation result from last login form submission |
||
748 | * |
||
749 | * @return ValidationResult |
||
750 | */ |
||
751 | protected function getValidationResult() |
||
759 | } |
||
760 |
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.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.