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 $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'];
104 // Without the serialization module only JSON is supported.
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
111 $account = $this->drupalCreateUser(['administer users']);
112 $name = $account->getUsername();
113 $pass = $account->passRaw;
115 $login_status_url = $this->getLoginStatusUrlString($format);
116 $response = $client->get($login_status_url);
117 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
120 $this->config('user.flood')
121 ->set('user_limit', 3)
124 $response = $this->loginRequest($name, 'wrong-pass', $format);
125 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
127 $response = $this->loginRequest($name, 'wrong-pass', $format);
128 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
130 $response = $this->loginRequest($name, 'wrong-pass', $format);
131 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
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);
136 // After testing the flood control we can increase the limit.
137 $this->config('user.flood')
138 ->set('user_limit', 100)
141 $response = $this->loginRequest(NULL, NULL, $format);
142 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.', $format);
144 $response = $this->loginRequest(NULL, $pass, $format);
145 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.name.', $format);
147 $response = $this->loginRequest($name, NULL, $format);
148 $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.pass.', $format);
155 $response = $this->loginRequest($name, $pass, $format);
156 $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
162 $response = $this->loginRequest($name, 'garbage', $format);
163 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
165 $response = $this->loginRequest('garbage', $pass, $format);
166 $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
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'];
176 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
177 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
179 $response = $this->logoutRequest($format, $logout_token);
180 $this->assertEquals(204, $response->getStatusCode());
182 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
183 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
191 * Gets a value for a given key from the response.
193 * @param \Psr\Http\Message\ResponseInterface $response
194 * The response object.
196 * The key for the value.
197 * @param string $format
198 * The encoded format.
201 * The value for the key.
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];
209 return $decoded->{$key};
214 * Resets all flood entries.
216 protected function resetFlood() {
217 $this->container->get('database')->delete(DatabaseBackend::TABLE_NAME)->execute();
221 * Tests the global login flood control.
223 * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testGlobalLoginFloodControl
224 * @see \Drupal\user\Tests\UserLoginTest::testGlobalLoginFloodControl
226 public function testGlobalLoginFloodControl() {
227 $this->config('user.flood')
229 // Set a high per-user limit out so that it is not relevant in the test.
230 ->set('user_limit', 4000)
233 $user = $this->drupalCreateUser([]);
234 $incorrect_user = clone $user;
235 $incorrect_user->passRaw .= 'incorrect';
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());
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.');
249 * Checks a response for status code and body.
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.
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());
264 * Checks a response for status code and message.
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.
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));
281 * Test the per-user login flood control.
283 * @see \Drupal\user\Tests\UserLoginTest::testPerUserLoginFloodControl
284 * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testPerUserLoginFloodControl
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)
295 $user1 = $this->drupalCreateUser([]);
296 $incorrect_user1 = clone $user1;
297 $incorrect_user1->passRaw .= 'incorrect';
299 $user2 = $this->drupalCreateUser([]);
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.');
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']);
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.');
318 // Try one successful attempt for user 2, it should not trigger any
320 $this->drupalLogin($user2);
321 $this->drupalLogout();
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.';
331 $excepted_message = 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.';
333 $this->assertHttpResponseWithMessage($response, 403, $excepted_message);
339 * Executes a logout HTTP request.
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.
346 * @return \Psr\Http\Message\ResponseInterface The HTTP response.
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)
356 $user_logout_url->setOption('query', ['token' => $logout_token]);
360 'Accept' => "application/$format",
362 'http_errors' => FALSE,
363 'cookies' => $this->cookies,
366 $response = $client->post($user_logout_url->toString(), $post_options);
371 * Test csrf protection of User Logout route.
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;
380 $response = $this->loginRequest($name, $pass);
381 $this->assertEquals(200, $response->getStatusCode());
382 $result_data = $this->serializer->decode($response->getBody(), 'json');
384 $logout_token = $result_data['logout_token'];
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
389 $response = $this->logoutRequest('json');
390 $this->assertEquals(403, $response->getStatusCode());
392 // Ensure still logged in.
393 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
394 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
396 // Try with an incorrect token.
397 $response = $this->logoutRequest('json', 'not-the-correct-token');
398 $this->assertEquals(403, $response->getStatusCode());
400 // Ensure still logged in.
401 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
402 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
404 // Try a logout request with correct token.
405 $response = $this->logoutRequest('json', $logout_token);
406 $this->assertEquals(204, $response->getStatusCode());
408 // Ensure actually logged out.
409 $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
410 $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
414 * Gets the URL string for checking login.
416 * @param string $format
417 * The format to use to make the request.
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();