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 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;
17 * Tests login via direct HTTP.
21 class UserLoginHttpTest extends BrowserTestBase {
28 public static $modules = ['hal'];
33 * @var \GuzzleHttp\Cookie\CookieJar
40 * @var \Symfony\Component\Serializer\Serializer
42 protected $serializer;
47 protected function setUp() {
49 $this->cookies = new CookieJar();
50 $encoders = [new JsonEncoder(), new XmlEncoder(), new HALJsonEncoder()];
51 $this->serializer = new Serializer([], $encoders);
55 * Executes a login HTTP request.
61 * @param string $format
62 * The format to use to make the request.
64 * @return \Psr\Http\Message\ResponseInterface The HTTP response.
67 protected function loginRequest($name, $pass, $format = 'json') {
68 $user_login_url = Url::fromRoute('user.login.http')
69 ->setRouteParameter('_format', $format)
74 $request_body['name'] = $name;
77 $request_body['pass'] = $pass;
80 $result = \Drupal::httpClient()->post($user_login_url->toString(), [
81 'body' => $this->serializer->encode($request_body, $format),
83 'Accept' => "application/$format",
85 'http_errors' => FALSE,
86 'cookies' => $this->cookies,
92 * Tests user session life cycle.
94 public function testLogin() {
95 // Without the serialization module only JSON is supported.
96 $this->doTestLogin('json');
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');
106 * Do login testing for a given serialization format.
108 * @param string $format
109 * Serialization format.
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
116 $account = $this->drupalCreateUser(['administer users']);
117 $name = $account->getUsername();
118 $pass = $account->passRaw;
120 $login_status_url = $this->getLoginStatusUrlString($format);
121 $response = $client->get($login_status_url);
122 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
125 $this->config('user.flood')
126 ->set('user_limit', 3)
129 $response = $this->loginRequest($name, 'wrong-pass', $format);
130 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
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, 403, 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.', $format);
141 // After testing the flood control we can increase the limit.
142 $this->config('user.flood')
143 ->set('user_limit', 100)
146 $response = $this->loginRequest(NULL, NULL, $format);
147 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.', $format);
149 $response = $this->loginRequest(NULL, $pass, $format);
150 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.name.', $format);
152 $response = $this->loginRequest($name, NULL, $format);
153 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.pass.', $format);
160 $response = $this->loginRequest($name, $pass, $format);
161 $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
167 $response = $this->loginRequest($name, 'garbage', $format);
168 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
170 $response = $this->loginRequest('garbage', $pass, $format);
171 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
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'];
181 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
182 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
184 $response = $this->logoutRequest($format, $logout_token);
185 $this->assertEquals(204, $response->getStatusCode());
187 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
188 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
194 * Gets a value for a given key from the response.
196 * @param \Psr\Http\Message\ResponseInterface $response
197 * The response object.
199 * The key for the value.
200 * @param string $format
201 * The encoded format.
204 * The value for the key.
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];
212 return $decoded->{$key};
217 * Resets all flood entries.
219 protected function resetFlood() {
220 $this->container->get('database')->delete(DatabaseBackend::TABLE_NAME)->execute();
224 * Tests the global login flood control.
226 * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testGlobalLoginFloodControl
227 * @see \Drupal\user\Tests\UserLoginTest::testGlobalLoginFloodControl
229 public function testGlobalLoginFloodControl() {
230 $this->config('user.flood')
232 // Set a high per-user limit out so that it is not relevant in the test.
233 ->set('user_limit', 4000)
236 $user = $this->drupalCreateUser([]);
237 $incorrect_user = clone $user;
238 $incorrect_user->passRaw .= 'incorrect';
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());
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.');
252 * Checks a response for status code and body.
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.
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());
267 * Checks a response for status code and message.
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.
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));
284 * Test the per-user login flood control.
286 * @see \Drupal\user\Tests\UserLoginTest::testPerUserLoginFloodControl
287 * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testPerUserLoginFloodControl
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)
298 $user1 = $this->drupalCreateUser([]);
299 $incorrect_user1 = clone $user1;
300 $incorrect_user1->passRaw .= 'incorrect';
302 $user2 = $this->drupalCreateUser([]);
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.');
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']);
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.');
321 // Try one successful attempt for user 2, it should not trigger any
323 $this->drupalLogin($user2);
324 $this->drupalLogout();
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.';
334 $excepted_message = 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.';
336 $this->assertHttpResponseWithMessage($response, 403, $excepted_message);
342 * Executes a logout HTTP request.
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.
349 * @return \Psr\Http\Message\ResponseInterface The HTTP response.
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)
359 $user_logout_url->setOption('query', ['token' => $logout_token]);
363 'Accept' => "application/$format",
365 'http_errors' => FALSE,
366 'cookies' => $this->cookies,
369 $response = $client->post($user_logout_url->toString(), $post_options);
374 * Test csrf protection of User Logout route.
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;
383 $response = $this->loginRequest($name, $pass);
384 $this->assertEquals(200, $response->getStatusCode());
385 $result_data = $this->serializer->decode($response->getBody(), 'json');
387 $logout_token = $result_data['logout_token'];
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
392 $response = $this->logoutRequest('json');
393 $this->assertEquals(403, $response->getStatusCode());
395 // Ensure still logged in.
396 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
397 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
399 // Try with an incorrect token.
400 $response = $this->logoutRequest('json', 'not-the-correct-token');
401 $this->assertEquals(403, $response->getStatusCode());
403 // Ensure still logged in.
404 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
405 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
407 // Try a logout request with correct token.
408 $response = $this->logoutRequest('json', $logout_token);
409 $this->assertEquals(204, $response->getStatusCode());
411 // Ensure actually logged out.
412 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
413 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
417 * Gets the URL string for checking login.
419 * @param string $format
420 * The format to use to make the request.
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();