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