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