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