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..6171490
--- /dev/null
+++ b/core/modules/rest/src/Plugin/rest/resource/UserLoginResource.php
@@ -0,0 +1,196 @@
+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 the user login POST requests and logs in a user.
+ *
+ * @return \Drupal\rest\ResourceResponse
+ * The HTTP response object.
+ */
+ public function post($credentials) {
+ if (!isset($credentials['name']) && !isset($credentials['pass'])) {
+ throw new BadRequestHttpException('Missing credentials.');
+ }
+
+ // Verify that the username is filled.
+ if (!isset($credentials['name'])) {
+ throw new BadRequestHttpException('Missing credentials.name.');
+ }
+ // Verify that the password is filled.
+ if (!isset($credentials['pass'])) {
+ throw new BadRequestHttpException('Missing credentials.pass.');
+ }
+
+ // Flood control.
+ if (!$this->isFloodBlocked($this->configFactory->get('user.flood'), 'rest.login_cookie')) {
+ throw new BadRequestHttpException('Blocked.');
+ }
+
+ // Verify that the user is not blocked.
+ if ($this->userIsBlocked($credentials['name'])) {
+ throw new BadRequestHttpException('The user has not been activated or is blocked.');
+ }
+
+ // Log in the user.
+ if ($uid = $this->userAuth->authenticate($credentials['name'], $credentials['pass'])) {
+ /** @var \Drupal\user\Entity\User $user */
+ $user = $this->userStorage->load($uid);
+ user_login_finalize($user);
+
+ // Add some basics about the user's account.
+ $response_data = [
+ 'current_user' => [
+ 'uid' => $user->id(),
+ 'roles' => $user->getRoles(),
+ 'name' => $user->getAccountName(),
+ ],
+ 'csrf_token' => $this->csrfToken->get('rest'),
+ ];
+
+ $response = new ResourceResponse($response_data, 200, []);
+ 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
+ * @return bool
+ */
+ protected function userIsBlocked($name) {
+ return user_is_blocked($name);
+ }
+
+ /**
+ * Checks for flooding.
+ *
+ * @param \Drupal\Core\Config\ImmutableConfig $config
+ * The flood control config object.
+ * @param string $name
+ * The name of the event.
+ *
+ * @return bool
+ * TRUE if the user is allowed to proceed, FALSE otherwise.
+ */
+ protected function isFloodBlocked(ImmutableConfig $config, $name) {
+ $limit = $config->get('user_limit');
+ $interval = $config->get('user_window');
+
+ return $this->flood->isAllowed($name, $limit, $interval);
+ }
+
+}
diff --git a/core/modules/rest/src/Plugin/rest/resource/UserLoginStatus.php b/core/modules/rest/src/Plugin/rest/resource/UserLoginStatus.php
new file mode 100644
index 0000000..5fe3019
--- /dev/null
+++ b/core/modules/rest/src/Plugin/rest/resource/UserLoginStatus.php
@@ -0,0 +1,89 @@
+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')
+ );
+ }
+
+ /**
+ * Response 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, 200, []);
+ $response->addCacheableDependency($this->currentUser);
+ }
+ else {
+ $response = new ResourceResponse(self::LOGGED_OUT, 200, []);
+ }
+ return $response->addCacheableDependency((new CacheableMetadata())->setCacheMaxAge(0));
+ }
+
+}
diff --git a/core/modules/rest/src/Plugin/rest/resource/UserLogout.php b/core/modules/rest/src/Plugin/rest/resource/UserLogout.php
new file mode 100644
index 0000000..2a751dc
--- /dev/null
+++ b/core/modules/rest/src/Plugin/rest/resource/UserLogout.php
@@ -0,0 +1,31 @@
+userStorage = $user_storage;
+ }
+
+ /**
+ * {@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')
+ );
+ }
+
+ /**
+ * Resets a user password using a POST request.
+ *
+ * @param string $name
+ * The username or mail address, that should be resetted.
+ * @param string|null $langcode
+ * The langcode.
+ *
+ * @return \Drupal\rest\Plugin\rest\resource\ResourceResponse
+ * The HTTP response.
+ */
+ public function post($name, $langcode = NULL) {
+ $name = trim($name);
+
+ if (!$account = $this->loadUserByNameOrEmail($name)) {
+ // No success, the user does not exist.
+ throw new BadRequestHttpException($this->t("Sorry, %name is not recognized as a user name or an e-mail address.", ['%name' => $name]));
+ }
+
+ $mail = _user_mail_notify('password_reset', $account, $langcode);
+ if (empty($mail)) {
+ throw new BadRequestHttpException('The email with the instructions was not sent as expected.');
+ }
+
+ $this->logger->notice('Password reset instructions mailed to %name at %email.', ['%name' => $account->getUsername(), '%email' => $account->getEmail()]);
+ return new ResourceResponse('Further instructions have been sent to your email address.', 200, []);
+ }
+
+ /**
+ * 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..ef70046 100644
--- a/core/modules/rest/src/RequestHandler.php
+++ b/core/modules/rest/src/RequestHandler.php
@@ -54,9 +54,15 @@ 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/NodeTest.php b/core/modules/rest/src/Tests/NodeTest.php
index 357109c..9bc7210 100644
--- a/core/modules/rest/src/Tests/NodeTest.php
+++ b/core/modules/rest/src/Tests/NodeTest.php
@@ -175,7 +175,7 @@ public function testInvalidBundle() {
// Make sure the response is "Bad Request".
$this->assertResponse(400);
- $this->assertResponseBody('{"error":"\"bad_bundle_name\" is not a valid bundle type for denormalization."}');
+ $this->assertResponseBody(NULL, '{"error":"\"bad_bundle_name\" is not a valid bundle type for denormalization."}');
}
/**
@@ -192,6 +192,6 @@ public function testMissingBundle() {
// Make sure the response is "Bad Request".
$this->assertResponse(400);
- $this->assertResponseBody('{"error":"A string must be provided as a bundle value."}');
+ $this->assertResponseBody(NULL, '{"error":"A string must be provided as a bundle value."}');
}
}
diff --git a/core/modules/rest/src/Tests/RESTTestBase.php b/core/modules/rest/src/Tests/RESTTestBase.php
index 099957e..ce7b66c 100644
--- a/core/modules/rest/src/Tests/RESTTestBase.php
+++ b/core/modules/rest/src/Tests/RESTTestBase.php
@@ -77,7 +77,7 @@ protected function setUp() {
* @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 +118,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 +174,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);
@@ -406,6 +410,10 @@ protected function removeNodeFieldsForNonAdminUsers(NodeInterface $node) {
* Check to see if the HTTP request response body is identical to the expected
* value.
*
+ *
+ * @param $code
+ * (optional) Response code. For example 200 is a successful page request. For a list
+ * of all codes see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html.
* @param $expected
* The first value to check.
* @param $message
@@ -422,7 +430,11 @@ protected function removeNodeFieldsForNonAdminUsers(NodeInterface $node) {
* @return bool
* TRUE if the assertion succeeded, FALSE otherwise.
*/
- protected function assertResponseBody($expected, $message = '', $group = 'REST Response') {
+ protected function assertResponseBody($code = NULL, $expected, $message = '', $group = 'REST Response') {
+ if ($code) {
+ $this->assertResponse($code);
+ }
return $this->assertIdentical($expected, $this->responseBody, $message ? $message : strtr('Response body @expected (expected) is equal to @response (actual).', array('@expected' => var_export($expected, TRUE), '@response' => var_export($this->responseBody, TRUE))), $group);
}
+
}
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..28b0ab7
--- /dev/null
+++ b/core/modules/rest/src/Tests/UserLoginTest.php
@@ -0,0 +1,103 @@
+config('rest.settings');
+ $settings = [];
+ $format = 'json';
+ $auth = $this->defaultAuth;
+
+ $resource = ['type' => 'user_login', 'method' => 'POST'];
+ $settings[$resource['type']][$resource['method']]['supported_formats'][] = $format;
+ $settings[$resource['type']][$resource['method']]['supported_auth'] = $auth;
+
+ $resource = ['type' => 'user_login_status', 'method' => 'GET'];
+ $settings[$resource['type']][$resource['method']]['supported_formats'][] = $format;
+ $settings[$resource['type']][$resource['method']]['supported_auth'] = $auth;
+
+ $resource = ['type' => 'user_logout', 'method' => 'POST'];
+ $settings[$resource['type']][$resource['method']]['supported_formats'][] = $format;
+ $settings[$resource['type']][$resource['method']]['supported_auth'] = $auth;
+
+
+ $config->set('resources', $settings);
+ $config->save();
+
+ $this->rebuildCache();
+
+ $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')
+ ->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->assertResponseBody('200', '"' . UserLoginStatus::LOGGED_OUT . '"');
+
+ $payload = [];
+ $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json');
+ $this->assertResponseBody('400', '{"error":"Missing credentials."}');
+
+ $payload = ['pass' => $pass];
+ $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json');
+ $this->assertResponseBody('400', '{"error":"Missing credentials.name."}');
+
+ $payload = ['name' => $name, 'pass' => 'garbage'];
+ $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json');
+ $this->assertResponseBody('400', '{"error":"Sorry, unrecognized username or password."}');
+
+ $payload = ['name' => 'garbage', 'pass' => $pass];
+ $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json');
+ $this->assertResponseBody('400', '{"error":"Sorry, unrecognized username or password."}');
+
+ $payload = ['name' => $name, 'pass' => $pass];
+ $response = $this->httpRequest('user_login', 'POST', json_encode($payload), '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->assertResponseBody('200', '"' . UserLoginStatus::LOGGED_IN . '"');
+
+ $payload = ['name' => $name, 'pass' => $pass];
+ $this->httpRequest('user_logout', 'POST', json_encode($payload), '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->assertResponseBody('200', '"' . UserLoginStatus::LOGGED_OUT . '"');
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Unit/UserLoginResourceTest.php b/core/modules/rest/tests/src/Unit/UserLoginResourceTest.php
new file mode 100644
index 0000000..01846a2
--- /dev/null
+++ b/core/modules/rest/tests/src/Unit/UserLoginResourceTest.php
@@ -0,0 +1,192 @@
+getMock('Drupal\user\UserAuthInterface');
+ $user_auth_service->expects($this->any())
+ ->method('authenticate')
+ ->will($this->returnValue(FALSE));
+
+ $container = new ContainerBuilder();
+ $container->set('user.auth', $user_auth_service);
+ \Drupal::setContainer($container);
+
+ $this->flood = $this->getMock(FloodInterface::class);
+
+ $this->userStorage = $this->getMockBuilder('\Drupal\user\UserStorage')
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->config = $this->getConfigFactoryStub([
+ 'user.flood' => [],
+ ]);
+
+ $this->logger = $this->getMock('Psr\Log\LoggerInterface');
+
+ $this->csrfToken = $this->prophesize(CsrfTokenGenerator::class);
+
+ $this->testClass = new TestUserLoginResource([], 'plugin_id', '', [], $this->logger, $this->config, $this->flood, $this->userStorage, $this->csrfToken->reveal(), $user_auth_service);
+ }
+
+ /**
+ * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+ * @expectedExceptionMessage Missing credentials.
+ */
+ public function testMissingCredentials() {
+ $this->testClass->post([]);
+ }
+
+ /**
+ * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+ * @expectedExceptionMessage Missing credentials.pass.
+ */
+ public function testLoginMissingCredentialPass() {
+ $this->testClass->post(['name' => 'Druplicon']);
+ }
+
+ /**
+ * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+ * @expectedExceptionMessage Blocked.
+ */
+ public function testLoginBlockedUserByFloodControl() {
+ $this->flood->expects($this->once())
+ ->method('isAllowed')
+ ->willReturn(FALSE);
+
+ $this->testClass->post(['name' => 'Druplicon', 'pass' => 'SuperSecret']);
+ }
+
+ /**
+ * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+ * @expectedExceptionMessage The user has not been activated or is blocked.
+ */
+ public function testLoginBlockedUser() {
+ $this->flood->expects($this->once())
+ ->method('isAllowed')
+ ->willReturn(TRUE);
+
+ $this->testClass->setUserIsBlocked(TRUE);
+
+ $this->testClass->post(['name' => 'Druplicon', 'pass' => 'SuperSecret']);
+ }
+
+ /**
+ * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+ * @expectedExceptionMessage Sorry, unrecognized username or password.
+ */
+ public function testLoginUnrecognizedUsernameOrPassword() {
+ $this->flood->expects($this->once())
+ ->method('isAllowed')
+ ->willReturn(TRUE);
+
+ $this->testClass->setUserIsBlocked(FALSE);
+
+ $this->testClass->post(['name' => 'Druplicon', 'pass' => 'SuperSecret']);
+ }
+
+ /**
+ * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+ * @expectedExceptionMessage Missing Email or Username.
+ */
+ public function testResetPasswordMissingNameOrEmail() {
+ $method = $this->getProtectedMethod('requestNewPassword');
+ $method->invokeArgs($this->testClass, [['name' => '']]);
+ }
+
+ /**
+ * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+ * @expectedExceptionMessage Sorry, Druplicon is not recognized as a user name or an e-mail address.
+ */
+ public function testResetPasswordNotRecognizedNameOrEmail() {
+ $this->userStorage->expects($this->any())
+ ->method('loadByProperties')
+ ->will($this->returnValue([]));
+
+ $method = $this->getProtectedMethod('requestNewPassword');
+ $method->invokeArgs($this->testClass, [['name' => 'Druplicon']]);
+ }
+}
+
+class TestUserLoginResource extends UserLoginResource {
+
+ /**
+ * @var bool|null
+ */
+ protected $blocked;
+
+ public function setUserIsBlocked($blocked) {
+ $this->blocked = $blocked;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function userIsBlocked($name) {
+ if (isset($this->blocked)) {
+ return $this->blocked;
+ }
+
+ return parent::userIsBlocked($name);
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Unit/UserPasswordResetTest.php b/core/modules/rest/tests/src/Unit/UserPasswordResetTest.php
new file mode 100644
index 0000000..de24be9
--- /dev/null
+++ b/core/modules/rest/tests/src/Unit/UserPasswordResetTest.php
@@ -0,0 +1,83 @@
+getMock('Drupal\user\UserAuthInterface');
+ $user_auth_service->expects($this->any())
+ ->method('authenticate')
+ ->will($this->returnValue(FALSE));
+
+ $container = new ContainerBuilder();
+ $container->set('user.auth', $user_auth_service);
+ \Drupal::setContainer($container);
+
+ $this->flood = $this->getMock(FloodInterface::class);
+
+ $this->userStorage = $this->getMockBuilder('\Drupal\user\UserStorage')
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->config = $this->getConfigFactoryStub([
+ 'user.flood' => [],
+ ]);
+
+ $this->logger = $this->getMock('Psr\Log\LoggerInterface');
+
+ $this->csrfToken = $this->prophesize(CsrfTokenGenerator::class);
+
+ $this->testClass = new TestUserLoginResource([], 'plugin_id', '', [], $this->logger, $this->config, $this->flood, $this->userStorage, $this->csrfToken->reveal());
+ }
+
+}