diff --git a/core/modules/rest/src/Plugin/ResourceBase.php b/core/modules/rest/src/Plugin/ResourceBase.php index 33cb3aa..4e77eb9 100644 --- a/core/modules/rest/src/Plugin/ResourceBase.php +++ b/core/modules/rest/src/Plugin/ResourceBase.php @@ -64,7 +64,7 @@ public static function create(ContainerInterface $container, array $configuratio $plugin_id, $plugin_definition, $container->getParameter('serializer.formats'), - $container->get('logger.factory')->get('rest') + $container->get('logger.channel.rest') ); } diff --git a/core/modules/rest/src/Plugin/rest/resource/UserLoginResource.php b/core/modules/rest/src/Plugin/rest/resource/UserLoginResource.php new file mode 100644 index 0000000..9fe24c4 --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserLoginResource.php @@ -0,0 +1,205 @@ +configFactory = $config_factory; + $this->flood = $flood; + $this->userStorage = $user_storage; + $this->csrfToken = $csrf_token; + $this->userAuth = $user_auth; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->getParameter('serializer.formats'), + $container->get('logger.channel.rest'), + $container->get('config.factory'), + $container->get('flood'), + $container->get('entity.manager')->getStorage('user'), + $container->get('csrf_token'), + $container->get('user.auth') + ); + } + + /** + * Responds to user login POST requests and logs in a user. + * + * @param array $credentials + * The login credentials. + * + * @return \Drupal\rest\ResourceResponse + * The HTTP response object. + */ + public function post($credentials) { + 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.'); + } + + if (!$this->isFloodBlocked()) { + throw new BadRequestHttpException('Blocked.'); + } + + 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'])) { + /** @var \Drupal\user\UserInterface $user */ + $user = $this->userStorage->load($uid); + $this->userLoginFinalize($user); + + // Send basic metadata about the logged in user. + $response_data = [ + 'current_user' => [ + 'uid' => $user->id(), + 'roles' => $user->getRoles(), + 'name' => $user->getAccountName(), + ], + 'csrf_token' => $this->csrfToken->get('rest'), + ]; + + $response = new ResourceResponse($response_data); + return $response->addCacheableDependency($user); + } + + $this->flood->register('rest.login_cookie', $this->configFactory->get('user.flood')->get('user_window')); + throw new BadRequestHttpException('Sorry, unrecognized username or password.'); + } + + /** + * Verifies if the user is blocked. + * + * @param string $name + * The username. + * + * @return bool + * Returns 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); + } + + /** + * Checks for flooding. + * + * @return bool + * TRUE if the user is allowed to proceed, FALSE otherwise. + */ + protected function isFloodBlocked() { + $config = $this->configFactory->get('user.flood'); + $limit = $config->get('user_limit'); + $interval = $config->get('user_window'); + + return $this->flood->isAllowed('rest.login_cookie', $limit, $interval); + } + +} diff --git a/core/modules/rest/src/Plugin/rest/resource/UserLoginStatusResource.php b/core/modules/rest/src/Plugin/rest/resource/UserLoginStatusResource.php new file mode 100644 index 0000000..97cdc49 --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserLoginStatusResource.php @@ -0,0 +1,99 @@ +currentUser = $current_user; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->getParameter('serializer.formats'), + $container->get('logger.channel.rest'), + $container->get('current_user') + ); + } + + /** + * Responds to user login status GET requests. + * + * @return \Drupal\rest\ResourceResponse + * A resource response. + */ + public function get() { + if ($this->currentUser->isAuthenticated()) { + $response = new ResourceResponse(self::LOGGED_IN); + $response->addCacheableDependency($this->currentUser); + } + else { + $response = new ResourceResponse(self::LOGGED_OUT); + } + return $response->addCacheableDependency((new CacheableMetadata())->setCacheMaxAge(0)); + } + +} diff --git a/core/modules/rest/src/Plugin/rest/resource/UserLogoutResource.php b/core/modules/rest/src/Plugin/rest/resource/UserLogoutResource.php new file mode 100644 index 0000000..58228b3 --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserLogoutResource.php @@ -0,0 +1,53 @@ +pluginId, ':', '.'); + $routes->get("$route_name.POST")->addRequirements([ + '_user_is_logged_in' => 'TRUE', + ]); + + return $routes; + } + + + /** + * Responds to user logout POST requests. + * + * @return \Drupal\rest\ResourceResponse + * The response. + */ + public function post() { + $this->userLogout(); + return new ResourceResponse(NULL, 204); + } + + /** + * Logs the user out. + */ + protected function userLogout() { + user_logout(); + } + +} diff --git a/core/modules/rest/src/Plugin/rest/resource/UserPasswordResetResource.php b/core/modules/rest/src/Plugin/rest/resource/UserPasswordResetResource.php new file mode 100644 index 0000000..a960fe3 --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserPasswordResetResource.php @@ -0,0 +1,169 @@ +userStorage = $user_storage; + $this->renderer = $renderer; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->getParameter('serializer.formats'), + $container->get('logger.channel.rest'), + $container->get('entity_type.manager')->getStorage('user'), + $container->get('renderer') + ); + } + + /** + * {@inheritdoc} + */ + public function routes() { + $routes = parent::routes(); + + // Make the langcode optional. + foreach ($routes->all() as $route) { + $route->setDefault('langcode', NULL); + } + return $routes; + } + + /** + * Responds to user password reset POST requests. + * + * @param string|null $langcode + * Language code to use for the notification, overriding account + * language. + * @param string $name + * The username or email address, that should be reset. + * + * @return \Drupal\rest\ResourceResponse + * The HTTP response. + */ + public function post($langcode, $name) { + $name = trim($name); + + if (!$account = $this->loadUserByNameOrEmail($name)) { + throw new BadRequestHttpException(sprintf('%s is not recognized as a username or an email address.', $name)); + } + + $mail = $this->userMailNotify($account, $langcode); + if (empty($mail)) { + throw new BadRequestHttpException('The email with password reset instructions could not be sent.'); + } + + $this->logger->notice('Password reset instructions mailed to %name at %email.', ['%name' => $account->getDisplayName(), '%email' => $account->getEmail()]); + return new ResourceResponse('Further instructions have been sent to your email address.', 200, []); + } + + /** + * Conditionally creates and sends a notification email. + * + * @param \Drupal\Core\Session\AccountInterface $account + * The user object of the account being notified. Must contain at + * least the fields 'uid', 'name', and 'mail'. + * @param string $langcode + * (optional) Language code to use for the notification, overriding account + * language. + * + * @return array + * An array containing various information about the message. + * See \Drupal\Core\Mail\MailManagerInterface::mail() for details. + */ + protected function userMailNotify(AccountInterface $account, $langcode = NULL) { + $render_context = new RenderContext(); + return $this->renderer->executeInRenderContext($render_context, function() use ($account, $langcode) { + return _user_mail_notify('password_reset', $account, $langcode); + }); + } + + /** + * Loads a user by name or mail address. + * + * @param string $name + * The username or mail address, that should be loaded. + * + * @return \Drupal\user\UserInterface|null + * The loaded user or NULL. + */ + protected function loadUserByNameOrEmail($name) { + // Try to load by email. + $accounts = $this->userStorage->loadByProperties(['mail' => $name, 'status' => '1']); + if ($accounts) { + return reset($accounts); + } + + // No success, try to load by name. + $accounts = $this->userStorage->loadByProperties(['name' => $name, 'status' => '1']); + if ($accounts) { + return reset($accounts); + } + } + +} diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index 9efdb35..bdf30e1 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -54,9 +54,16 @@ public function handle(RouteMatchInterface $route_match, Request $request) { $method_settings = $config[$plugin][$request->getMethod()]; if (empty($method_settings['supported_formats']) || in_array($format, $method_settings['supported_formats'])) { $definition = $resource->getPluginDefinition(); - $class = $definition['serialization_class']; try { - $unserialized = $serializer->deserialize($received, $class, $format, array('request_method' => $method)); + if (array_key_exists('serialization_class', $definition)) { + $unserialized = $serializer->deserialize($received, $definition['serialization_class'], $format, array('request_method' => $method)); + } + // If the plugin does not specify a serialization class just decode + // the received data. + // Example: received JSON is decoded into a PHP array. + else { + $unserialized = $serializer->decode($received, $format, array('request_method' => $method)); + } } catch (UnexpectedValueException $e) { $error['error'] = $e->getMessage(); diff --git a/core/modules/rest/src/Tests/RESTTestBase.php b/core/modules/rest/src/Tests/RESTTestBase.php index 099957e..ad290a6 100644 --- a/core/modules/rest/src/Tests/RESTTestBase.php +++ b/core/modules/rest/src/Tests/RESTTestBase.php @@ -73,11 +73,13 @@ protected function setUp() { * The body for POST and PUT. * @param string $mime_type * The MIME type of the transmitted content. + * @param array $request_headers + * Some additional request headers. * * @return string * The content returned from the request. */ - protected function httpRequest($url, $method, $body = NULL, $mime_type = NULL) { + protected function httpRequest($url, $method, $body = NULL, $mime_type = NULL, $request_headers = []) { if (!isset($mime_type)) { $mime_type = $this->defaultMimeType; } @@ -118,10 +120,11 @@ protected function httpRequest($url, $method, $body = NULL, $mime_type = NULL) { CURLOPT_POSTFIELDS => $body, CURLOPT_URL => $url, CURLOPT_NOBODY => FALSE, - CURLOPT_HTTPHEADER => array( + CURLOPT_HTTPHEADER => array_merge( + array( 'Content-Type: ' . $mime_type, 'X-CSRF-Token: ' . $token, - ), + ), $request_headers), ); break; @@ -173,6 +176,9 @@ protected function httpRequest($url, $method, $body = NULL, $mime_type = NULL) { $this->verbose($method . ' request to: ' . $url . '
Code: ' . curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE) . + '
Request headers: ' . nl2br(print_r($curl_options[CURLOPT_HTTPHEADER], TRUE)) . + '
Extra headers: ' . nl2br(print_r($request_headers, TRUE)) . + '
Request body: ' . nl2br(print_r($body, TRUE)) . '
Response headers: ' . nl2br(print_r($headers, TRUE)) . '
Response body: ' . $this->responseBody); @@ -254,11 +260,13 @@ protected function entityValues($entity_type) { * (Optional) The serialization format, e.g. hal_json. * @param array $auth * (Optional) The list of valid authentication methods. + * @param bool $append + * (Optional) Append to the existing settings. */ - protected function enableService($resource_type, $method = 'GET', $format = NULL, $auth = NULL) { + protected function enableService($resource_type, $method = 'GET', $format = NULL, $auth = NULL, $append = FALSE) { // Enable REST API for this entity type. $config = $this->config('rest.settings'); - $settings = array(); + $settings = $append ? $config->get('resources') : []; if ($resource_type) { if ($format == NULL) { diff --git a/core/modules/rest/src/Tests/ResourceTest.php b/core/modules/rest/src/Tests/ResourceTest.php index df99cc6..0919205 100644 --- a/core/modules/rest/src/Tests/ResourceTest.php +++ b/core/modules/rest/src/Tests/ResourceTest.php @@ -112,8 +112,10 @@ public function testUriPaths() { $manager = \Drupal::service('plugin.manager.rest'); foreach ($manager->getDefinitions() as $resource => $definition) { - foreach ($definition['uri_paths'] as $key => $uri_path) { - $this->assertFalse(strpos($uri_path, '//'), 'The resource URI path does not have duplicate slashes.'); + if (isset($definition['uri_paths'])) { + foreach ($definition['uri_paths'] as $key => $uri_path) { + $this->assertFalse(strpos($uri_path, '//'), 'The resource URI path does not have duplicate slashes.'); + } } } } diff --git a/core/modules/rest/src/Tests/UserLoginTest.php b/core/modules/rest/src/Tests/UserLoginTest.php new file mode 100644 index 0000000..b712937 --- /dev/null +++ b/core/modules/rest/src/Tests/UserLoginTest.php @@ -0,0 +1,146 @@ +defaultAuth; + + $this->enableService('user_login', 'POST', $format, $auth); + $this->enableService('user_login_status', 'GET', $format, $auth, TRUE); + $this->enableService('user_logout', 'POST', $format, $auth, TRUE); + $this->enableService('user_password_reset', 'POST', $format, $auth, TRUE); + + $permissions[] = 'restful post user_login'; + $account = $this->drupalCreateUser($permissions); + $name = $account->getUsername(); + $pass = $account->pass_raw; + + // Add login permission to anonymous user. + Role::load(RoleInterface::ANONYMOUS_ID) + ->grantPermission('restful post user_login') + ->grantPermission('restful get user_login_status') + ->grantPermission('restful post user_password_reset') + ->save(); + + Role::load(RoleInterface::AUTHENTICATED_ID) + ->grantPermission('restful get user_login_status') + ->grantPermission('restful post user_logout') + ->save(); + + + $url = Url::fromRoute('rest.user_login_status.GET.json'); + $url->setRouteParameter('_format', 'json'); + $this->httpRequest($url, 'GET', NULL, 'application/json'); + $this->assertResponse(200); + $this->assertResponseBody('"' . UserLoginStatusResource::LOGGED_OUT . '"'); + + + // Flooded. + \Drupal::configFactory()->getEditable('user.flood') + ->set('user_limit', 3) + ->save(); + $flood = \Drupal::flood(); + $flood->register('rest.login_cookie'); + $flood->register('rest.login_cookie'); + $flood->register('rest.login_cookie'); + + $request_body = ['name' => $name, 'pass' => $pass]; + $this->httpRequest('/user/login', 'POST', json_encode($request_body), 'application/json'); + $this->assertResponse(400); + $this->assertResponseBody('{"error":"Blocked."}'); + + // After testing the flood control we can increase the limit. + \Drupal::configFactory()->getEditable('user.flood') + ->set('user_limit', 100) + ->save(); + + $request_body = []; + $this->httpRequest('/user/login/', 'POST', json_encode($request_body), 'application/json'); + $this->assertResponse(400); + $this->assertResponseBody('{"error":"Missing credentials."}'); + + $request_body = ['pass' => $pass]; + $this->httpRequest('/user/login', 'POST', json_encode($request_body), 'application/json'); + $this->assertResponse(400); + $this->assertResponseBody('{"error":"Missing credentials.name."}'); + + $request_body = ['name' => $name]; + $this->httpRequest('/user/login', 'POST', json_encode($request_body), 'application/json'); + $this->assertResponse(400); + $this->assertResponseBody('{"error":"Missing credentials.pass."}'); + + // Blocked. + $account + ->block() + ->save(); + $request_body = ['name' => $name, 'pass' => $pass]; + $this->httpRequest('/user/login', 'POST', json_encode($request_body), 'application/json'); + $this->assertResponse(400); + $this->assertResponseBody('{"error":"The user has not been activated or is blocked."}'); + $account + ->activate() + ->save(); + + $request_body = ['name' => $name, 'pass' => 'garbage']; + $this->httpRequest('/user/login', 'POST', json_encode($request_body), 'application/json'); + $this->assertResponse(400); + $this->assertResponseBody('{"error":"Sorry, unrecognized username or password."}'); + + $request_body = ['name' => 'garbage', 'pass' => $pass]; + $this->httpRequest('/user/login', 'POST', json_encode($request_body), 'application/json'); + $this->assertResponse(400); + $this->assertResponseBody('{"error":"Sorry, unrecognized username or password."}'); + + $request_body = ['name' => $name, 'pass' => $pass]; + $response = $this->httpRequest('/user/login', 'POST', json_encode($request_body), 'application/json'); + $response = json_decode($response); + $this->assertEqual($name, $response->current_user->name, "The user name is correct."); + + $url = Url::fromRoute('rest.user_login_status.GET.json'); + $url->setRouteParameter('_format', 'json'); + $this->httpRequest($url, 'GET', NULL, 'application/json'); + $this->assertResponse(200); + $this->assertResponseBody('"' . UserLoginStatusResource::LOGGED_IN . '"'); + + $request_body = ['name' => $name, 'pass' => $pass]; + $this->httpRequest('/user/logout', 'POST', json_encode($request_body), 'application/json'); + $this->assertResponse(204); + + $url = Url::fromRoute('rest.user_login_status.GET.json'); + $url->setRouteParameter('_format', 'json'); + $this->httpRequest($url, 'GET', NULL, 'application/json'); + $this->assertResponse(200); + $this->assertResponseBody('"' . UserLoginStatusResource::LOGGED_OUT . '"'); + + + $random_name = $this->randomMachineName(); + $url = Url::fromRoute('rest.user_password_reset.POST', ['name' => $random_name]); + $this->httpRequest($url, 'POST', NULL, 'application/json'); + $this->assertResponse(400); + $this->assertResponseBody('{"error":"' . sprintf('%s is not recognized as a username or an email address.', $random_name) . '"}'); + + $url = Url::fromRoute('rest.user_password_reset.POST', ['name' => $name]); + $this->httpRequest($url, 'POST', NULL, 'application/json'); + $this->assertResponse(200); + $this->assertResponseBody('"Further instructions have been sent to your email address."'); + } + +}