3 namespace Drupal\Tests\user\Functional;
5 use Drupal\Core\Flood\DatabaseBackend;
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;
18 * Tests login and password reset via direct HTTP.
22 class UserLoginHttpTest extends BrowserTestBase {
24 use UserResetEmailTestTrait;
31 public static $modules = ['hal'];
36 * @var \GuzzleHttp\Cookie\CookieJar
43 * @var \Symfony\Component\Serializer\Serializer
45 protected $serializer;
50 protected function setUp() {
52 $this->cookies = new CookieJar();
53 $encoders = [new JsonEncoder(), new XmlEncoder(), new HALJsonEncoder()];
54 $this->serializer = new Serializer([], $encoders);
58 * Executes a login HTTP request.
64 * @param string $format
65 * The format to use to make the request.
67 * @return \Psr\Http\Message\ResponseInterface
70 protected function loginRequest($name, $pass, $format = 'json') {
71 $user_login_url = Url::fromRoute('user.login.http')
72 ->setRouteParameter('_format', $format)
77 $request_body['name'] = $name;
80 $request_body['pass'] = $pass;
83 $result = \Drupal::httpClient()->post($user_login_url->toString(), [
84 'body' => $this->serializer->encode($request_body, $format),
86 'Accept' => "application/$format",
88 'http_errors' => FALSE,
89 'cookies' => $this->cookies,
95 * Tests user session life cycle.
97 public function testLogin() {
98 // Without the serialization module only JSON is supported.
99 $this->doTestLogin('json');
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');
109 * Do login testing for a given serialization format.
111 * @param string $format
112 * Serialization format.
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
119 $account = $this->drupalCreateUser(['administer users']);
120 $name = $account->getUsername();
121 $pass = $account->passRaw;
123 $login_status_url = $this->getLoginStatusUrlString($format);
124 $response = $client->get($login_status_url);
125 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
128 $this->config('user.flood')
129 ->set('user_limit', 3)
132 $response = $this->loginRequest($name, 'wrong-pass', $format);
133 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
135 $response = $this->loginRequest($name, 'wrong-pass', $format);
136 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
138 $response = $this->loginRequest($name, 'wrong-pass', $format);
139 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
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);
144 // After testing the flood control we can increase the limit.
145 $this->config('user.flood')
146 ->set('user_limit', 100)
149 $response = $this->loginRequest(NULL, NULL, $format);
150 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.', $format);
152 $response = $this->loginRequest(NULL, $pass, $format);
153 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.name.', $format);
155 $response = $this->loginRequest($name, NULL, $format);
156 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.pass.', $format);
163 $response = $this->loginRequest($name, $pass, $format);
164 $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
170 $response = $this->loginRequest($name, 'garbage', $format);
171 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
173 $response = $this->loginRequest('garbage', $pass, $format);
174 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
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'];
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));
189 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
190 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
192 $response = $this->logoutRequest($format, $logout_token);
193 $this->assertEquals(204, $response->getStatusCode());
195 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
196 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
202 * Executes a password HTTP request.
204 * @param array $request_body
206 * @param string $format
207 * The format to use to make the request.
209 * @return \Psr\Http\Message\ResponseInterface
212 protected function passwordRequest(array $request_body, $format = 'json') {
213 $password_reset_url = Url::fromRoute('user.pass.http')
214 ->setRouteParameter('_format', $format)
217 $result = \Drupal::httpClient()->post($password_reset_url->toString(), [
218 'body' => $this->serializer->encode($request_body, $format),
220 'Accept' => "application/$format",
222 'http_errors' => FALSE,
223 'cookies' => $this->cookies,
230 * Tests user password reset.
232 public function testPasswordReset() {
233 // Create a user account.
234 $account = $this->drupalCreateUser();
236 // Without the serialization module only JSON is supported.
237 $this->doTestPasswordReset('json', $account);
239 // Enable serialization so we have access to additional formats.
240 $this->container->get('module_installer')->install(['serialization']);
242 $this->doTestPasswordReset('json', $account);
243 $this->doTestPasswordReset('xml', $account);
244 $this->doTestPasswordReset('hal_json', $account);
248 * Gets a value for a given key from the response.
250 * @param \Psr\Http\Message\ResponseInterface $response
251 * The response object.
253 * The key for the value.
254 * @param string $format
255 * The encoded format.
258 * The value for the key.
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];
266 return $decoded->{$key};
271 * Resets all flood entries.
273 protected function resetFlood() {
274 $this->container->get('database')->delete(DatabaseBackend::TABLE_NAME)->execute();
278 * Tests the global login flood control.
280 * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testGlobalLoginFloodControl
281 * @see \Drupal\user\Tests\UserLoginTest::testGlobalLoginFloodControl
283 public function testGlobalLoginFloodControl() {
284 $this->config('user.flood')
286 // Set a high per-user limit out so that it is not relevant in the test.
287 ->set('user_limit', 4000)
290 $user = $this->drupalCreateUser([]);
291 $incorrect_user = clone $user;
292 $incorrect_user->passRaw .= 'incorrect';
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());
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.');
306 * Checks a response for status code and body.
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.
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());
321 * Checks a response for status code and message.
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.
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));
338 * Test the per-user login flood control.
340 * @see \Drupal\user\Tests\UserLoginTest::testPerUserLoginFloodControl
341 * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testPerUserLoginFloodControl
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)
352 $user1 = $this->drupalCreateUser([]);
353 $incorrect_user1 = clone $user1;
354 $incorrect_user1->passRaw .= 'incorrect';
356 $user2 = $this->drupalCreateUser([]);
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.');
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']);
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.');
375 // Try one successful attempt for user 2, it should not trigger any
377 $this->drupalLogin($user2);
378 $this->drupalLogout();
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.';
388 $excepted_message = 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.';
390 $this->assertHttpResponseWithMessage($response, 403, $excepted_message);
396 * Executes a logout HTTP request.
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.
403 * @return \Psr\Http\Message\ResponseInterface
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)
413 $user_logout_url->setOption('query', ['token' => $logout_token]);
417 'Accept' => "application/$format",
419 'http_errors' => FALSE,
420 'cookies' => $this->cookies,
423 $response = $client->post($user_logout_url->toString(), $post_options);
428 * Test csrf protection of User Logout route.
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;
437 $response = $this->loginRequest($name, $pass);
438 $this->assertEquals(200, $response->getStatusCode());
439 $result_data = $this->serializer->decode($response->getBody(), 'json');
441 $logout_token = $result_data['logout_token'];
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
446 $response = $this->logoutRequest('json');
447 $this->assertEquals(403, $response->getStatusCode());
449 // Ensure still logged in.
450 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
451 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
453 // Try with an incorrect token.
454 $response = $this->logoutRequest('json', 'not-the-correct-token');
455 $this->assertEquals(403, $response->getStatusCode());
457 // Ensure still logged in.
458 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
459 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
461 // Try a logout request with correct token.
462 $response = $this->logoutRequest('json', $logout_token);
463 $this->assertEquals(204, $response->getStatusCode());
465 // Ensure actually logged out.
466 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
467 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
471 * Gets the URL string for checking login.
473 * @param string $format
474 * The format to use to make the request.
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();
487 * Do password reset testing for given format and account.
489 * @param string $format
490 * Serialization format.
491 * @param \Drupal\user\UserInterface $account
494 protected function doTestPasswordReset($format, $account) {
495 $response = $this->passwordRequest([], $format);
496 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.name or credentials.mail', $format);
498 $response = $this->passwordRequest(['name' => 'dramallama'], $format);
499 $this->assertHttpResponseWithMessage($response, 400, 'Unrecognized username or email address.', $format);
501 $response = $this->passwordRequest(['mail' => 'llama@drupal.org'], $format);
502 $this->assertHttpResponseWithMessage($response, 400, 'Unrecognized username or email address.', $format);
508 $response = $this->passwordRequest(['name' => $account->getAccountName()], $format);
509 $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
511 $response = $this->passwordRequest(['mail' => $account->getEmail()], $format);
512 $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
518 $response = $this->passwordRequest(['name' => $account->getAccountName()], $format);
519 $this->assertEquals(200, $response->getStatusCode());
520 $this->loginFromResetEmail();
521 $this->drupalLogout();
523 $response = $this->passwordRequest(['mail' => $account->getEmail()], $format);
524 $this->assertEquals(200, $response->getStatusCode());
525 $this->loginFromResetEmail();
526 $this->drupalLogout();