diff --git a/core/modules/rest/src/Plugin/ResourceBase.php b/core/modules/rest/src/Plugin/ResourceBase.php index 5f8065e..2038fa9 100644 --- a/core/modules/rest/src/Plugin/ResourceBase.php +++ b/core/modules/rest/src/Plugin/ResourceBase.php @@ -8,6 +8,7 @@ namespace Drupal\rest\Plugin; use Drupal\Core\Access\AccessManagerInterface; +use Drupal\Core\Flood\FloodInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Plugin\PluginBase; use Psr\Log\LoggerInterface; @@ -42,6 +43,11 @@ protected $logger; /** + * @var \Drupal\Core\Config\ImmutableConfig + */ + protected $flood; + + /** * Constructs a Drupal\rest\Plugin\ResourceBase object. * * @param array $configuration @@ -54,11 +60,14 @@ * The available serialization formats. * @param \Psr\Log\LoggerInterface $logger * A logger instance. + * @param \Drupal\Core\Flood\FloodInterface $flood + * The flood control mechanism. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, array $serializer_formats, LoggerInterface $logger) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, array $serializer_formats, LoggerInterface $logger, FloodInterface $flood) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->serializerFormats = $serializer_formats; $this->logger = $logger; + $this->flood = $flood; } /** @@ -70,7 +79,8 @@ 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.factory')->get('rest'), + $container->get('flood') ); } @@ -214,4 +224,21 @@ protected function getBaseRoute($canonical_path, $method) { return $route; } + /** + * Checks for flooding. + * + * @param \Drupal\Core\Config\ImmutableConfig $config + * @param $name + * @return bool + */ + protected function restFloodControl($config, $name) { + $limit = $config->get('user_limit'); + $interval = $config->get('user_window'); + if (!$this->flood->isAllowed($name, $limit, $interval)) { + return TRUE; + } + return FALSE; + } + + } 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..6faa9c2 --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserLoginResource.php @@ -0,0 +1,201 @@ +configFactory = $config_factory; + } + + /** + * {@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') + ); + } + + /** + * Responds to the user login POST requests and log in a user. + * + * @param string[] $operation + * array( + * 'op' => 'login', 'logout' + * 'credentials' => array( + * 'name' => 'your-name', + * 'pass' => 'your-password', + * ), + * ) + * + * The operation and username + pass for the login op. + * + * @return \Drupal\rest\ResourceResponse + * The HTTP response object. + */ + public function post(array $operation = array()) { + + if (array_key_exists('op', $operation)) { + switch ($operation['op']) { + + case 'login': + if (!array_key_exists('credentials', $operation)) { + $operation['credentials'] = array(); + } + return $this->login($operation['credentials']); + + case 'status': + return $this->status(); + + case 'logout': + return $this->logout(); + + default: + // TODO: do we have to escape? + throw new BadRequestHttpException('Unsupported op '. Html::escape($operation['op']) . '.'); + + } + } + else { + throw new BadRequestHttpException('No op found. Use: status, login, logout.'); + } + } + + /** + * User login. + * + * @param array $credentials + * The username and pass for the user. + * + * @return \Drupal\rest\ResourceResponse + * The HTTP response object + */ + protected function login(array $credentials = array()) { +// if ($this->userIsAuthenticated()) { +// throw new BadRequestHttpException('You need to logout first.'); +// } + + if (empty($credentials)) { + throw new BadRequestHttpException('Missing credentials.'); + } + + // Verify that the username is filled. + if (!array_key_exists('name', $credentials)) { + throw new BadRequestHttpException('Missing credentials.name.'); + } + // Verify that the password is filled. + if (!array_key_exists('pass', $credentials)) { + 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'])) { + $user = User::load($uid); + user_login_finalize($user); + return new ResourceResponse('You are logged in as ' . $credentials['name'] . '.', 200, array()); + } + + $this->flood->register('rest.login_cookie', $this->configFactory->get('user.flood')->get('user_window')); + throw new BadRequestHttpException('Sorry, unrecognized username or password.'); + } + + protected function userIsAuthenticated() { + return \Drupal::currentUser()->isAuthenticated(); + + } + protected function status() { +// if (\Drupal::currentUser()->isAuthenticated()) { +// return new ResourceResponse('You are logged in.', 200, array()); +// } + return new ResourceResponse('You are not logged in.', 200, array()); + } + + /** + * User Logout. + * + * @return ResourceResponse + */ + protected function logout() { + if (!\Drupal::currentUser()->isAuthenticated()) { + throw new BadRequestHttpException('You cannot logout as you are not logged in.'); + } + + user_logout(); + return new ResourceResponse('You are logged out.', 200, array()); + } + + /** + * Verifies if the user is blocked. + * + * @param string $name + * @return bool + */ + protected function userIsBlocked($name) { + return user_is_blocked($name); + } + +} diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index ee4b890..801e0cd 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -60,9 +60,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']; + $class = isset($definition['serialization_class']) ? $definition['serialization_class'] : NULL; try { - $unserialized = $serializer->deserialize($received, $class, $format, array('request_method' => $method)); + if ($class) { + $unserialized = $serializer->deserialize($received, $class, $format, array('request_method' => $method)); + } + // Avoid denormalization because we need to instantiate a class. + 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 5e84827..a2da3a7 100644 --- a/core/modules/rest/src/Tests/RESTTestBase.php +++ b/core/modules/rest/src/Tests/RESTTestBase.php @@ -83,7 +83,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; } @@ -113,10 +113,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; @@ -168,6 +169,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); diff --git a/core/modules/rest/src/Tests/UserTest.php b/core/modules/rest/src/Tests/UserTest.php new file mode 100644 index 0000000..4c2d48a --- /dev/null +++ b/core/modules/rest/src/Tests/UserTest.php @@ -0,0 +1,116 @@ +defaultAuth = array('basic_auth'); + + $this->enableService('user_login', 'POST'); + + $permissions[] = 'restful post user_login'; + $account = $this->drupalCreateUser($permissions); + + $name = $account->getUsername(); + $pass = $account->pass_raw; + + $basic_auth = ['Authorization: Basic ' . base64_encode("$name:$pass")]; + + $payload = array(); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(400, 'No op found. Use: status, login, logout.'); + + $payload = $this->getPayload('garbage'); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(400, 'Unsupported op garbage.'); + + $payload = $this->getPayload('status'); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(200, 'You are logged in.'); + + $payload = $this->getPayload('logout'); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(200, 'You are logged out.', $basic_auth); + + $payload = $this->getPayload('login'); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(400, 'Missing credentials.'); + + $payload = $this->getPayload('login', $name); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(400, 'Missing credentials.pass.'); + + $payload = $this->getPayload('login', NULL, $pass); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(400, 'Missing credentials.name.'); + + $payload = $this->getPayload('login', $name, 'garbage'); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(400, 'Sorry, unrecognized username or password.'); + + $payload = $this->getPayload('login', 'garbage', $pass); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(400, 'Sorry, unrecognized username or password.'); + + $payload = $this->getPayload('login', $name, $pass); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(200, "You are logged in as $name"); + + $payload = $this->getPayload('status'); + $this->httpRequest('user_login', 'POST', json_encode($payload), $this->defaultMimeType, $basic_auth); + $this->assertResponseAndText(200, 'You are logged in.'); + } + + protected function assertResponseAndText($code, $text) { + $this->assertResponse($code); + $this->assertText($text); + } + + /** + * Helper function to build the payload. + * + * @param string $op + * @param string|null $user + * @param string|null $pass + * @return array + * + * @see UserLoginResource.php + */ + 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..fdd4113 --- /dev/null +++ b/core/modules/rest/tests/src/Unit/UserLoginResourceTest.php @@ -0,0 +1,173 @@ +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->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->testClassMock = $this->getMockBuilder('\Drupal\rest\Plugin\rest\resource\UserLoginResource') + ->setMethods(['restFloodControl', 'login', 'logout', 'post', 'userIsBlocked']) + ->setConstructorArgs([[], 'plugin_id', '', [], $this->logger, $this->config, $this->flood]) + ->getMock(); + + $this->reflection = new \ReflectionClass($this->testClass); + } + + /** + * Gets a protected method from current class using reflection. + * + * @param $method + * @return mixed + */ + 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(TRUE)); + + $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(FALSE)); + + $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(FALSE)); + + $this->testClassMock->expects($this->once()) + ->method('userIsBlocked') + ->will($this->returnValue(FALSE)); + + $method = $this->getProtectedMethod('login'); + $method->invokeArgs($this->testClassMock, ['credentials' => ['name' => 'Druplicon', 'pass' => 'SuperSecret']]); + } +} +