Commit 79dfc068 authored by effulgentsia's avatar effulgentsia

Issue #2291055 by marthinal, tedbow, Wim Leers, kylebrowning, m1r1k,...

Issue #2291055 by marthinal, tedbow, Wim Leers, kylebrowning, m1r1k, clemens.tolboom, jlbellido, vivekvpandya, snehal.brahmbhatt, dawehner, klausi, droti, alexpott, cloudbull, Berdir, heykarthikwithu, claudiu.cristea: REST resources for anonymous users: register
parent 97f20479
......@@ -36,6 +36,9 @@
*/
class EntityResource extends ResourceBase implements DependentPluginInterface {
use EntityResourceValidationTrait;
use EntityResourceAccessTrait;
/**
* The entity type targeted by this resource.
*
......@@ -156,14 +159,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);
......@@ -175,8 +171,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);
......@@ -276,39 +271,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}
*/
......
<?php
namespace Drupal\rest\Plugin\rest\resource;
use Drupal\Core\Entity\EntityInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @internal
* @todo Consider making public in https://www.drupal.org/node/2300677
*/
trait EntityResourceAccessTrait {
/**
* Performs edit access checks for fields.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity whose fields edit access should be checked for.
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* Throws access denied when the user does not have permissions to edit a
* field.
*/
protected function checkEditFieldAccess(EntityInterface $entity) {
// 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'.");
}
}
}
}
<?php
namespace Drupal\rest\Plugin\rest\resource;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
* @todo Consider making public in https://www.drupal.org/node/2300677
*/
trait EntityResourceValidationTrait {
/**
* Verifies that the whole entity does not violate any validation constraints.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to validate.
*
* @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
* 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 ($violations->count() > 0) {
$message = "Unprocessable Entity: validation failed.\n";
foreach ($violations as $violation) {
$message .= $violation->getPropertyPath() . ': ' . $violation->getMessage() . "\n";
}
throw new UnprocessableEntityHttpException($message);
}
}
}
<?php
namespace Drupal\Tests\rest\Unit;
use Drupal\Core\Entity\EntityConstraintViolationList;
use Drupal\node\Entity\Node;
use Drupal\Tests\UnitTestCase;
use Drupal\user\Entity\User;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* @group rest
* @coversDefaultClass \Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait
*/
class EntityResourceValidationTraitTest extends UnitTestCase {
/**
* @covers ::validate
*/
public function testValidate() {
$trait = $this->getMockForTrait('Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait');
$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\Plugin\rest\resource\EntityResourceValidationTrait');
$method = new \ReflectionMethod($trait, 'validate');
$method->setAccessible(TRUE);
$this->setExpectedException(UnprocessableEntityHttpException::class);
$method->invoke($trait, $entity->reveal());
}
}
<?php
namespace Drupal\user\Plugin\rest\resource;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Session\AccountInterface;
use Drupal\rest\ModifiedResourceResponse;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\Plugin\rest\resource\EntityResourceAccessTrait;
use Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait;
use Drupal\user\UserInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Represents user registration as a resource.
*
* @RestResource(
* id = "user_registration",
* label = @Translation("User registration"),
* serialization_class = "Drupal\user\Entity\User",
* uri_paths = {
* "https://www.drupal.org/link-relations/create" = "/user/register",
* },
* )
*/
class UserRegistrationResource extends ResourceBase {
use EntityResourceValidationTrait;
use EntityResourceAccessTrait;
/**
* User settings config instance.
*
* @var \Drupal\Core\Config\ImmutableConfig
*/
protected $userSettings;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Constructs a new UserRegistrationResource instance.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param array $serializer_formats
* The available serialization formats.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
* @param \Drupal\Core\Config\ImmutableConfig $user_settings
* A user settings config instance.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, array $serializer_formats, LoggerInterface $logger, ImmutableConfig $user_settings, AccountInterface $current_user) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);
$this->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) {
$this->ensureAccountCanRegister($account);
// Only activate new users if visitors are allowed to register and no email
// verification required.
if ($this->userSettings->get('register') == USER_REGISTER_VISITORS && !$this->userSettings->get('verify_mail')) {
$account->activate();
}
else {
$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();
$this->sendEmailNotifications($account);
return new ModifiedResourceResponse($account, 200);
}
/**
* Ensure the account can be registered in this request.
*
* @param \Drupal\user\UserInterface $account
* The user account to register.
*/
protected function ensureAccountCanRegister(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 a user.');
}
// Verify that the current user can register a user account.
if ($this->userSettings->get('register') == USER_REGISTER_ADMINISTRATORS_ONLY) {
throw new AccessDeniedHttpException('You cannot register a new user account.');
}
if (!$this->userSettings->get('verify_mail')) {
if (empty($account->getPassword())) {
// If no e-mail verification then the user must provide a password.
throw new UnprocessableEntityHttpException('No password provided.');
}
}
else {
if (!empty($account->getPassword())) {
// If e-mail verification required then a password cannot provided.
// The password will be set when the user logs in.
throw new UnprocessableEntityHttpException('A Password cannot be specified. It will be generated on login.');
}
}
}
/**
* Sends email notifications if necessary for user that was registered.
*
* @param \Drupal\user\UserInterface $account
* The user account.
*/
protected function sendEmailNotifications(UserInterface $account) {
$approval_settings = $this->userSettings->get('register');
// No e-mail verification is required. Activating the user.
if ($approval_settings == 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 ($approval_settings == USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL) {
_user_mail_notify('register_pending_approval', $account);
}
}
}
<?php
namespace Drupal\user\Tests;
use Drupal\rest\Tests\RESTTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
/**
* Tests user registration via REST resource.
*
* @group user
*/
class RestRegisterUserTest extends RESTTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['hal'];
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
$this->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() {
// 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->registerRequest('palmer.eldritch');
$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());
$this->assertFalse(empty($user->getPassword()));
$email_count = count($this->drupalGetMails());
$this->assertEqual(0, $email_count);
// Attempt to register without sending a password.
$this->registerRequest('Rick.Deckard', FALSE);
$this->assertResponse('422', 'No password provided');
// 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', FALSE);
$this->assertTrue(empty($user->getPassword()));
$this->assertTrue($user->isBlocked());
$this->assertMailString('body', 'You may now log in by clicking this link', 1);
// Attempt to register with a password when e-mail verification is on.
$this->registerRequest('Estraven', TRUE);
$this->assertResponse('422', 'A Password cannot be specified. It will be generated on login.');
// Allow visitors to register with Admin approval and e-mail verification.
$user_settings->set('register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL);
$user_settings->set('verify_mail', 1);
$user_settings->save();
$user = $this->registerUser('Bob.Arctor', FALSE);
$this->assertTrue(empty($user->getPassword()));
$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);
// Attempt to register with a password when e-mail verification is on.
$this->registerRequest('Ursula', TRUE);
$this->assertResponse('422', 'A Password cannot be specified. It will be generated on login.');
// Allow visitors to register with Admin approval and no email verification.
$user_settings->set('register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL);
$user_settings->set('verify_mail', 0);
$user_settings->save();
$user = $this->registerUser('Argaven');
$this->assertFalse(empty($user->getPassword()));
$this->assertTrue($user->isBlocked());
$this->assertMailString('body', 'Your application for an account is', 2);
$this->assertMailString('body', 'Argaven has applied for an account', 2);
// Attempt to register without sending a password.
$this->registerRequest('Tibe', FALSE);
$this->assertResponse('422', 'No password provided');
}
/**
* Creates serialize user values.
*
* @param string $name
* The name of the user. Use only valid values for emails.
*
* @param bool $include_password
* Whether to include a password in the user values.
*
* @return string Serialized user values.
* Serialized user values.
*/
protected function createSerializedUser($name, $include_password = TRUE) {
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",
],
],
];
if ($include_password) {
$data['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.
*
* @param bool $include_password
*
* @return bool|\Drupal\user\Entity\User
*/
protected function registerUser($name, $include_password = TRUE) {
// Verify that an anonymous user can register.
$this->registerRequest($name, $include_password);
$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;
}
/**
* Make a REST user registration request.
*
* @param $name
* @param $include_password
*/
protected function registerRequest($name, $include_password = TRUE) {
$serialized = $this->createSerializedUser($name, $include_password);
$this->httpRequest('/user/register', 'POST', $serialized, 'application/hal+json');
}
}
<?php
namespace Drupal\Tests\user\Unit;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Session\AccountInterface;
use Drupal\Tests\UnitTestCase;
use Drupal\user\Entity\User;
use Drupal\user\Plugin\rest\resource\UserRegistrationResource;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Only administrators can create user accounts.
*/
if (!defined('USER_REGISTER_ADMINISTRATORS_ONLY')) {
define('USER_REGISTER_ADMINISTRATORS_ONLY', 'admin_only');
}
/**
* Visitors can create their own accounts.
*/
if (!defined('USER_REGISTER_VISITORS')) {
define('USER_REGISTER_VISITORS', 'visitors');
}
/**
* Visitors can create accounts, but they don't become active without
* administrative approval.
*/
if (!defined('USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL')) {
define('USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL', 'visitors_admin_approval');
}
/**
* Tests User Registration REST resource.
*
* @coversDefaultClass \Drupal\user\Plugin\rest\resource\UserRegistrationResource
* @group user
*/
class UserRegistrationResourceTest extends UnitTestCase {
const ERROR_MESSAGE = "Unprocessable Entity: validation failed.\nproperty_path: message\nproperty_path_2: message_2\n";
/**
* Class to be tested.
*
* @var \Drupal\user\Plugin\rest\resource\UserRegistrationResource
*/
protected $testClass;
/**
* A reflection of self::$testClass.
*
* @var \ReflectionClass
*/
protected $reflection;
/**
* A user settings config instance.
*
* @var \Drupal\Core\Config\ImmutableConfig|\PHPUnit_Framework_MockObject_MockObject
*/
protected $userSettings;
/**
* Logger service.
*
* @var \Psr\Log\LoggerInterface|\PHPUnit_Framework_MockObject_MockObject
*/
protected $logger;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface|\PHPUnit_Framework_MockObject_MockObject
*/
protected $currentUser;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->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.