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..51bb3d4 --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserRegistrationResource.php @@ -0,0 +1,206 @@ +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\HttpException + */ + 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.'); + } + + // The current resource only allows anonymous users to register users. + 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('Only administrators can register users.'); + } + // 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(); + } + + // 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 ($account->_restSubmittedFields as $key => $field_name) { + if (!$account->get($field_name)->access('edit')) { + throw new AccessDeniedHttpException("Access denied on creating field '$field_name'."); + } + } + + // 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); + } + + $url = $account->toUrl('canonical', ['absolute' => TRUE])->toString(TRUE); + $response = new ModifiedResourceResponse(NULL, 201, ['Location' => $url->getGeneratedUrl()]); + + return $response; + } + + /** + * {@inheritdoc} + */ + public function routes() { + $collection = new RouteCollection(); + + $route = $this->getBaseRoute('/user/register', 'POST'); + + // Restrict the incoming HTTP Content-type header to the known serialization + // formats. + $route->addRequirements(['_content_type_format' => implode('|', $this->serializerFormats)]); + $collection->add("$this->pluginId", $route); + + return $collection; + } + + /** + * Verifies that the whole entity does not violate any validation constraints. + * + * @param \Drupal\user\UserInterface $entity + * The entity object. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + * If validation errors are found. + */ + protected function validate(UserInterface $entity) { + $violations = $entity->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 HttpException(422, $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..bda801c --- /dev/null +++ b/core/modules/rest/src/Tests/RegisterUserTest.php @@ -0,0 +1,103 @@ +config('rest.settings'); + $settings = []; + $resources = [ + ['type' => 'rest_user_registration', 'method' => 'POST'], + ]; + $format = 'hal_json'; + $auth = $this->defaultAuth; + foreach ($resources as $resource) { + $settings[$resource['type']][$resource['method']]['supported_formats'][] = $format; + $settings[$resource['type']][$resource['method']]['supported_auth'] = $auth; + $config->set('resources', $settings); + $config->save(); + } + $this->rebuildCache(); + + // Add registration permission to anonymous user. + Role::load(RoleInterface::ANONYMOUS_ID) + ->grantPermission('restful post rest_user_registration') + ->save(); + } + + /** + * Tests user registration from REST. + */ + public function testRegisterUser() { + + // New user info to be serialized. + $data = [ + "_links" => + [ + "type" => ["href" => $GLOBALS['base_url'] . "/rest/type/user/user"] + ], + "langcode" => [ + [ + "value" => "en" + ] + ], + "name" => [ + [ + "value" => "Druplicon" + ] + ], + "mail" => [ + [ + "value" => "druplicon@example.com" + ] + ], + "pass" => [ + [ + "value" => "SuperSecretPassword" + ] + ] + ]; + + // Create a JSON version for the user entity we want to create. + $serialized = $this->container->get('serializer')->serialize($data, 'hal_json'); + + // Post to the REST service to register the user. + $this->httpRequest('entity/user/register', 'POST', $serialized, 'application/hal+json'); + $this->assertResponse('201', 'HTTP response code is correct.'); + + // Obtain the uid from the header. + $url_parts = explode('/', $this->drupalGetHeader('location')); + $id = end($url_parts); + $this->assertHeader('Location', $GLOBALS['base_url'] . '/user/' . $id); + + $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/UserRegistrationResourceTest.php b/core/modules/rest/tests/src/Unit/UserRegistrationResourceTest.php new file mode 100644 index 0000000..93fdfa1 --- /dev/null +++ b/core/modules/rest/tests/src/Unit/UserRegistrationResourceTest.php @@ -0,0 +1,262 @@ +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); + } + + /** + * Helper method to set a protected method as accessible. + * + * @param string $method + * The protected method. + * + * @return \ReflectionMethod + */ + public function getProtectedMethod($method) { + $method = $this->reflection->getMethod($method); + $method->setAccessible(TRUE); + + return $method; + } + + /** + * Tests that the user entity does not violate any validation constraints. + */ + public function testValidate() { + $violations = $this->prophesize(EntityConstraintViolationList::class); + $violations->filterByFieldAccess()->willReturn([]); + $violations->count()->willReturn(0); + + $entity = $this->prophesize(User::class); + $entity->validate()->willReturn($violations->reveal()); + + $method = $this->getProtectedMethod('validate'); + + // No exception is thrown. + $method->invokeArgs($this->testClass, [$entity->reveal()]); + } + + /** + * Tests that error validation is thrown as expected. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\HttpException + * @expectedException UserRegistrationResourceTest::ERROR_MESSAGE + */ + 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); + + $method = $this->getProtectedMethod('validate'); + + $method->invoke($this->testClass, $entity->reveal()); + } + + /** + * Tests that an exception is thrown when no data provided for the account. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage No user account data for registration received. + */ + public function testEmptyPost() { + $this->testClass->post(NULL); + } + + /** + * Tests that only new user accounts can be registered. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage An ID has been set and only new user accounts can be registered. + */ + public function testExistedEntityPost() { + $entity = $this->prophesize(User::class); + $entity->isNew()->willReturn(FALSE); + + $this->testClass->post($entity->reveal()); + } + + /** + * Tests that admin permissions are required to register a user account. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * @expectedExceptionMessage Only administrators can register users. + */ + 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->testClass->post($entity->reveal()); + } + + /** + * Tests that only anonymous users can register users. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * @expectedExceptionMessage 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->testClass->post($entity->reveal()); + } + + /** + * Tests access denied on creating field. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * @expectedExceptionMessage Access denied on creating field 'test_field'. + */ + public function testFieldAccessValidation() { + $this->currentUser->isAnonymous()->willReturn(TRUE); + + $this->testClass = new UserRegistrationResource([], 'plugin_id', '', [], $this->logger, $this->userSettings->reveal(), $this->currentUser->reveal()); + + $entity = $this->getMockBuilder(User::class) + ->disableOriginalConstructor() + ->getMock(); + + $entity->expects($this->once()) + ->method('isNew') + ->will($this->returnValue(TRUE)); + + $field = $this->getMockBuilder(FieldItemList::class) + ->setMethods(['access']) + ->disableOriginalConstructor() + ->getMock(); + + $field->expects($this->once()) + ->method('access') + ->will($this->returnValue(FALSE)); + + $entity->expects($this->once()) + ->method('get') + ->will($this->returnValue($field)); + + $entity->expects($this->once()) + ->method('__get') + ->will($this->returnValue(['test' => 'test_field'])); + + $this->testClass->post($entity); + } + +}