3 namespace Drupal\Tests\user\Functional;
5 use Drupal\Core\Flood\DatabaseBackend;
6 use Drupal\Core\Test\AssertMailTrait;
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;
18 * Tests login and password reset via direct HTTP.
22 class UserLoginHttpTest extends BrowserTestBase {
25 getMails as drupalGetMails;
33 public static $modules = ['hal'];
38 * @var \GuzzleHttp\Cookie\CookieJar
45 * @var \Symfony\Component\Serializer\Serializer
47 protected $serializer;
52 protected function setUp() {
54 $this->cookies = new CookieJar();
55 $encoders = [new JsonEncoder(), new XmlEncoder(), new HALJsonEncoder()];
56 $this->serializer = new Serializer([], $encoders);
60 * Executes a login HTTP request.
66 * @param string $format
67 * The format to use to make the request.
69 * @return \Psr\Http\Message\ResponseInterface
72 protected function loginRequest($name, $pass, $format = 'json') {
73 $user_login_url = Url::fromRoute('user.login.http')
74 ->setRouteParameter('_format', $format)
79 $request_body['name'] = $name;
82 $request_body['pass'] = $pass;
85 $result = \Drupal::httpClient()->post($user_login_url->toString(), [
86 'body' => $this->serializer->encode($request_body, $format),
88 'Accept' => "application/$format",
90 'http_errors' => FALSE,
91 'cookies' => $this->cookies,
97 * Tests user session life cycle.
99 public function testLogin() {
100 // Without the serialization module only JSON is supported.
101 $this->doTestLogin('json');
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');
111 * Do login testing for a given serialization format.
113 * @param string $format
114 * Serialization format.
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
121 $account = $this->drupalCreateUser(['administer users']);
122 $name = $account->getUsername();
123 $pass = $account->passRaw;
125 $login_status_url = $this->getLoginStatusUrlString($format);
126 $response = $client->get($login_status_url);
127 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
130 $this->config('user.flood')
131 ->set('user_limit', 3)
134 $response = $this->loginRequest($name, 'wrong-pass', $format);
135 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
137 $response = $this->loginRequest($name, 'wrong-pass', $format);
138 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
140 $response = $this->loginRequest($name, 'wrong-pass', $format);
141 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
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);
146 // After testing the flood control we can increase the limit.
147 $this->config('user.flood')
148 ->set('user_limit', 100)
151 $response = $this->loginRequest(NULL, NULL, $format);
152 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.', $format);
154 $response = $this->loginRequest(NULL, $pass, $format);
155 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.name.', $format);
157 $response = $this->loginRequest($name, NULL, $format);
158 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.pass.', $format);
165 $response = $this->loginRequest($name, $pass, $format);
166 $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
172 $response = $this->loginRequest($name, 'garbage', $format);
173 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
175 $response = $this->loginRequest('garbage', $pass, $format);
176 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
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'];
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));
191 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
192 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
194 $response = $this->logoutRequest($format, $logout_token);
195 $this->assertEquals(204, $response->getStatusCode());
197 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
198 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
204 * Executes a password HTTP request.
206 * @param array $request_body
208 * @param string $format
209 * The format to use to make the request.
211 * @return \Psr\Http\Message\ResponseInterface
214 protected function passwordRequest(array $request_body, $format = 'json') {
215 $password_reset_url = Url::fromRoute('user.pass.http')
216 ->setRouteParameter('_format', $format)
219 $result = \Drupal::httpClient()->post($password_reset_url->toString(), [
220 'body' => $this->serializer->encode($request_body, $format),
222 'Accept' => "application/$format",
224 'http_errors' => FALSE,
225 'cookies' => $this->cookies,
232 * Tests user password reset.
234 public function testPasswordReset() {
235 // Create a user account.
236 $account = $this->drupalCreateUser();
238 // Without the serialization module only JSON is supported.
239 $this->doTestPasswordReset('json', $account);
241 // Enable serialization so we have access to additional formats.
242 $this->container->get('module_installer')->install(['serialization']);
244 $this->doTestPasswordReset('json', $account);
245 $this->doTestPasswordReset('xml', $account);
246 $this->doTestPasswordReset('hal_json', $account);
250 * Gets a value for a given key from the response.
252 * @param \Psr\Http\Message\ResponseInterface $response
253 * The response object.
255 * The key for the value.
256 * @param string $format
257 * The encoded format.
260 * The value for the key.
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];
268 return $decoded->{$key};
273 * Resets all flood entries.
275 protected function resetFlood() {
276 $this->container->get('database')->delete(DatabaseBackend::TABLE_NAME)->execute();
280 * Tests the global login flood control.
282 * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testGlobalLoginFloodControl
283 * @see \Drupal\user\Tests\UserLoginTest::testGlobalLoginFloodControl
285 public function testGlobalLoginFloodControl() {
286 $this->config('user.flood')
288 // Set a high per-user limit out so that it is not relevant in the test.
289 ->set('user_limit', 4000)
292 $user = $this->drupalCreateUser([]);
293 $incorrect_user = clone $user;
294 $incorrect_user->passRaw .= 'incorrect';
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());
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.');
308 * Checks a response for status code and body.
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.
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());
323 * Checks a response for status code and message.
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.
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));
340 * Test the per-user login flood control.
342 * @see \Drupal\user\Tests\UserLoginTest::testPerUserLoginFloodControl
343 * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testPerUserLoginFloodControl
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)
354 $user1 = $this->drupalCreateUser([]);
355 $incorrect_user1 = clone $user1;
356 $incorrect_user1->passRaw .= 'incorrect';
358 $user2 = $this->drupalCreateUser([]);
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.');
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']);
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.');
377 // Try one successful attempt for user 2, it should not trigger any
379 $this->drupalLogin($user2);
380 $this->drupalLogout();
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.';
390 $excepted_message = 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.';
392 $this->assertHttpResponseWithMessage($response, 403, $excepted_message);
398 * Executes a logout HTTP request.
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.
405 * @return \Psr\Http\Message\ResponseInterface
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)
415 $user_logout_url->setOption('query', ['token' => $logout_token]);
419 'Accept' => "application/$format",
421 'http_errors' => FALSE,
422 'cookies' => $this->cookies,
425 $response = $client->post($user_logout_url->toString(), $post_options);
430 * Test csrf protection of User Logout route.
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;
439 $response = $this->loginRequest($name, $pass);
440 $this->assertEquals(200, $response->getStatusCode());
441 $result_data = $this->serializer->decode($response->getBody(), 'json');
443 $logout_token = $result_data['logout_token'];
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
448 $response = $this->logoutRequest('json');
449 $this->assertEquals(403, $response->getStatusCode());
451 // Ensure still logged in.
452 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
453 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
455 // Try with an incorrect token.
456 $response = $this->logoutRequest('json', 'not-the-correct-token');
457 $this->assertEquals(403, $response->getStatusCode());
459 // Ensure still logged in.
460 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
461 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
463 // Try a logout request with correct token.
464 $response = $this->logoutRequest('json', $logout_token);
465 $this->assertEquals(204, $response->getStatusCode());
467 // Ensure actually logged out.
468 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
469 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
473 * Gets the URL string for checking login.
475 * @param string $format
476 * The format to use to make the request.
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();
489 * Do password reset testing for given format and account.
491 * @param string $format
492 * Serialization format.
493 * @param \Drupal\user\UserInterface $account
496 protected function doTestPasswordReset($format, $account) {
497 $response = $this->passwordRequest([], $format);
498 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.name or credentials.mail', $format);
500 $response = $this->passwordRequest(['name' => 'dramallama'], $format);
501 $this->assertHttpResponseWithMessage($response, 400, 'Unrecognized username or email address.', $format);
503 $response = $this->passwordRequest(['mail' => 'llama@drupal.org'], $format);
504 $this->assertHttpResponseWithMessage($response, 400, 'Unrecognized username or email address.', $format);
510 $response = $this->passwordRequest(['name' => $account->getAccountName()], $format);
511 $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
513 $response = $this->passwordRequest(['mail' => $account->getEmail()], $format);
514 $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
520 $response = $this->passwordRequest(['name' => $account->getAccountName()], $format);
521 $this->assertEquals(200, $response->getStatusCode());
522 $this->loginFromResetEmail();
523 $this->drupalLogout();
525 $response = $this->passwordRequest(['mail' => $account->getEmail()], $format);
526 $this->assertEquals(200, $response->getStatusCode());
527 $this->loginFromResetEmail();
528 $this->drupalLogout();
532 * Login from reset password email.
534 protected function loginFromResetEmail() {
535 $_emails = $this->drupalGetMails();
536 $email = end($_emails);
538 preg_match('#.+user/reset/.+#', $email['body'], $urls);
539 $resetURL = $urls[0];
540 $this->drupalGet($resetURL);
541 $this->drupalPostForm(NULL, NULL, 'Log in');