diff --git a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php index 8b96940..6319ec7 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->checkEditFieldAccess($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..9933a1f --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserRegistrationResource.php @@ -0,0 +1,160 @@ +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.'); + } + // All new users should be blocked unless visitors are allowed to register. + if ($approvalSettings != USER_REGISTER_VISITORS) { + $account->block(); + } + else { + if (!$this->userSettings->get('verify_mail')) { + // No email verification needed activate account. + $account->activate(); + } + else { + // Email verification needed. + $account->block(); + } + } + + $this->checkEditFieldAccess($account); + + // Make sure that the user entity is valid (email and name are valid). + $this->validate($account); + + // Create the account. + $account->save(); + + // No e-mail verification is required. Activating the user. + if ($approvalSettings == USER_REGISTER_VISITORS) { + if ($this->userSettings->get('verify_mail')) { + // No administrator approval required. + _user_mail_notify('register_no_approval_required', $account); + } + } + // Administrator approval required. + elseif ($approvalSettings == USER_REGISTER_VISITORS_ADMINISTRATIVE_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..776caa6 --- /dev/null +++ b/core/modules/rest/src/ResourceAccessTrait.php @@ -0,0 +1,31 @@ +_restSubmittedFields as $key => $field_name) { + if (!$entity->get($field_name)->access('edit')) { + throw new AccessDeniedHttpException("Access denied on updating 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..7e10bac --- /dev/null +++ b/core/modules/rest/src/Tests/RegisterUserTest.php @@ -0,0 +1,152 @@ +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() { + + $serialized = $this->createSerializedUser('palmer.eldritch'); + + // 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(); + + $user_settings = $this->config('user.settings'); + + // Test out different setting User Registration and Email Verification. + + // Allow visitors to register with no email verification. + $user_settings->set('register', USER_REGISTER_VISITORS); + $user_settings->set('verify_mail', 0); + $user_settings->save(); + $user = $this->registerUser('Palmer.Eldritch'); + $this->assertFalse($user->isBlocked()); + $email_count = count($this->drupalGetMails()); + $this->assertEqual(0, $email_count); + + // Allow visitors to register with email verification. + $user_settings->set('register', USER_REGISTER_VISITORS); + $user_settings->set('verify_mail', 1); + $user_settings->save(); + $user = $this->registerUser('Jason.Taverner'); + $this->assertTrue($user->isBlocked()); + $this->assertMailString('body', 'You may now log in by clicking this link', 1); + $this->verbose('
' . var_export($this->drupalGetMails(), TRUE) .  '
'); + + // Allow visitors to register with Admin approval. + $user_settings->set('register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL); + $user_settings->set('verify_mail', 1); + $user_settings->save(); + $user = $this->registerUser('Bob.Arctor'); + $this->assertTrue($user->isBlocked()); + $this->assertMailString('body', 'Your application for an account is', 2); + $this->assertMailString('body', 'Bob.Arctor has applied for an account', 2); + $this->verbose('
' . var_export($this->drupalGetMails(), TRUE) .  '
'); + + } + + /** + * Creates serialize user values. + * + * @param $name + * The name of the user. Use only valid values for emails. + * + * + * @return string Serialized user values. + * Serialized user values. + */ + protected function createSerializedUser($name) { + global $base_url; + // New user info to be serialized. + $data = [ + "_links" => + [ + "type" => ["href" => $base_url . "/rest/type/user/user"], + ], + "langcode" => [ + [ + "value" => "en", + ], + ], + "name" => [ + [ + "value" => $name, + ], + ], + "mail" => [ + [ + "value" => "$name@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'); + return $serialized; + } + + /** + * Registers a user via REST resource. + * + * @param $name + * User name. + * + * @return bool|\Drupal\user\Entity\User + */ + protected function registerUser($name) { + // Verify that an anonymous user can register. + $serialized = $this->createSerializedUser($name); + $this->httpRequest('/user/register', 'POST', $serialized, 'application/hal+json'); + $this->assertResponse('200', 'HTTP response code is correct.'); + $user = user_load_by_name($name); + $this->assertFalse(empty($user), 'User was create as expected'); + return $user; + } + +} 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..51cf5c0 --- /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..db22768 --- /dev/null +++ b/core/modules/rest/tests/src/Unit/UserRegistrationResourceTest.php @@ -0,0 +1,151 @@ +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()); + } + +}