diff --git a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php index 7e620df..64d1e1b 100644 --- a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php @@ -9,7 +9,9 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityStorageException; use Drupal\rest\Plugin\ResourceBase; +use Drupal\rest\ResourceAccessTrait; use Drupal\rest\ResourceResponse; +use Drupal\rest\ResourceValidationTrait; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Drupal\rest\ModifiedResourceResponse; @@ -35,6 +37,9 @@ */ class EntityResource extends ResourceBase implements DependentPluginInterface { + use ResourceValidationTrait; + use ResourceAccessTrait; + /** * The entity type targeted by this resource. * @@ -144,14 +149,7 @@ public function post(EntityInterface $entity = NULL) { throw new BadRequestHttpException('Only new entities can be created'); } - // Only check 'edit' permissions for fields that were actually - // submitted by the user. Field access makes no difference between 'create' - // and 'update', so the 'edit' operation is used here. - foreach ($entity->_restSubmittedFields as $key => $field_name) { - if (!$entity->get($field_name)->access('edit')) { - throw new AccessDeniedHttpException("Access denied on creating field '$field_name'"); - } - } + $this->checkFieldAccess($entity); // Validate the received data before saving. $this->validate($entity); @@ -163,8 +161,7 @@ public function post(EntityInterface $entity = NULL) { // body. These responses are not cacheable, so we add no cacheability // metadata here. $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE); - $response = new ModifiedResourceResponse($entity, 201, ['Location' => $url->getGeneratedUrl()]); - return $response; + return new ModifiedResourceResponse($entity, 201, ['Location' => $url->getGeneratedUrl()]); } catch (EntityStorageException $e) { throw new HttpException(500, 'Internal Server Error', $e); @@ -265,39 +262,6 @@ public function delete(EntityInterface $entity) { } /** - * Verifies that the whole entity does not violate any validation constraints. - * - * @param \Drupal\Core\Entity\EntityInterface $entity - * The entity object. - * - * @throws \Symfony\Component\HttpKernel\Exception\HttpException - * If validation errors are found. - */ - protected function validate(EntityInterface $entity) { - // @todo Remove when https://www.drupal.org/node/2164373 is committed. - if (!$entity instanceof FieldableEntityInterface) { - return; - } - $violations = $entity->validate(); - - // Remove violations of inaccessible fields as they cannot stem from our - // changes. - $violations->filterByFieldAccess(); - - if (count($violations) > 0) { - $message = "Unprocessable Entity: validation failed.\n"; - foreach ($violations as $violation) { - $message .= $violation->getPropertyPath() . ': ' . $violation->getMessage() . "\n"; - } - // Instead of returning a generic 400 response we use the more specific - // 422 Unprocessable Entity code from RFC 4918. That way clients can - // distinguish between general syntax errors in bad serializations (code - // 400) and semantic errors in well-formed requests (code 422). - throw new HttpException(422, $message); - } - } - - /** * {@inheritdoc} */ protected function getBaseRoute($canonical_path, $method) { diff --git a/core/modules/rest/src/Plugin/rest/resource/UserRegistrationResource.php b/core/modules/rest/src/Plugin/rest/resource/UserRegistrationResource.php new file mode 100644 index 0000000..3ad7e28 --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserRegistrationResource.php @@ -0,0 +1,158 @@ +userSettings = $user_settings; + $this->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('config.factory')->get('user.settings'), + $container->get('current_user') + ); + } + + /** + * Responds to user registration POST request. + * + * @param \Drupal\user\UserInterface $account + * The user account entity. + * + * @return \Drupal\rest\ModifiedResourceResponse + * The HTTP response object. + * + * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + */ + public function post(UserInterface $account = NULL) { + if ($account === NULL) { + throw new BadRequestHttpException('No user account data for registration received.'); + } + + // POSTed user accounts must not have an ID set, because we always want to + // create new entities here. + if (!$account->isNew()) { + throw new BadRequestHttpException('An ID has been set and only new user accounts can be registered.'); + } + + // Only allow anonymous users to register, authenticated users with the + // necessary permissions can POST a new user to the "user" REST resource. + // @see \Drupal\rest\Plugin\rest\resource\EntityResource + if (!$this->currentUser->isAnonymous()) { + throw new AccessDeniedHttpException('Only anonymous users can register users.'); + } + $approvalSettings = $this->userSettings->get('register'); + + // Verify that the current user can register a user account. + if ($approvalSettings == USER_REGISTER_ADMINISTRATORS_ONLY) { + throw new AccessDeniedHttpException('You cannot register a new user account.'); + } + // If current user can register accounts then let's block the new registered + // user if admin approval is needed. + elseif ($approvalSettings == USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL) { + $account->block(); + } + + $this->checkFieldAccess($account); + + // Make sure that the user entity is valid (email and name are valid). + $this->validate($account); + + // Create the account. + $account->save(); + + $register = $this->userSettings->get('register'); + // No e-mail verification is required. Activating the user. + if ($register == 'visitors') { + if (!$this->userSettings->get('verify_mail')) { + // Notification will be sent if activated. + $account->activate(); + // Save changes to apply active status to the account. + $account->save(); + } + // No administrator approval required. + else { + _user_mail_notify('register_no_approval_required', $account); + } + } + // Administrator approval required. + elseif ($register == 'visitors_admin_approval') { + _user_mail_notify('register_pending_approval', $account); + } + + return new ModifiedResourceResponse($account, 200); + } + +} diff --git a/core/modules/rest/src/ResourceAccessTrait.php b/core/modules/rest/src/ResourceAccessTrait.php new file mode 100644 index 0000000..2385a20 --- /dev/null +++ b/core/modules/rest/src/ResourceAccessTrait.php @@ -0,0 +1,28 @@ +_restSubmittedFields as $key => $field_name) { + if (!$entity->get($field_name)->access('edit')) { + throw new AccessDeniedHttpException("Access denied on creating field '$field_name'."); + } + } + } + +} + diff --git a/core/modules/rest/src/ResourceValidationTrait.php b/core/modules/rest/src/ResourceValidationTrait.php new file mode 100644 index 0000000..f8576a7 --- /dev/null +++ b/core/modules/rest/src/ResourceValidationTrait.php @@ -0,0 +1,39 @@ +validate(); + + // Remove violations of inaccessible fields as they cannot stem from our + // changes. + $violations->filterByFieldAccess(); + + if ($violations->count() > 0) { + $message = "Unprocessable Entity: validation failed.\n"; + foreach ($violations as $violation) { + $message .= $violation->getPropertyPath() . ': ' . $violation->getMessage() . "\n"; + } + // Instead of returning a generic 400 response we use the more specific + // 422 Unprocessable Entity code from RFC 4918. That way clients can + // distinguish between general syntax errors in bad serializations (code + // 400) and semantic errors in well-formed requests (code 422). + throw new UnprocessableEntityHttpException($message); + } + } + +} diff --git a/core/modules/rest/src/Tests/RegisterUserTest.php b/core/modules/rest/src/Tests/RegisterUserTest.php new file mode 100644 index 0000000..f167ea3 --- /dev/null +++ b/core/modules/rest/src/Tests/RegisterUserTest.php @@ -0,0 +1,95 @@ +enableService('user_registration', 'POST', 'hal_json'); + + Role::load(RoleInterface::ANONYMOUS_ID) + ->grantPermission('restful post user_registration') + ->save(); + + Role::load(RoleInterface::AUTHENTICATED_ID) + ->grantPermission('restful post user_registration') + ->save(); + } + + /** + * Tests that only anonymous users can register users. + */ + public function testRegisterUser() { + global $base_url; + + // New user info to be serialized. + $data = [ + "_links" => + [ + "type" => ["href" => $base_url . "/rest/type/user/user"] + ], + "langcode" => [ + [ + "value" => "en" + ] + ], + "name" => [ + [ + "value" => "Druplicon" + ] + ], + "mail" => [ + [ + "value" => "druplicon@example.com" + ] + ], + "pass" => [ + [ + "value" => "SuperSecretPassword" + ] + ] + ]; + + // Create a HAL+JSON version for the user entity we want to create. + $serialized = $this->container->get('serializer')->serialize($data, 'hal_json'); + + // Verify that an authenticated user cannot register a new user, despite + // being granted permission to do so because only anonymous users can + // register themselves, authenticated users with the necessary permissions + // can POST a new user to the "user" REST resource. + $user = $this->createUser(); + $this->drupalLogin($user); + $this->httpRequest('/user/register', 'POST', $serialized, 'application/hal+json'); + $this->assertResponse('403', 'Only anonymous users can register users.'); + $this->drupalLogout(); + + // Verify that an anonymous user can register. + $this->httpRequest('/user/register', 'POST', $serialized, 'application/hal+json'); + $this->assertResponse('200', 'HTTP response code is correct.'); + $this->assertTrue((bool) $this->container->get('entity.query') + ->get('user') + ->condition('name', 'Druplicon') + ->range(0, 1) + ->count() + ->execute(), 'The user was created as expected'); + } + +} diff --git a/core/modules/rest/tests/src/Unit/ResourceValidationTraitTest.php b/core/modules/rest/tests/src/Unit/ResourceValidationTraitTest.php new file mode 100644 index 0000000..378a193 --- /dev/null +++ b/core/modules/rest/tests/src/Unit/ResourceValidationTraitTest.php @@ -0,0 +1,73 @@ +getMockForTrait('Drupal\rest\ResourceValidationTrait'); + + $method = new \ReflectionMethod($trait, 'validate'); + $method->setAccessible(TRUE); + + $entity = $this->prophesize(Node::class); + + $violations = $this->prophesize(EntityConstraintViolationList::class); + $violations->filterByFieldAccess()->willReturn([]); + $violations->count()->willReturn(0); + + $entity->validate()->willReturn($violations->reveal()); + + $method->invoke($trait, $entity->reveal()); + } + + /** + * @covers ::validate + */ + public function testFailedValidate() { + $violation1 = $this->prophesize(ConstraintViolationInterface::class); + $violation1->getPropertyPath()->willReturn('property_path'); + $violation1->getMessage()->willReturn('message'); + + $violation2 = $this->prophesize(ConstraintViolationInterface::class); + $violation2->getPropertyPath()->willReturn('property_path'); + $violation2->getMessage()->willReturn('message'); + + $entity = $this->prophesize(User::class); + + $violations = $this->getMockBuilder(EntityConstraintViolationList::class) + ->setConstructorArgs([$entity->reveal(), [$violation1->reveal(), $violation2->reveal()]]) + ->setMethods(['filterByFieldAccess']) + ->getMock(); + + $violations->expects($this->once()) + ->method('filterByFieldAccess') + ->will($this->returnValue([])); + + $entity->validate()->willReturn($violations); + + $trait = $this->getMockForTrait('Drupal\rest\ResourceValidationTrait'); + + $method = new \ReflectionMethod($trait, 'validate'); + $method->setAccessible(TRUE); + + $this->setExpectedException(UnprocessableEntityHttpException::class); + + $method->invoke($trait, $entity->reveal()); + } +} diff --git a/core/modules/rest/tests/src/Unit/UserRegistrationResourceTest.php b/core/modules/rest/tests/src/Unit/UserRegistrationResourceTest.php new file mode 100644 index 0000000..12d1190 --- /dev/null +++ b/core/modules/rest/tests/src/Unit/UserRegistrationResourceTest.php @@ -0,0 +1,150 @@ +logger = $this->prophesize(LoggerInterface::class)->reveal(); + + $this->userSettings = $this->prophesize(ImmutableConfig::class); + + $this->currentUser = $this->prophesize(AccountInterface::class); + + $this->testClass = new UserRegistrationResource([], 'plugin_id', '', [], $this->logger, $this->userSettings->reveal(), $this->currentUser->reveal()); + $this->reflection = new \ReflectionClass($this->testClass); + } + + /** + * Tests that an exception is thrown when no data provided for the account. + */ + public function testEmptyPost() { + $this->setExpectedException(BadRequestHttpException::class); + $this->testClass->post(NULL); + } + + /** + * Tests that only new user accounts can be registered. + */ + public function testExistedEntityPost() { + $entity = $this->prophesize(User::class); + $entity->isNew()->willReturn(FALSE); + $this->setExpectedException(BadRequestHttpException::class); + + $this->testClass->post($entity->reveal()); + } + + /** + * Tests that admin permissions are required to register a user account. + */ + public function testRegistrationAdminOnlyPost() { + + $this->userSettings->get('register')->willReturn(USER_REGISTER_ADMINISTRATORS_ONLY); + + $this->currentUser->isAnonymous()->willReturn(TRUE); + + $this->testClass = new UserRegistrationResource([], 'plugin_id', '', [], $this->logger, $this->userSettings->reveal(), $this->currentUser->reveal()); + + $entity = $this->prophesize(User::class); + $entity->isNew()->willReturn(TRUE); + + $this->setExpectedException(AccessDeniedHttpException::class); + + $this->testClass->post($entity->reveal()); + } + + /** + * Tests that only anonymous users can register users. + */ + public function testRegistrationAnonymousOnlyPost() { + $this->currentUser->isAnonymous()->willReturn(FALSE); + + $this->testClass = new UserRegistrationResource([], 'plugin_id', '', [], $this->logger, $this->userSettings->reveal(), $this->currentUser->reveal()); + + $entity = $this->prophesize(User::class); + $entity->isNew()->willReturn(TRUE); + + $this->setExpectedException(AccessDeniedHttpException::class); + + $this->testClass->post($entity->reveal()); + } +}