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..fa19040 --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserLoginResource.php @@ -0,0 +1,178 @@ +configFactory = $config_factory; + $this->flood = $flood; + $this->userStorage = $user_storage; + $this->logger = $logger; + } + + /** + * {@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.factory')->get('rest'), + $container->get('config.factory'), + $container->get('flood'), + $container->get('entity.manager')->getStorage('user') + ); + } + + /** + * Responds to the user login POST requests and logs in a user. + * + * @return \Drupal\rest\ResourceResponse + * The HTTP response object. + */ + public function post($name, $password) { + if (empty($credentials)) { + throw new BadRequestHttpException('Missing credentials.'); + } + + // Verify that the username is filled. + if (!isset($name)) { + throw new BadRequestHttpException('Missing credentials.name.'); + } + // Verify that the password is filled. + if (!isset($password)) { + throw new BadRequestHttpException('Missing credentials.pass.'); + } + + // Flood control. + if (!$this->restFloodControl($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 = \Drupal::service('user.auth')->authenticate($credentials['name'], $credentials['pass'])) { + /** @var \Drupal\user\Entity\User $user */ + $user = User::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' => \Drupal::csrfToken()->get('rest'), + ]; + + return new ResourceResponse($response_data, 200, []); + } + + $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 restFloodControl($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..7a87c2d --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserLoginStatus.php @@ -0,0 +1,80 @@ +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.factory')->get('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()) { + return new ResourceResponse('You are logged in.', 200, []); + } + return new ResourceResponse('You are not logged in.', 200, []); + } + +} 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..5f34ea9 --- /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.factory')->get('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) { + // Verify that the email or username is filled. + if (!$name) { + throw new BadRequestHttpException('Missing Email or Username.'); + } + + $name = trim($name); + + if (!$account = $this->loadUserByNameOrEmail($name)) { + // No success, the user does not exist. + throw new BadRequestHttpException("Sorry, $name is not recognized as a user name or an e-mail address."); + } + + $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..fb40dbc --- /dev/null +++ b/core/modules/rest/src/Tests/UserLoginTest.php @@ -0,0 +1,120 @@ +config('rest.settings'); + $settings = []; + $resource = ['type' => 'user_login', 'method' => 'POST']; + $format = 'json'; + $auth = $this->defaultAuth; + $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') + ->save(); + + $payload = array(); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('400', '{"error":"No op found. Use: status, login, logout."}'); + + $payload = $this->getPayload('garbage'); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('400', '{"error":"Unsupported op garbage."}'); + + $payload = $this->getPayload('status'); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('200', '"You are not logged in."'); + + $payload = $this->getPayload('login'); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('400', '{"error":"Missing credentials."}'); + + $payload = $this->getPayload('login', $name); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('400', '{"error":"Missing credentials.pass."}'); + + $payload = $this->getPayload('login', NULL, $pass); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('400', '{"error":"Missing credentials.name."}'); + + $payload = $this->getPayload('login', $name, 'garbage'); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('400', '{"error":"Sorry, unrecognized username or password."}'); + + $payload = $this->getPayload('login', 'garbage', $pass); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('400', '{"error":"Sorry, unrecognized username or password."}'); + + $payload = $this->getPayload('login', $name, $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."); + + $payload = $this->getPayload('status'); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('200', '"You are logged in."'); + + $payload = $this->getPayload('logout'); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('200', '"You are logged out."'); + + $payload = $this->getPayload('status'); + $this->httpRequest('user_login', 'POST', json_encode($payload), 'application/json'); + $this->assertResponseBody('200', '"You are not logged in."'); + + } + + /** + * Helper function to build the payload. + * + * @param string $op + * The operation. + * @param string $name + * The user name. + * @param string $pass + * The user pass. + * + * @return array + * The payload. + */ + private function getPayload( $op, $name = NULL, $pass = NULL) { + $result = array('op' => $op); + + if ($op == 'login') { + $result['credentials'] = array(); + if (isset($name)) { + $result['credentials']['name'] = $name; + } + if (isset($pass)) { + $result['credentials']['pass'] = $pass; + } + } + return $result; + } +} 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..d7e2445 --- /dev/null +++ b/core/modules/rest/tests/src/Unit/UserLoginResourceTest.php @@ -0,0 +1,239 @@ +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('\Drupal\Core\Flood\FloodInterface'); + + $this->userStorage = $this->getMockBuilder('\Drupal\user\UserStorage') + ->disableOriginalConstructor() + ->getMock(); + + $this->config = $this->getMockBuilder('\Drupal\Core\Config\ConfigFactory') + ->disableOriginalConstructor() + ->setMethods(['get']) + ->getMock(); + + $immutableConfig = $this->getMockBuilder('\Drupal\Core\Config\ConfigFactory') + ->disableOriginalConstructor() + ->setMethods(['get']) + ->getMock(); + + $this->config->expects($this->any()) + ->method('get') + ->will($this->returnValue($immutableConfig)); + + $this->logger = $this->getMock('Psr\Log\LoggerInterface'); + + $this->testClass = new UserLoginResource([], 'plugin_id', '', [], $this->logger, $this->config, $this->flood, $this->userStorage); + + $this->testClassMock = $this->getMockBuilder('\Drupal\rest\Plugin\rest\resource\UserLoginResource') + ->setMethods(['restFloodControl', 'login', 'logout', 'post', 'userIsBlocked']) + ->setConstructorArgs([[], 'plugin_id', '', [], $this->logger, $this->config, $this->flood, $this->userStorage]) + ->getMock(); + + $this->reflection = new \ReflectionClass($this->testClass); + } + + /** + * Gets a protected method from current class using reflection. + * + * @param string $method + * The requested method. + * + * @return mixed + * The protected method. + */ + public function getProtectedMethod($method) { + $method = $this->reflection->getMethod($method); + $method->setAccessible(TRUE); + + return $method; + } + + /** + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage No op found. Use: status, login, logout. + */ + public function testEmptyPayload() { + $this->testClass->post([]); + } + + /** + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage Missing credentials. + */ + public function testMissingCredentials() { + $this->testClass->post(['op'=>'login']); + } + + /** + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage Unsupported op UnsuportedOp. + */ + public function testUnsupportedOp() { + $this->testClass->post(['op'=>'UnsuportedOp']); + } + + /** + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage Missing credentials. + */ + public function testLoginMissingCredentialName() { + $method = $this->getProtectedMethod('login'); + $method->invokeArgs($this->testClass, ['credentials' => []]); + } + + /** + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage Missing credentials.pass. + */ + public function testLoginMissingCredentialPass() { + $method = $this->getProtectedMethod('login'); + $method->invokeArgs($this->testClass, ['credentials' => ['name' => 'Druplicon']]); + } + + /** + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage Blocked. + */ + public function testLoginBlockedUserByFloodControl() { + $this->testClassMock->expects($this->once()) + ->method('restFloodControl') + ->will($this->returnValue(FALSE)); + + $method = $this->getProtectedMethod('login'); + $method->invokeArgs($this->testClassMock, ['credentials' => ['name' => 'Druplicon', 'pass' => 'SuperSecret']]); + } + + /** + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage The user has not been activated or is blocked. + */ + public function testLoginBlockedUser() { + $this->testClassMock->expects($this->once()) + ->method('restFloodControl') + ->will($this->returnValue(TRUE)); + + $this->testClassMock->expects($this->once()) + ->method('userIsBlocked') + ->will($this->returnValue(TRUE)); + + $method = $this->getProtectedMethod('login'); + $method->invokeArgs($this->testClassMock, ['credentials' => ['name' => 'Druplicon', 'pass' => 'SuperSecret']]); + } + + /** + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage Sorry, unrecognized username or password. + */ + public function testLoginUnrecognizedUsernameOrPassword() { + $this->testClassMock->expects($this->once()) + ->method('restFloodControl') + ->will($this->returnValue(TRUE)); + + $this->testClassMock->expects($this->once()) + ->method('userIsBlocked') + ->will($this->returnValue(FALSE)); + + $method = $this->getProtectedMethod('login'); + $method->invokeArgs($this->testClassMock, ['credentials' => ['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']]); + } +} +