Security update for Core, with self-updated composer
[yaffs-website] / web / core / modules / user / tests / src / Functional / UserLoginHttpTest.php
1 <?php
2
3 namespace Drupal\Tests\user\Functional;
4
5 use Drupal\Core\Flood\DatabaseBackend;
6 use Drupal\Core\Url;
7 use Drupal\Tests\BrowserTestBase;
8 use Drupal\user\Controller\UserAuthenticationController;
9 use Drupal\user\Tests\UserResetEmailTestTrait;
10 use GuzzleHttp\Cookie\CookieJar;
11 use Psr\Http\Message\ResponseInterface;
12 use Symfony\Component\Serializer\Encoder\JsonEncoder;
13 use Symfony\Component\Serializer\Encoder\XmlEncoder;
14 use Drupal\hal\Encoder\JsonEncoder as HALJsonEncoder;
15 use Symfony\Component\Serializer\Serializer;
16
17 /**
18  * Tests login and password reset via direct HTTP.
19  *
20  * @group user
21  */
22 class UserLoginHttpTest extends BrowserTestBase {
23
24   use UserResetEmailTestTrait;
25
26   /**
27    * Modules to install.
28    *
29    * @var array
30    */
31   public static $modules = ['hal'];
32
33   /**
34    * The cookie jar.
35    *
36    * @var \GuzzleHttp\Cookie\CookieJar
37    */
38   protected $cookies;
39
40   /**
41    * The serializer.
42    *
43    * @var \Symfony\Component\Serializer\Serializer
44    */
45   protected $serializer;
46
47   /**
48    * {@inheritdoc}
49    */
50   protected function setUp() {
51     parent::setUp();
52     $this->cookies = new CookieJar();
53     $encoders = [new JsonEncoder(), new XmlEncoder(), new HALJsonEncoder()];
54     $this->serializer = new Serializer([], $encoders);
55   }
56
57   /**
58    * Executes a login HTTP request.
59    *
60    * @param string $name
61    *   The username.
62    * @param string $pass
63    *   The user password.
64    * @param string $format
65    *   The format to use to make the request.
66    *
67    * @return \Psr\Http\Message\ResponseInterface
68    *   The HTTP response.
69    */
70   protected function loginRequest($name, $pass, $format = 'json') {
71     $user_login_url = Url::fromRoute('user.login.http')
72       ->setRouteParameter('_format', $format)
73       ->setAbsolute();
74
75     $request_body = [];
76     if (isset($name)) {
77       $request_body['name'] = $name;
78     }
79     if (isset($pass)) {
80       $request_body['pass'] = $pass;
81     }
82
83     $result = \Drupal::httpClient()->post($user_login_url->toString(), [
84       'body' => $this->serializer->encode($request_body, $format),
85       'headers' => [
86         'Accept' => "application/$format",
87       ],
88       'http_errors' => FALSE,
89       'cookies' => $this->cookies,
90     ]);
91     return $result;
92   }
93
94   /**
95    * Tests user session life cycle.
96    */
97   public function testLogin() {
98     // Without the serialization module only JSON is supported.
99     $this->doTestLogin('json');
100
101     // Enable serialization so we have access to additional formats.
102     $this->container->get('module_installer')->install(['serialization']);
103     $this->doTestLogin('json');
104     $this->doTestLogin('xml');
105     $this->doTestLogin('hal_json');
106   }
107
108   /**
109    * Do login testing for a given serialization format.
110    *
111    * @param string $format
112    *   Serialization format.
113    */
114   protected function doTestLogin($format) {
115     $client = \Drupal::httpClient();
116     // Create new user for each iteration to reset flood.
117     // Grant the user administer users permissions to they can see the
118     // 'roles' field.
119     $account = $this->drupalCreateUser(['administer users']);
120     $name = $account->getUsername();
121     $pass = $account->passRaw;
122
123     $login_status_url = $this->getLoginStatusUrlString($format);
124     $response = $client->get($login_status_url);
125     $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
126
127     // Flooded.
128     $this->config('user.flood')
129       ->set('user_limit', 3)
130       ->save();
131
132     $response = $this->loginRequest($name, 'wrong-pass', $format);
133     $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
134
135     $response = $this->loginRequest($name, 'wrong-pass', $format);
136     $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
137
138     $response = $this->loginRequest($name, 'wrong-pass', $format);
139     $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
140
141     $response = $this->loginRequest($name, 'wrong-pass', $format);
142     $this->assertHttpResponseWithMessage($response, 403, 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.', $format);
143
144     // After testing the flood control we can increase the limit.
145     $this->config('user.flood')
146       ->set('user_limit', 100)
147       ->save();
148
149     $response = $this->loginRequest(NULL, NULL, $format);
150     $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.', $format);
151
152     $response = $this->loginRequest(NULL, $pass, $format);
153     $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.name.', $format);
154
155     $response = $this->loginRequest($name, NULL, $format);
156     $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.pass.', $format);
157
158     // Blocked.
159     $account
160       ->block()
161       ->save();
162
163     $response = $this->loginRequest($name, $pass, $format);
164     $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
165
166     $account
167       ->activate()
168       ->save();
169
170     $response = $this->loginRequest($name, 'garbage', $format);
171     $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
172
173     $response = $this->loginRequest('garbage', $pass, $format);
174     $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
175
176     $response = $this->loginRequest($name, $pass, $format);
177     $this->assertEquals(200, $response->getStatusCode());
178     $result_data = $this->serializer->decode($response->getBody(), $format);
179     $this->assertEquals($name, $result_data['current_user']['name']);
180     $this->assertEquals($account->id(), $result_data['current_user']['uid']);
181     $this->assertEquals($account->getRoles(), $result_data['current_user']['roles']);
182     $logout_token = $result_data['logout_token'];
183
184     // Logging in while already logged in results in a 403 with helpful message.
185     $response = $this->loginRequest($name, $pass, $format);
186     $this->assertSame(403, $response->getStatusCode());
187     $this->assertSame(['message' => 'This route can only be accessed by anonymous users.'], $this->serializer->decode($response->getBody(), $format));
188
189     $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
190     $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
191
192     $response = $this->logoutRequest($format, $logout_token);
193     $this->assertEquals(204, $response->getStatusCode());
194
195     $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
196     $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
197
198     $this->resetFlood();
199   }
200
201   /**
202    * Executes a password HTTP request.
203    *
204    * @param array $request_body
205    *   The request body.
206    * @param string $format
207    *   The format to use to make the request.
208    *
209    * @return \Psr\Http\Message\ResponseInterface
210    *   The HTTP response.
211    */
212   protected function passwordRequest(array $request_body, $format = 'json') {
213     $password_reset_url = Url::fromRoute('user.pass.http')
214       ->setRouteParameter('_format', $format)
215       ->setAbsolute();
216
217     $result = \Drupal::httpClient()->post($password_reset_url->toString(), [
218       'body' => $this->serializer->encode($request_body, $format),
219       'headers' => [
220         'Accept' => "application/$format",
221       ],
222       'http_errors' => FALSE,
223       'cookies' => $this->cookies,
224     ]);
225
226     return $result;
227   }
228
229   /**
230    * Tests user password reset.
231    */
232   public function testPasswordReset() {
233     // Create a user account.
234     $account = $this->drupalCreateUser();
235
236     // Without the serialization module only JSON is supported.
237     $this->doTestPasswordReset('json', $account);
238
239     // Enable serialization so we have access to additional formats.
240     $this->container->get('module_installer')->install(['serialization']);
241
242     $this->doTestPasswordReset('json', $account);
243     $this->doTestPasswordReset('xml', $account);
244     $this->doTestPasswordReset('hal_json', $account);
245   }
246
247   /**
248    * Gets a value for a given key from the response.
249    *
250    * @param \Psr\Http\Message\ResponseInterface $response
251    *   The response object.
252    * @param string $key
253    *   The key for the value.
254    * @param string $format
255    *   The encoded format.
256    *
257    * @return mixed
258    *   The value for the key.
259    */
260   protected function getResultValue(ResponseInterface $response, $key, $format) {
261     $decoded = $this->serializer->decode((string) $response->getBody(), $format);
262     if (is_array($decoded)) {
263       return $decoded[$key];
264     }
265     else {
266       return $decoded->{$key};
267     }
268   }
269
270   /**
271    * Resets all flood entries.
272    */
273   protected function resetFlood() {
274     $this->container->get('database')->delete(DatabaseBackend::TABLE_NAME)->execute();
275   }
276
277   /**
278    * Tests the global login flood control.
279    *
280    * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testGlobalLoginFloodControl
281    * @see \Drupal\user\Tests\UserLoginTest::testGlobalLoginFloodControl
282    */
283   public function testGlobalLoginFloodControl() {
284     $this->config('user.flood')
285       ->set('ip_limit', 2)
286       // Set a high per-user limit out so that it is not relevant in the test.
287       ->set('user_limit', 4000)
288       ->save();
289
290     $user = $this->drupalCreateUser([]);
291     $incorrect_user = clone $user;
292     $incorrect_user->passRaw .= 'incorrect';
293
294     // Try 2 failed logins.
295     for ($i = 0; $i < 2; $i++) {
296       $response = $this->loginRequest($incorrect_user->getUsername(), $incorrect_user->passRaw);
297       $this->assertEquals('400', $response->getStatusCode());
298     }
299
300     // IP limit has reached to its limit. Even valid user credentials will fail.
301     $response = $this->loginRequest($user->getUsername(), $user->passRaw);
302     $this->assertHttpResponseWithMessage($response, '403', 'Access is blocked because of IP based flood prevention.');
303   }
304
305   /**
306    * Checks a response for status code and body.
307    *
308    * @param \Psr\Http\Message\ResponseInterface $response
309    *   The response object.
310    * @param int $expected_code
311    *   The expected status code.
312    * @param mixed $expected_body
313    *   The expected response body.
314    */
315   protected function assertHttpResponse(ResponseInterface $response, $expected_code, $expected_body) {
316     $this->assertEquals($expected_code, $response->getStatusCode());
317     $this->assertEquals($expected_body, (string) $response->getBody());
318   }
319
320   /**
321    * Checks a response for status code and message.
322    *
323    * @param \Psr\Http\Message\ResponseInterface $response
324    *   The response object.
325    * @param int $expected_code
326    *   The expected status code.
327    * @param string $expected_message
328    *   The expected message encoded in response.
329    * @param string $format
330    *   The format that the response is encoded in.
331    */
332   protected function assertHttpResponseWithMessage(ResponseInterface $response, $expected_code, $expected_message, $format = 'json') {
333     $this->assertEquals($expected_code, $response->getStatusCode());
334     $this->assertEquals($expected_message, $this->getResultValue($response, 'message', $format));
335   }
336
337   /**
338    * Test the per-user login flood control.
339    *
340    * @see \Drupal\user\Tests\UserLoginTest::testPerUserLoginFloodControl
341    * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testPerUserLoginFloodControl
342    */
343   public function testPerUserLoginFloodControl() {
344     foreach ([TRUE, FALSE] as $uid_only_setting) {
345       $this->config('user.flood')
346         // Set a high global limit out so that it is not relevant in the test.
347         ->set('ip_limit', 4000)
348         ->set('user_limit', 3)
349         ->set('uid_only', $uid_only_setting)
350         ->save();
351
352       $user1 = $this->drupalCreateUser([]);
353       $incorrect_user1 = clone $user1;
354       $incorrect_user1->passRaw .= 'incorrect';
355
356       $user2 = $this->drupalCreateUser([]);
357
358       // Try 2 failed logins.
359       for ($i = 0; $i < 2; $i++) {
360         $response = $this->loginRequest($incorrect_user1->getUsername(), $incorrect_user1->passRaw);
361         $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.');
362       }
363
364       // A successful login will reset the per-user flood control count.
365       $response = $this->loginRequest($user1->getUsername(), $user1->passRaw);
366       $result_data = $this->serializer->decode($response->getBody(), 'json');
367       $this->logoutRequest('json', $result_data['logout_token']);
368
369       // Try 3 failed logins for user 1, they will not trigger flood control.
370       for ($i = 0; $i < 3; $i++) {
371         $response = $this->loginRequest($incorrect_user1->getUsername(), $incorrect_user1->passRaw);
372         $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.');
373       }
374
375       // Try one successful attempt for user 2, it should not trigger any
376       // flood control.
377       $this->drupalLogin($user2);
378       $this->drupalLogout();
379
380       // Try one more attempt for user 1, it should be rejected, even if the
381       // correct password has been used.
382       $response = $this->loginRequest($user1->getUsername(), $user1->passRaw);
383       // Depending on the uid_only setting the error message will be different.
384       if ($uid_only_setting) {
385         $excepted_message = 'There have been more than 3 failed login attempts for this account. It is temporarily blocked. Try again later or request a new password.';
386       }
387       else {
388         $excepted_message = 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.';
389       }
390       $this->assertHttpResponseWithMessage($response, 403, $excepted_message);
391     }
392
393   }
394
395   /**
396    * Executes a logout HTTP request.
397    *
398    * @param string $format
399    *   The format to use to make the request.
400    * @param string $logout_token
401    *   The csrf token for user logout.
402    *
403    * @return \Psr\Http\Message\ResponseInterface
404    *   The HTTP response.
405    */
406   protected function logoutRequest($format = 'json', $logout_token = '') {
407     /** @var \GuzzleHttp\Client $client */
408     $client = $this->container->get('http_client');
409     $user_logout_url = Url::fromRoute('user.logout.http')
410       ->setRouteParameter('_format', $format)
411       ->setAbsolute();
412     if ($logout_token) {
413       $user_logout_url->setOption('query', ['token' => $logout_token]);
414     }
415     $post_options = [
416       'headers' => [
417         'Accept' => "application/$format",
418       ],
419       'http_errors' => FALSE,
420       'cookies' => $this->cookies,
421     ];
422
423     $response = $client->post($user_logout_url->toString(), $post_options);
424     return $response;
425   }
426
427   /**
428    * Test csrf protection of User Logout route.
429    */
430   public function testLogoutCsrfProtection() {
431     $client = \Drupal::httpClient();
432     $login_status_url = $this->getLoginStatusUrlString();
433     $account = $this->drupalCreateUser();
434     $name = $account->getUsername();
435     $pass = $account->passRaw;
436
437     $response = $this->loginRequest($name, $pass);
438     $this->assertEquals(200, $response->getStatusCode());
439     $result_data = $this->serializer->decode($response->getBody(), 'json');
440
441     $logout_token = $result_data['logout_token'];
442
443     // Test third party site posting to current site with logout request.
444     // This should not logout the current user because it lacks the CSRF
445     // token.
446     $response = $this->logoutRequest('json');
447     $this->assertEquals(403, $response->getStatusCode());
448
449     // Ensure still logged in.
450     $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
451     $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
452
453     // Try with an incorrect token.
454     $response = $this->logoutRequest('json', 'not-the-correct-token');
455     $this->assertEquals(403, $response->getStatusCode());
456
457     // Ensure still logged in.
458     $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
459     $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
460
461     // Try a logout request with correct token.
462     $response = $this->logoutRequest('json', $logout_token);
463     $this->assertEquals(204, $response->getStatusCode());
464
465     // Ensure actually logged out.
466     $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
467     $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
468   }
469
470   /**
471    * Gets the URL string for checking login.
472    *
473    * @param string $format
474    *   The format to use to make the request.
475    *
476    * @return string
477    *   The URL string.
478    */
479   protected function getLoginStatusUrlString($format = 'json') {
480     $user_login_status_url = Url::fromRoute('user.login_status.http');
481     $user_login_status_url->setRouteParameter('_format', $format);
482     $user_login_status_url->setAbsolute();
483     return $user_login_status_url->toString();
484   }
485
486   /**
487    * Do password reset testing for given format and account.
488    *
489    * @param string $format
490    *   Serialization format.
491    * @param \Drupal\user\UserInterface $account
492    *   Test account.
493    */
494   protected function doTestPasswordReset($format, $account) {
495     $response = $this->passwordRequest([], $format);
496     $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.name or credentials.mail', $format);
497
498     $response = $this->passwordRequest(['name' => 'dramallama'], $format);
499     $this->assertHttpResponseWithMessage($response, 400, 'Unrecognized username or email address.', $format);
500
501     $response = $this->passwordRequest(['mail' => 'llama@drupal.org'], $format);
502     $this->assertHttpResponseWithMessage($response, 400, 'Unrecognized username or email address.', $format);
503
504     $account
505       ->block()
506       ->save();
507
508     $response = $this->passwordRequest(['name' => $account->getAccountName()], $format);
509     $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
510
511     $response = $this->passwordRequest(['mail' => $account->getEmail()], $format);
512     $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
513
514     $account
515       ->activate()
516       ->save();
517
518     $response = $this->passwordRequest(['name' => $account->getAccountName()], $format);
519     $this->assertEquals(200, $response->getStatusCode());
520     $this->loginFromResetEmail();
521     $this->drupalLogout();
522
523     $response = $this->passwordRequest(['mail' => $account->getEmail()], $format);
524     $this->assertEquals(200, $response->getStatusCode());
525     $this->loginFromResetEmail();
526     $this->drupalLogout();
527   }
528
529 }