summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAlex Pott2016-07-28 17:54:03 (GMT)
committerAlex Pott2016-07-28 17:54:03 (GMT)
commit27df05cd811c7e0667b780d656c82eb65e7888e3 (patch)
tree3b15867303c36183a465b0b5bf0bc11317852e42
parent1e770ce9f11fe1f54f61b7abd38df5e9fa00296d (diff)
Issue #2403307 by dawehner, marthinal, tedbow, clemens.tolboom, Wim Leers, neclimdul, Crell, klausi, andypost, e0ipso: RPC endpoints for user authentication: log in, check login status, log out
-rw-r--r--core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php11
-rw-r--r--core/modules/serialization/serialization.services.yml10
-rw-r--r--core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php10
-rw-r--r--core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php72
-rw-r--r--core/modules/user/src/Controller/UserAuthenticationController.php345
-rw-r--r--core/modules/user/tests/src/Functional/UserLoginHttpTest.php421
-rw-r--r--core/modules/user/user.routing.yml28
7 files changed, 897 insertions, 0 deletions
diff --git a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php
index 01f3620..34eda1f 100644
--- a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php
+++ b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php
@@ -28,6 +28,17 @@ class ExceptionJsonSubscriber extends HttpExceptionSubscriberBase {
}
/**
+ * Handles a 400 error for JSON.
+ *
+ * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event
+ * The event to process.
+ */
+ public function on400(GetResponseForExceptionEvent $event) {
+ $response = new JsonResponse(array('message' => $event->getException()->getMessage()), Response::HTTP_BAD_REQUEST);
+ $event->setResponse($response);
+ }
+
+ /**
* Handles a 403 error for JSON.
*
* @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event
diff --git a/core/modules/serialization/serialization.services.yml b/core/modules/serialization/serialization.services.yml
index c510ab1..8b570c0 100644
--- a/core/modules/serialization/serialization.services.yml
+++ b/core/modules/serialization/serialization.services.yml
@@ -64,3 +64,13 @@ services:
class: Drupal\serialization\EntityResolver\TargetIdResolver
tags:
- { name: entity_resolver}
+ serialization.exception.default:
+ class: Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber
+ tags:
+ - { name: event_subscriber }
+ arguments: ['@serializer', '%serializer.formats%']
+ serialization.user_route_alter_subscriber:
+ class: Drupal\serialization\EventSubscriber\UserRouteAlterSubscriber
+ tags:
+ - { name: event_subscriber }
+ arguments: ['@serializer', '%serializer.formats%']
diff --git a/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php b/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php
index 753d3c1..ba91836 100644
--- a/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php
+++ b/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php
@@ -116,6 +116,16 @@ class DefaultExceptionSubscriber extends HttpExceptionSubscriberBase {
}
/**
+ * Handles a 429 error for HTTP.
+ *
+ * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event
+ * The event to process.
+ */
+ public function on429(GetResponseForExceptionEvent $event) {
+ $this->setEventResponse($event, Response::HTTP_TOO_MANY_REQUESTS);
+ }
+
+ /**
* Sets the Response for the exception event.
*
* @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event
diff --git a/core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php b/core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php
new file mode 100644
index 0000000..a8b71c7
--- /dev/null
+++ b/core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\serialization\EventSubscriber;
+
+use Drupal\Core\Routing\RouteBuildEvent;
+use Drupal\Core\Routing\RoutingEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * Alters user authentication routes to support additional serialization formats.
+ */
+class UserRouteAlterSubscriber implements EventSubscriberInterface {
+
+ /**
+ * The serializer.
+ *
+ * @var \Symfony\Component\Serializer\Serializer
+ */
+ protected $serializer;
+
+ /**
+ * The available serialization formats.
+ *
+ * @var array
+ */
+ protected $serializerFormats = [];
+
+ /**
+ * UserRouteAlterSubscriber constructor.
+ *
+ * @param \Symfony\Component\Serializer\SerializerInterface $serializer
+ * The serializer service.
+ * @param array $serializer_formats
+ * The available serializer formats.
+ */
+ public function __construct(SerializerInterface $serializer, array $serializer_formats) {
+ $this->serializer = $serializer;
+ $this->serializerFormats = $serializer_formats;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getSubscribedEvents() {
+ $events[RoutingEvents::ALTER][] = 'onRoutingAlterAddFormats';
+ return $events;
+ }
+
+ /**
+ * Adds supported formats to the user authentication HTTP routes.
+ *
+ * @param \Drupal\Core\Routing\RouteBuildEvent $event
+ * The event to process.
+ */
+ public function onRoutingAlterAddFormats(RouteBuildEvent $event) {
+ $route_names = [
+ 'user.login_status.http',
+ 'user.login.http',
+ 'user.logout.http',
+ ];
+ $routes = $event->getRouteCollection();
+ foreach ($route_names as $route_name) {
+ if ($route = $routes->get($route_name)) {
+ $formats = explode('|', $route->getRequirement('_format'));
+ $formats = array_unique($formats + $this->serializerFormats);
+ $route->setRequirement('_format', implode('|', $formats));
+ }
+ }
+ }
+
+}
diff --git a/core/modules/user/src/Controller/UserAuthenticationController.php b/core/modules/user/src/Controller/UserAuthenticationController.php
new file mode 100644
index 0000000..569a121
--- /dev/null
+++ b/core/modules/user/src/Controller/UserAuthenticationController.php
@@ -0,0 +1,345 @@
+<?php
+
+namespace Drupal\user\Controller;
+
+use Drupal\Core\Access\CsrfTokenGenerator;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Flood\FloodInterface;
+use Drupal\Core\Routing\RouteProviderInterface;
+use Drupal\user\UserAuthInterface;
+use Drupal\user\UserInterface;
+use Drupal\user\UserStorageInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\Serializer\Encoder\JsonEncoder;
+use Symfony\Component\Serializer\Serializer;
+
+/**
+ * Provides controllers for login, login status and logout via HTTP requests.
+ */
+class UserAuthenticationController extends ControllerBase implements ContainerInjectionInterface {
+
+ /**
+ * String sent in responses, to describe the user as being logged in.
+ *
+ * @var string
+ */
+ const LOGGED_IN = 1;
+
+ /**
+ * String sent in responses, to describe the user as being logged out.
+ *
+ * @var string
+ */
+ const LOGGED_OUT = 0;
+
+ /**
+ * The flood controller.
+ *
+ * @var \Drupal\Core\Flood\FloodInterface
+ */
+ protected $flood;
+
+ /**
+ * The user storage.
+ *
+ * @var \Drupal\user\UserStorageInterface
+ */
+ protected $userStorage;
+
+ /**
+ * The CSRF token generator.
+ *
+ * @var \Drupal\Core\Access\CsrfTokenGenerator
+ */
+ protected $csrfToken;
+
+ /**
+ * The user authentication.
+ *
+ * @var \Drupal\user\UserAuthInterface
+ */
+ protected $userAuth;
+
+ /**
+ * The route provider.
+ *
+ * @var \Drupal\Core\Routing\RouteProviderInterface
+ */
+ protected $routeProvider;
+
+ /**
+ * The serializer.
+ *
+ * @var \Symfony\Component\Serializer\Serializer
+ */
+ protected $serializer;
+
+ /**
+ * The available serialization formats.
+ *
+ * @var array
+ */
+ protected $serializerFormats = [];
+
+ /**
+ * Constructs a new UserAuthenticationController object.
+ *
+ * @param \Drupal\Core\Flood\FloodInterface $flood
+ * The flood controller.
+ * @param \Drupal\user\UserStorageInterface $user_storage
+ * The user storage.
+ * @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token
+ * The CSRF token generator.
+ * @param \Drupal\user\UserAuthInterface $user_auth
+ * The user authentication.
+ * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
+ * The route provider.
+ * @param \Symfony\Component\Serializer\Serializer $serializer
+ * The serializer.
+ * @param array $serializer_formats
+ * The available serialization formats.
+ */
+ public function __construct(FloodInterface $flood, UserStorageInterface $user_storage, CsrfTokenGenerator $csrf_token, UserAuthInterface $user_auth, RouteProviderInterface $route_provider, Serializer $serializer, array $serializer_formats) {
+ $this->flood = $flood;
+ $this->userStorage = $user_storage;
+ $this->csrfToken = $csrf_token;
+ $this->userAuth = $user_auth;
+ $this->serializer = $serializer;
+ $this->serializerFormats = $serializer_formats;
+ $this->routeProvider = $route_provider;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ if ($container->hasParameter('serializer.formats') && $container->has('serializer')) {
+ $serializer = $container->get('serializer');
+ $formats = $container->getParameter('serializer.formats');
+ }
+ else {
+ $formats = ['json'];
+ $encoders = [new JsonEncoder()];
+ $serializer = new Serializer([], $encoders);
+ }
+
+ return new static(
+ $container->get('flood'),
+ $container->get('entity_type.manager')->getStorage('user'),
+ $container->get('csrf_token'),
+ $container->get('user.auth'),
+ $container->get('router.route_provider'),
+ $serializer,
+ $formats
+ );
+ }
+
+ /**
+ * Logs in a user.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request.
+ *
+ * @return \Symfony\Component\HttpFoundation\Response
+ * A response which contains the ID and CSRF token.
+ */
+ public function login(Request $request) {
+ $format = $this->getRequestFormat($request);
+
+ $content = $request->getContent();
+ $credentials = $this->serializer->decode($content, $format);
+ if (!isset($credentials['name']) && !isset($credentials['pass'])) {
+ throw new BadRequestHttpException('Missing credentials.');
+ }
+
+ if (!isset($credentials['name'])) {
+ throw new BadRequestHttpException('Missing credentials.name.');
+ }
+ if (!isset($credentials['pass'])) {
+ throw new BadRequestHttpException('Missing credentials.pass.');
+ }
+
+ $this->floodControl($request, $credentials['name']);
+
+ if ($this->userIsBlocked($credentials['name'])) {
+ throw new BadRequestHttpException('The user has not been activated or is blocked.');
+ }
+
+ if ($uid = $this->userAuth->authenticate($credentials['name'], $credentials['pass'])) {
+ $this->flood->clear('user.http_login', $this->getLoginFloodIdentifier($request, $credentials['name']));
+ /** @var \Drupal\user\UserInterface $user */
+ $user = $this->userStorage->load($uid);
+ $this->userLoginFinalize($user);
+
+ // Send basic metadata about the logged in user.
+ $response_data = [];
+ if ($user->get('uid')->access('view', $user)) {
+ $response_data['current_user']['uid'] = $user->id();
+ }
+ if ($user->get('roles')->access('view', $user)) {
+ $response_data['current_user']['roles'] = $user->getRoles();
+ }
+ if ($user->get('name')->access('view', $user)) {
+ $response_data['current_user']['name'] = $user->getAccountName();
+ }
+ $response_data['csrf_token'] = $this->csrfToken->get('rest');
+
+ $logout_route = $this->routeProvider->getRouteByName('user.logout.http');
+ // Trim '/' off path to match \Drupal\Core\Access\CsrfAccessCheck.
+ $logout_path = ltrim($logout_route->getPath(), '/');
+ $response_data['logout_token'] = $this->csrfToken->get($logout_path);
+
+ $encoded_response_data = $this->serializer->encode($response_data, $format);
+ return new Response($encoded_response_data);
+ }
+
+ $flood_config = $this->config('user.flood');
+ if ($identifier = $this->getLoginFloodIdentifier($request, $credentials['name'])) {
+ $this->flood->register('user.http_login', $flood_config->get('user_window'), $identifier);
+ }
+ // Always register an IP-based failed login event.
+ $this->flood->register('user.failed_login_ip', $flood_config->get('ip_window'));
+ throw new BadRequestHttpException('Sorry, unrecognized username or password.');
+ }
+
+ /**
+ * Verifies if the user is blocked.
+ *
+ * @param string $name
+ * The username.
+ *
+ * @return bool
+ * TRUE if the user is blocked, otherwise FALSE.
+ */
+ protected function userIsBlocked($name) {
+ return user_is_blocked($name);
+ }
+
+ /**
+ * Finalizes the user login.
+ *
+ * @param \Drupal\user\UserInterface $user
+ * The user.
+ */
+ protected function userLoginFinalize(UserInterface $user) {
+ user_login_finalize($user);
+ }
+
+ /**
+ * Logs out a user.
+ *
+ * @return \Drupal\rest\ResourceResponse
+ * The response object.
+ */
+ public function logout() {
+ $this->userLogout();
+ return new Response(NULL, 204);
+ }
+
+ /**
+ * Logs the user out.
+ */
+ protected function userLogout() {
+ user_logout();
+ }
+
+ /**
+ * Checks whether a user is logged in or not.
+ *
+ * @return \Symfony\Component\HttpFoundation\Response
+ * The response.
+ */
+ public function loginStatus() {
+ if ($this->currentUser()->isAuthenticated()) {
+ $response = new Response(self::LOGGED_IN);
+ }
+ else {
+ $response = new Response(self::LOGGED_OUT);
+ }
+ $response->headers->set('Content-Type', 'text/plain');
+ return $response;
+ }
+
+ /**
+ * Gets the format of the current request.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The current request.
+ *
+ * @return string
+ * The format of the request.
+ */
+ protected function getRequestFormat(Request $request) {
+ $format = $request->getRequestFormat();
+ if (!in_array($format, $this->serializerFormats)) {
+ throw new BadRequestHttpException("Unrecognized format: $format.");
+ }
+ return $format;
+ }
+
+ /**
+ * Enforces flood control for the current login request.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The current request.
+ * @param string $username
+ * The user name sent for login credentials.
+ */
+ protected function floodControl(Request $request, $username) {
+ $flood_config = $this->config('user.flood');
+ if (!$this->flood->isAllowed('user.failed_login_ip', $flood_config->get('ip_limit'), $flood_config->get('ip_window'))) {
+ throw new AccessDeniedHttpException('Access is blocked because of IP based flood prevention.', NULL, Response::HTTP_TOO_MANY_REQUESTS);
+ }
+
+ if ($identifier = $this->getLoginFloodIdentifier($request, $username)) {
+ // Don't allow login if the limit for this user has been reached.
+ // Default is to allow 5 failed attempts every 6 hours.
+ if (!$this->flood->isAllowed('user.http_login', $flood_config->get('user_limit'), $flood_config->get('user_window'), $identifier)) {
+ if ($flood_config->get('uid_only')) {
+ $error_message = sprintf('There have been more than %s failed login attempts for this account. It is temporarily blocked. Try again later or request a new password.', $flood_config->get('user_limit'));
+ }
+ else {
+ $error_message = 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.';
+ }
+ throw new AccessDeniedHttpException($error_message, NULL, Response::HTTP_TOO_MANY_REQUESTS);
+ }
+ }
+ }
+
+ /**
+ * Gets the login identifier for user login flood control.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The current request.
+ * @param string $username
+ * The username supplied in login credentials.
+ *
+ * @return string
+ * The login identifier or if the user does not exist an empty string.
+ */
+ protected function getLoginFloodIdentifier(Request $request, $username) {
+ $flood_config = $this->config('user.flood');
+ $accounts = $this->userStorage->loadByProperties(['name' => $username, 'status' => 1]);
+ if ($account = reset($accounts)) {
+ if ($flood_config->get('uid_only')) {
+ // Register flood events based on the uid only, so they apply for any
+ // IP address. This is the most secure option.
+ $identifier = $account->id();
+ }
+ else {
+ // The default identifier is a combination of uid and IP address. This
+ // is less secure but more resistant to denial-of-service attacks that
+ // could lock out all users with public user names.
+ $identifier = $account->id() . '-' . $request->getClientIp();
+ }
+ return $identifier;
+ }
+ return '';
+ }
+
+}
diff --git a/core/modules/user/tests/src/Functional/UserLoginHttpTest.php b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php
new file mode 100644
index 0000000..3565441
--- /dev/null
+++ b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php
@@ -0,0 +1,421 @@
+<?php
+
+namespace Drupal\Tests\user\Functional;
+
+use Drupal\Core\Flood\DatabaseBackend;
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Controller\UserAuthenticationController;
+use GuzzleHttp\Cookie\CookieJar;
+use Psr\Http\Message\ResponseInterface;
+use Symfony\Component\Serializer\Encoder\JsonEncoder;
+use Symfony\Component\Serializer\Encoder\XmlEncoder;
+use Symfony\Component\Serializer\Serializer;
+
+/**
+ * Tests login via direct HTTP.
+ *
+ * @group user
+ */
+class UserLoginHttpTest extends BrowserTestBase {
+
+ /**
+ * The cookie jar.
+ *
+ * @var \GuzzleHttp\Cookie\CookieJar
+ */
+ protected $cookies;
+
+ /**
+ * The serializer.
+ *
+ * @var \Symfony\Component\Serializer\Serializer
+ */
+ protected $serializer;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->cookies = new CookieJar();
+ $encoders = [new JsonEncoder(), new XmlEncoder()];
+ $this->serializer = new Serializer([], $encoders);
+ }
+
+ /**
+ * Executes a login HTTP request.
+ *
+ * @param string $name
+ * The username.
+ * @param string $pass
+ * The user password.
+ * @param string $format
+ * The format to use to make the request.
+ *
+ * @return \Psr\Http\Message\ResponseInterface The HTTP response.
+ * The HTTP response.
+ */
+ protected function loginRequest($name, $pass, $format = 'json') {
+ $user_login_url = Url::fromRoute('user.login.http')
+ ->setRouteParameter('_format', $format)
+ ->setAbsolute();
+
+ $request_body = [];
+ if (isset($name)) {
+ $request_body['name'] = $name;
+ }
+ if (isset($pass)) {
+ $request_body['pass'] = $pass;
+ }
+
+ $result = \Drupal::httpClient()->post($user_login_url->toString(), [
+ 'body' => $this->serializer->encode($request_body, $format),
+ 'headers' => [
+ 'Accept' => "application/$format",
+ ],
+ 'http_errors' => FALSE,
+ 'cookies' => $this->cookies,
+ ]);
+ return $result;
+ }
+
+ /**
+ * Tests user session life cycle.
+ */
+ public function testLogin() {
+ $client = \Drupal::httpClient();
+ foreach ([FALSE, TRUE] as $serialization_enabled_option) {
+ if ($serialization_enabled_option) {
+ /** @var \Drupal\Core\Extension\ModuleInstaller $module_installer */
+ $module_installer = $this->container->get('module_installer');
+ $module_installer->install(['serialization']);
+ $formats = ['json', 'xml'];
+ }
+ else {
+ // Without the serialization module only JSON is supported.
+ $formats = ['json'];
+ }
+ foreach ($formats as $format) {
+ // Create new user for each iteration to reset flood.
+ // Grant the user administer users permissions to they can see the
+ // 'roles' field.
+ $account = $this->drupalCreateUser(['administer users']);
+ $name = $account->getUsername();
+ $pass = $account->passRaw;
+
+ $login_status_url = $this->getLoginStatusUrlString($format);
+ $response = $client->get($login_status_url);
+ $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
+
+ // Flooded.
+ $this->config('user.flood')
+ ->set('user_limit', 3)
+ ->save();
+
+ $response = $this->loginRequest($name, 'wrong-pass', $format);
+ $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
+
+ $response = $this->loginRequest($name, 'wrong-pass', $format);
+ $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
+
+ $response = $this->loginRequest($name, 'wrong-pass', $format);
+ $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
+
+ $response = $this->loginRequest($name, 'wrong-pass', $format);
+ $this->assertHttpResponseWithMessage($response, 403, 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.', $format);
+
+ // After testing the flood control we can increase the limit.
+ $this->config('user.flood')
+ ->set('user_limit', 100)
+ ->save();
+
+ $response = $this->loginRequest(NULL, NULL, $format);
+ $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.', $format);
+
+ $response = $this->loginRequest(NULL, $pass, $format);
+ $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.name.', $format);
+
+ $response = $this->loginRequest($name, NULL, $format);
+ $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.pass.', $format);
+
+ // Blocked.
+ $account
+ ->block()
+ ->save();
+
+ $response = $this->loginRequest($name, $pass, $format);
+ $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format);
+
+ $account
+ ->activate()
+ ->save();
+
+ $response = $this->loginRequest($name, 'garbage', $format);
+ $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
+
+ $response = $this->loginRequest('garbage', $pass, $format);
+ $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.', $format);
+
+ $response = $this->loginRequest($name, $pass, $format);
+ $this->assertEquals(200, $response->getStatusCode());
+ $result_data = $this->serializer->decode($response->getBody(), $format);
+ $this->assertEquals($name, $result_data['current_user']['name']);
+ $this->assertEquals($account->id(), $result_data['current_user']['uid']);
+ $this->assertEquals($account->getRoles(), $result_data['current_user']['roles']);
+ $logout_token = $result_data['logout_token'];
+
+ $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
+ $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
+
+ $response = $this->logoutRequest($format, $logout_token);
+ $this->assertEquals(204, $response->getStatusCode());
+
+ $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
+ $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
+
+ $this->resetFlood();
+ }
+ }
+ }
+
+ /**
+ * Gets a value for a given key from the response.
+ *
+ * @param \Psr\Http\Message\ResponseInterface $response
+ * The response object.
+ * @param string $key
+ * The key for the value.
+ * @param string $format
+ * The encoded format.
+ *
+ * @return mixed
+ * The value for the key.
+ */
+ protected function getResultValue(ResponseInterface $response, $key, $format) {
+ $decoded = $this->serializer->decode((string) $response->getBody(), $format);
+ if (is_array($decoded)) {
+ return $decoded[$key];
+ }
+ else {
+ return $decoded->{$key};
+ }
+ }
+
+ /**
+ * Resets all flood entries.
+ */
+ protected function resetFlood() {
+ $this->container->get('database')->delete(DatabaseBackend::TABLE_NAME)->execute();
+ }
+
+ /**
+ * Tests the global login flood control.
+ *
+ * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testGlobalLoginFloodControl
+ * @see \Drupal\user\Tests\UserLoginTest::testGlobalLoginFloodControl
+ */
+ public function testGlobalLoginFloodControl() {
+ $this->config('user.flood')
+ ->set('ip_limit', 2)
+ // Set a high per-user limit out so that it is not relevant in the test.
+ ->set('user_limit', 4000)
+ ->save();
+
+ $user = $this->drupalCreateUser([]);
+ $incorrect_user = clone $user;
+ $incorrect_user->passRaw .= 'incorrect';
+
+ // Try 2 failed logins.
+ for ($i = 0; $i < 2; $i++) {
+ $response = $this->loginRequest($incorrect_user->getUsername(), $incorrect_user->passRaw);
+ $this->assertEquals('400', $response->getStatusCode());
+ }
+
+ // IP limit has reached to its limit. Even valid user credentials will fail.
+ $response = $this->loginRequest($user->getUsername(), $user->passRaw);
+ $this->assertHttpResponseWithMessage($response, '403', 'Access is blocked because of IP based flood prevention.');
+ }
+
+ /**
+ * Checks a response for status code and body.
+ *
+ * @param \Psr\Http\Message\ResponseInterface $response
+ * The response object.
+ * @param int $expected_code
+ * The expected status code.
+ * @param mixed $expected_body
+ * The expected response body.
+ */
+ protected function assertHttpResponse(ResponseInterface $response, $expected_code, $expected_body) {
+ $this->assertEquals($expected_code, $response->getStatusCode());
+ $this->assertEquals($expected_body, (string) $response->getBody());
+ }
+
+ /**
+ * Checks a response for status code and message.
+ *
+ * @param \Psr\Http\Message\ResponseInterface $response
+ * The response object.
+ * @param int $expected_code
+ * The expected status code.
+ * @param string $expected_message
+ * The expected message encoded in response.
+ * @param string $format
+ * The format that the response is encoded in.
+ */
+ protected function assertHttpResponseWithMessage(ResponseInterface $response, $expected_code, $expected_message, $format = 'json') {
+ $this->assertEquals($expected_code, $response->getStatusCode());
+ $this->assertEquals($expected_message, $this->getResultValue($response, 'message', $format));
+ }
+
+ /**
+ * Test the per-user login flood control.
+ *
+ * @see \Drupal\user\Tests\UserLoginTest::testPerUserLoginFloodControl
+ * @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testPerUserLoginFloodControl
+ */
+ public function testPerUserLoginFloodControl() {
+ foreach ([TRUE, FALSE] as $uid_only_setting) {
+ $this->config('user.flood')
+ // Set a high global limit out so that it is not relevant in the test.
+ ->set('ip_limit', 4000)
+ ->set('user_limit', 3)
+ ->set('uid_only', $uid_only_setting)
+ ->save();
+
+ $user1 = $this->drupalCreateUser([]);
+ $incorrect_user1 = clone $user1;
+ $incorrect_user1->passRaw .= 'incorrect';
+
+ $user2 = $this->drupalCreateUser([]);
+
+ // Try 2 failed logins.
+ for ($i = 0; $i < 2; $i++) {
+ $response = $this->loginRequest($incorrect_user1->getUsername(), $incorrect_user1->passRaw);
+ $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.');
+ }
+
+ // A successful login will reset the per-user flood control count.
+ $response = $this->loginRequest($user1->getUsername(), $user1->passRaw);
+ $result_data = $this->serializer->decode($response->getBody(), 'json');
+ $this->logoutRequest('json', $result_data['logout_token']);
+
+ // Try 3 failed logins for user 1, they will not trigger flood control.
+ for ($i = 0; $i < 3; $i++) {
+ $response = $this->loginRequest($incorrect_user1->getUsername(), $incorrect_user1->passRaw);
+ $this->assertHttpResponseWithMessage($response, 400, 'Sorry, unrecognized username or password.');
+ }
+
+ // Try one successful attempt for user 2, it should not trigger any
+ // flood control.
+ $this->drupalLogin($user2);
+ $this->drupalLogout();
+
+ // Try one more attempt for user 1, it should be rejected, even if the
+ // correct password has been used.
+ $response = $this->loginRequest($user1->getUsername(), $user1->passRaw);
+ // Depending on the uid_only setting the error message will be different.
+ if ($uid_only_setting) {
+ $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.';
+ }
+ else {
+ $excepted_message = 'Too many failed login attempts from your IP address. This IP address is temporarily blocked.';
+ }
+ $this->assertHttpResponseWithMessage($response, 403, $excepted_message);
+ }
+
+ }
+
+ /**
+ * Executes a logout HTTP request.
+ *
+ * @param string $format
+ * The format to use to make the request.
+ * @param string $logout_token
+ * The csrf token for user logout.
+ *
+ * @return \Psr\Http\Message\ResponseInterface The HTTP response.
+ * The HTTP response.
+ */
+ protected function logoutRequest($format = 'json', $logout_token = '') {
+ /** @var \GuzzleHttp\Client $client */
+ $client = $this->container->get('http_client');
+ $user_logout_url = Url::fromRoute('user.logout.http')
+ ->setRouteParameter('_format', $format)
+ ->setAbsolute();
+ if ($logout_token) {
+ $user_logout_url->setOption('query', ['token' => $logout_token]);
+ }
+ $post_options = [
+ 'headers' => [
+ 'Accept' => "application/$format",
+ ],
+ 'http_errors' => FALSE,
+ 'cookies' => $this->cookies,
+ ];
+
+ $response = $client->post($user_logout_url->toString(), $post_options);
+ return $response;
+ }
+
+ /**
+ * Test csrf protection of User Logout route.
+ */
+ public function testLogoutCsrfProtection() {
+ $client = \Drupal::httpClient();
+ $login_status_url = $this->getLoginStatusUrlString();
+ $account = $this->drupalCreateUser();
+ $name = $account->getUsername();
+ $pass = $account->passRaw;
+
+ $response = $this->loginRequest($name, $pass);
+ $this->assertEquals(200, $response->getStatusCode());
+ $result_data = $this->serializer->decode($response->getBody(), 'json');
+
+ $logout_token = $result_data['logout_token'];
+
+ // Test third party site posting to current site with logout request.
+ // This should not logout the current user because it lacks the CSRF
+ // token.
+ $response = $this->logoutRequest('json');
+ $this->assertEquals(403, $response->getStatusCode());
+
+ // Ensure still logged in.
+ $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
+ $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
+
+ // Try with an incorrect token.
+ $response = $this->logoutRequest('json', 'not-the-correct-token');
+ $this->assertEquals(403, $response->getStatusCode());
+
+ // Ensure still logged in.
+ $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
+ $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_IN);
+
+ // Try a logout request with correct token.
+ $response = $this->logoutRequest('json', $logout_token);
+ $this->assertEquals(204, $response->getStatusCode());
+
+ // Ensure actually logged out.
+ $response = $client->get($login_status_url, ['cookies' => $this->cookies]);
+ $this->assertHttpResponse($response, 200, UserAuthenticationController::LOGGED_OUT);
+ }
+
+ /**
+ * Gets the URL string for checking login.
+ *
+ * @param string $format
+ * The format to use to make the request.
+ *
+ * @return string
+ * The URL string.
+ */
+ protected function getLoginStatusUrlString($format = 'json') {
+ $user_login_status_url = Url::fromRoute('user.login_status.http');
+ $user_login_status_url->setRouteParameter('_format', $format);
+ $user_login_status_url->setAbsolute();
+ return $user_login_status_url->toString();
+ }
+
+}
diff --git a/core/modules/user/user.routing.yml b/core/modules/user/user.routing.yml
index 6eea7ec..caea979 100644
--- a/core/modules/user/user.routing.yml
+++ b/core/modules/user/user.routing.yml
@@ -129,6 +129,34 @@ user.login:
options:
_maintenance_access: TRUE
+user.login.http:
+ path: '/user/login'
+ defaults:
+ _controller: \Drupal\user\Controller\UserAuthenticationController::login
+ methods: [POST]
+ requirements:
+ _user_is_logged_in: 'FALSE'
+ _format: 'json'
+
+user.login_status.http:
+ path: '/user/login_status'
+ defaults:
+ _controller: \Drupal\user\Controller\UserAuthenticationController::loginStatus
+ methods: [GET]
+ requirements:
+ _access: 'TRUE'
+ _format: 'json'
+
+user.logout.http:
+ path: '/user/logout'
+ defaults:
+ _controller: \Drupal\user\Controller\UserAuthenticationController::logout
+ methods: [POST]
+ requirements:
+ _user_is_logged_in: 'TRUE'
+ _format: 'json'
+ _csrf_token: 'TRUE'
+
user.cancel_confirm:
path: '/user/{user}/cancel/confirm/{timestamp}/{hashed_pass}'
defaults: