Commit 253c2081 authored by Hoi Sing Edison Wong's avatar Hoi Sing Edison Wong Committed by Paulo Henrique Cota Starling
Browse files

Issue #2867320 by paulocs, hswong3i, ultrabob, iyyappan.govind, kamkejj,...

Issue #2867320 by paulocs, hswong3i, ultrabob, iyyappan.govind, kamkejj, kunal_singh, steveworley, bruno.bicudo, gulab.bisht: Password Policy History module wrong behaviour
parent caa33b01
Loading
Loading
Loading
Loading
+58 −21
Changes for password_policy_history/src/Plugin/PasswordConstraint/PasswordHistory.php: 58 added lines, 21 removed lines.
Original line number Diff line number Diff line
@@ -2,17 +2,17 @@

namespace Drupal\password_policy_history\Plugin\PasswordConstraint;

use Drupal\Core\Database\Connection;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\password_policy\PasswordConstraintBase;
use Drupal\password_policy\PasswordPolicyValidation;
use Drupal\Core\Database\Database;
use Drupal\user\UserInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Password\PasswordInterface;

/**
 * Enforces a specific character length for passwords.
 * Enforces a limit repeated use of the same password.
 *
 * @PasswordConstraint(
 *   id = "password_policy_history_constraint",
@@ -30,6 +30,13 @@ class PasswordHistory extends PasswordConstraintBase implements ContainerFactory
   */
  protected $passwordService;

  /**
   * The database connection.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $connection;

  /**
   * {@inheritdoc}
   */
@@ -38,7 +45,8 @@ class PasswordHistory extends PasswordConstraintBase implements ContainerFactory
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('password')
      $container->get('password'),
      $container->get('database')
    );
  }

@@ -53,44 +61,73 @@ class PasswordHistory extends PasswordConstraintBase implements ContainerFactory
   *   The plugin implementation definition.
   * @param \Drupal\Core\Password\PasswordInterface $password_service
   *   The password service.
   * @param \Drupal\Core\Database\Connection $connection
   *   The database connection.
   */
  public function __construct(array $configuration, $plugin_id, $plugin_definition, PasswordInterface $password_service) {
  public function __construct(array $configuration, $plugin_id, $plugin_definition, PasswordInterface $password_service, Connection $connection) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    $this->passwordService = $password_service;
    $this->connection = $connection;
  }

  /**
   * {@inheritdoc}
   */
  public function validate($password, UserInterface $user) {
    $configuration = $this->getConfiguration();
    $validation = new PasswordPolicyValidation();

    if (empty($user->id())) {
    $uid = $user->id();
    if (empty($uid)) {
      return $validation;
    }

    // Query for users hashes.
    $hashes = Database::getConnection()->select('password_policy_history', 'pph')
      ->fields('pph', ['pass_hash'])
      ->condition('uid', $user->id())
      ->execute()
      ->fetchAll();
    $hashes = $this->getHashes($uid);

    $repeats = 0;
    foreach ($hashes as $hash) {
      if ($this->passwordService->check($password, $hash->pass_hash)) {
        $repeats++;
      if ($this->getPasswordService()->check($password, $hash->pass_hash)) {
        $configuration = $this->getConfiguration();
        if ($configuration['history_repeats'] == 0) {
          $validation->setErrorMessage($this->t('No one of the old passwords can be reused. Choose a different password'));
        }
        $validation->setErrorMessage($this->formatPlural($configuration['history_repeats'], 'The last @count password cannot be reused. Choose a different password.', 'The last @count passwords cannot be reused. Choose a different password.', ['@count' => $configuration['history_repeats']]));
      }

    if ($repeats > intval($configuration['history_repeats'])) {
      $validation->setErrorMessage($this->t('Password has been reused too many times.  Choose a different password.'));
    }

    return $validation;
  }

  /**
   * Get the recent passwords for a given user.
   *
   * Attempt to get the latest password that a user has changed limited by the
   * number of reuses that are configured for the policy.
   *
   * @return array
   *   A result matching all password history hashes for the user.
   */
  protected function getHashes($uid) {
    $configuration = $this->getConfiguration();

    $query = $this->connection->select('password_policy_history', 'pph')
      ->fields('pph', ['pass_hash'])
      ->condition('uid', $uid)
      ->orderBy('timestamp', 'desc');
    if ($configuration['history_repeats'] != 0) {
      $query = $query->range(0, $configuration['history_repeats']);
    }
    return $query->execute()->fetchAll();
  }

  /**
   * Accessor for the password service.
   *
   * @return \Drupal\Core\Password\PasswordInterface
   *   The password interface service.
   */
  public function getPasswordService() {
    return $this->passwordService;
  }

  /**
   * {@inheritdoc}
   */
@@ -106,8 +143,8 @@ class PasswordHistory extends PasswordConstraintBase implements ContainerFactory
  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
    $form['history_repeats'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Number of allowed repeated passwords'),
      '#description' => $this->t('A value of 0 represents no allowed repeats'),
      '#title' => $this->t('Number of passwords that will be checked in the user password update history'),
      '#description' => $this->t('A value of 0 represents that the user can not repeat any of the old passwords.'),
      '#default_value' => $this->getConfiguration()['history_repeats'],
    ];
    return $form;
@@ -133,7 +170,7 @@ class PasswordHistory extends PasswordConstraintBase implements ContainerFactory
   * {@inheritdoc}
   */
  public function getSummary() {
    return $this->t('Number of allowed repeated passwords: @number-repeats', ['@number-repeats' => $this->configuration['history_repeats']]);
    return $this->t('Number of passwords that will be checked in the user password update history: @number-repeats', ['@number-repeats' => $this->configuration['history_repeats']]);
  }

}
+0 −146
Changes for password_policy_history/tests/src/FunctionalJavascript/PasswordHistoryTest.php: 0 added lines, 146 removed lines.
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\password_policy_history\FunctionalJavascript;

use Drupal\FunctionalJavascriptTests\WebDriverTestBase;

/**
 * Tests password history.
 *
 * @group password_policy_history
 */
class PasswordHistoryTest extends WebDriverTestBase {

  /**
   * Set default theme to stark.
   *
   * @var string
   */
  protected $defaultTheme = 'stark';

  /**
   * {@inheritdoc}
   */
  protected static $modules = ['password_policy', 'password_policy_history'];

  /**
   * Test history constraint.
   */
  public function testHistoryConstraint() {
    // Create user with permission to create policy.
    $user1 = $this->drupalCreateUser([
      'administer site configuration',
      'administer users',
      'administer permissions',
    ]);
    $this->drupalLogin($user1);

    $user2 = $this->drupalCreateUser();

    // Create role.
    $rid = $this->drupalCreateRole([]);

    $user_path = sprintf('user/%s/edit', $user2->id());

    $this->drupalGet($user_path);

    $session = $this->getSession();
    $page = $session->getPage();

    // Set role for user. Also manually update password. The user insert hook
    // does not add a password hash in the password_policy_history table for
    // users on initial creation via drupalCreateUser(), but this password
    // update will register an entry since the password is updated in the
    // form instead.
    $page->fillField('pass[pass1]', $user2->pass_raw);
    $page->fillField('pass[pass2]', $user2->pass_raw);
    $page->checkField(sprintf('roles[%s]', $rid));
    $this->assertSession()->assertWaitOnAjaxRequest();
    $this->submitForm([], 'Save');

    // Create new password reset policy for role.
    $this->drupalGet('admin/config/security/password-policy/add');
    $session = $this->getSession();
    $page = $session->getPage();

    $page->fillField('label', 'test');
    $label_field = $page->findField('label');
    $label_field->setValue('test');
    $label_field->blur();
    $this->assertSession()->waitForElementVisible('css', '.link');
    $page->fillField('password_reset', '1');
    $this->submitForm([], 'Save');

    $this->assertSession()->pageTextContains('No constraints have been configured.');

    // Fill out length constraint for test policy.
    $this->drupalGet('admin/config/system/password_policy/constraint/add/test/password_policy_history_constraint');

    $session = $this->getSession();
    $page = $session->getPage();
    $page->fillField('history_repeats', '1');
    $this->submitForm([], 'Save');
    $this->assertSession()->assertWaitOnAjaxRequest();

    $this->assertSession()->responseContains('password_policy_history_constraint');
    $this->assertSession()->pageTextContains('Number of allowed repeated passwords: 1');

    // Set the roles for the policy.
    $this->drupalGet('admin/config/security/password-policy/test');
    $session = $this->getSession();
    $page = $session->getPage();
    $page->checkField(sprintf('roles[%s]', $rid));
    $this->submitForm([], 'Save');

    // Login as user2.
    $this->drupalLogin($user2);

    // Visit the user edit page.
    $this->drupalGet($user_path);

    $assert_session = $this->assertSession();
    $page = $this->getSession()->getPage();

    // Set a value for the pass[pass1] field.
    $pass_1_field = $page->findField('pass[pass1]');
    $pass_1_field->setValue($user2->pass_raw);
    // Remove focus from the password field so the onchange event is triggered.
    $pass_1_field->blur();

    $page->fillField('current_pass', $user2->pass_raw);
    $page->fillField('pass[pass2]', $user2->pass_raw);

    $this->assertSession()->assertWaitOnAjaxRequest();

    $assert_session->pageTextNotContains('Password has been reused too many times. Choose a different password.');

    // Save the form so the password history updates.
    $this->submitForm([], 'Save');
    $this->assertSession()->pageTextContains('The changes have been saved.');

    // Visit the user edit page.
    $this->drupalGet($user_path);

    $assert_session = $this->assertSession();
    $page = $this->getSession()->getPage();

    // Set a value for the pass[pass1] field.
    $pass_1_field = $page->findField('pass[pass1]');
    $pass_1_field->setValue($user2->pass_raw);
    // Remove focus from the password field so the onchange event is triggered.
    $pass_1_field->blur();

    $page->fillField('current_pass', $user2->pass_raw);
    $page->fillField('pass[pass2]', $user2->pass_raw);

    $this->assertSession()->assertWaitOnAjaxRequest();

    // The user shouldn't be able to change a password they used before.
    $assert_session->pageTextContains('Password has been reused too many times. Choose a different password.');

    // Attempt to save the form. Should not succeed.
    $this->submitForm([], 'Save');
    $this->assertSession()->pageTextContains('The password does not satisfy the password policies');
  }

}
+111 −0
Changes for password_policy_history/tests/src/Unit/PasswordHistoryTest.php: 111 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\password_policy_history\FunctionalJavascript;

use Drupal\password_policy_history\Plugin\PasswordConstraint\PasswordHistory;
use Drupal\Tests\UnitTestCase;

/**
 * Tests password history.
 *
 * @group password_policy_history
 */
class PasswordHistoryTest extends UnitTestCase {

  /**
   * The PasswordHistory mock.
   *
   * @var \Drupal\password_policy_history\Plugin\PasswordConstraint\PasswordHistory
   */
  public $passwordHistoryMock;

  /**
   * The User mock.
   *
   * @var \Drupal\user\UserInterface
   */
  public $user;

  /**
   * Set up the test mock.
   */
  public function setup(): void {
    $password_reuse = $this->getMockBuilder(PasswordHistory::class)
      ->onlyMethods(['getHashes', 'getPasswordService', 't'])
      ->disableOriginalConstructor()
      ->getMock();

    $password_reuse
      ->expects($this->once())
      ->method('getHashes')
      ->willReturn([(object) ['pass_hash' => 'fake_password']]);
    $this->user = $this->getMockBuilder('Drupal\user\UserInterface')
      ->disableOriginalConstructor()
      ->getMock();
    $this->user
      ->expects($this->once())
      ->method('id')
      ->willReturn(1);
    $this->passwordHistoryMock = $password_reuse;
  }

  /**
   * Ensure that a password check success results in the correct output.
   *
   * @dataProvider userContextProvider
   */
  public function testPasswordReuseValid($password) {
    $passwordService = $this->getPasswordService(FALSE);

    $this->passwordHistoryMock->expects($this->once())
      ->method('getPasswordService')
      ->willReturn($passwordService);

    $this->assertEquals($this->passwordHistoryMock->validate($password, $this->user)->isValid(), TRUE);
  }

  /**
   * Ensure that a password check failure results in the correct output.
   *
   * @dataProvider userContextProvider
   */
  public function testPasswordReuseInvalid($password) {
    $passwordService = $this->getPasswordService(TRUE);

    $this->passwordHistoryMock->expects($this->once())
      ->method('getPasswordService')
      ->willReturn($passwordService);

    $this->passwordHistoryMock->expects($this->once())
      ->method('t')
      ->willReturn('Invalid password');
    $this->assertEquals($this->passwordHistoryMock->validate($password, $this->user)->isValid(), FALSE);
  }

  /**
   * Return a password interface mock object.
   *
   * @return \Drupal\Core\Password\PasswordInterface
   *   The password interface mock.
   */
  public function getPasswordService($return) {
    $password_service = $this->getMockBuilder('Drupal\Core\Password\PasswordInterface')
      ->disableOriginalConstructor()
      ->getMock();

    $password_service->method('check')->willReturn($return);

    return $password_service;
  }

  /**
   * Data provider for the user context.
   *
   * @return array
   *   The user context array.
   */
  public function userContextProvider() {
    return [['password']];
  }

}