[ Index ]

PHP Cross Reference of Joomla 4.2.2 documentation

title

Body

[close]

/administrator/components/com_users/src/Table/ -> MfaTable.php (source)

   1  <?php
   2  
   3  /**
   4   * @package    Joomla.Administrator
   5   * @subpackage com_users
   6   *
   7   * @copyright  (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
   8   * @license    GNU General Public License version 2 or later; see LICENSE.txt
   9   */
  10  
  11  namespace Joomla\Component\Users\Administrator\Table;
  12  
  13  use Exception;
  14  use Joomla\CMS\Date\Date;
  15  use Joomla\CMS\Factory;
  16  use Joomla\CMS\Language\Text;
  17  use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
  18  use Joomla\CMS\Table\Table;
  19  use Joomla\CMS\User\UserFactoryInterface;
  20  use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
  21  use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
  22  use Joomla\Component\Users\Administrator\Service\Encrypt;
  23  use Joomla\Database\DatabaseDriver;
  24  use Joomla\Database\ParameterType;
  25  use Joomla\Event\DispatcherInterface;
  26  use RuntimeException;
  27  use Throwable;
  28  
  29  // phpcs:disable PSR1.Files.SideEffects
  30  \defined('_JEXEC') or die;
  31  // phpcs:enable PSR1.Files.SideEffects
  32  
  33  /**
  34   * Table for the Multi-Factor Authentication records
  35   *
  36   * @property int    $id          Record ID.
  37   * @property int    $user_id     User ID
  38   * @property string $title       Record title.
  39   * @property string $method      MFA Method (corresponds to one of the plugins).
  40   * @property int    $default     Is this the default Method?
  41   * @property array  $options     Configuration options for the MFA Method.
  42   * @property string $created_on  Date and time the record was created.
  43   * @property string $last_used   Date and time the record was last used successfully.
  44   *
  45   * @since 4.2.0
  46   */
  47  class MfaTable extends Table
  48  {
  49      /**
  50       * Delete flags per ID, set up onBeforeDelete and used onAfterDelete
  51       *
  52       * @var   array
  53       * @since 4.2.0
  54       */
  55      private $deleteFlags = [];
  56  
  57      /**
  58       * Encryption service
  59       *
  60       * @var   Encrypt
  61       * @since 4.2.0
  62       */
  63      private $encryptService;
  64  
  65      /**
  66       * Indicates that columns fully support the NULL value in the database
  67       *
  68       * @var   boolean
  69       * @since 4.2.0
  70       */
  71      // phpcs:ignore
  72      protected $_supportNullValue = true;
  73  
  74      /**
  75       * Table constructor
  76       *
  77       * @param   DatabaseDriver            $db          Database driver object
  78       * @param   DispatcherInterface|null  $dispatcher  Events dispatcher object
  79       *
  80       * @since 4.2.0
  81       */
  82      public function __construct(DatabaseDriver $db, DispatcherInterface $dispatcher = null)
  83      {
  84          parent::__construct('#__user_mfa', 'id', $db, $dispatcher);
  85  
  86          $this->encryptService = new Encrypt();
  87      }
  88  
  89      /**
  90       * Method to store a row in the database from the Table instance properties.
  91       *
  92       * If a primary key value is set the row with that primary key value will be updated with the instance property values.
  93       * If no primary key value is set a new row will be inserted into the database with the properties from the Table instance.
  94       *
  95       * @param   boolean  $updateNulls  True to update fields even if they are null.
  96       *
  97       * @return  boolean  True on success.
  98       *
  99       * @since 4.2.0
 100       */
 101      public function store($updateNulls = true)
 102      {
 103          // Encrypt the options before saving them
 104          $this->options = $this->encryptService->encrypt(json_encode($this->options ?: []));
 105  
 106          // Set last_used date to null if empty or zero date
 107          if (!((int) $this->last_used)) {
 108              $this->last_used = null;
 109          }
 110  
 111          $records = MfaHelper::getUserMfaRecords($this->user_id);
 112  
 113          if ($this->id) {
 114              // Existing record. Remove it from the list of records.
 115              $records = array_filter(
 116                  $records,
 117                  function ($rec) {
 118                      return $rec->id != $this->id;
 119                  }
 120              );
 121          }
 122  
 123          // Update the dates on a new record
 124          if (empty($this->id)) {
 125              $this->created_on = Date::getInstance()->toSql();
 126              $this->last_used  = null;
 127          }
 128  
 129          // Do I need to mark this record as the default?
 130          if ($this->default == 0) {
 131              $hasDefaultRecord = array_reduce(
 132                  $records,
 133                  function ($carry, $record) {
 134                      return $carry || ($record->default == 1);
 135                  },
 136                  false
 137              );
 138  
 139              $this->default = $hasDefaultRecord ? 0 : 1;
 140          }
 141  
 142          // Let's find out if we are saving a new MFA method record without having backup codes yet.
 143          $mustCreateBackupCodes = false;
 144  
 145          if (empty($this->id) && $this->method !== 'backupcodes') {
 146              // Do I have any backup records?
 147              $hasBackupCodes = array_reduce(
 148                  $records,
 149                  function (bool $carry, $record) {
 150                      return $carry || $record->method === 'backupcodes';
 151                  },
 152                  false
 153              );
 154  
 155              $mustCreateBackupCodes = !$hasBackupCodes;
 156  
 157              // If the only other entry is the backup records one I need to make this the default method
 158              if ($hasBackupCodes && count($records) === 1) {
 159                  $this->default = 1;
 160              }
 161          }
 162  
 163          // Store the record
 164          try {
 165              $result = parent::store($updateNulls);
 166          } catch (Throwable $e) {
 167              $this->setError($e->getMessage());
 168  
 169              $result = false;
 170          }
 171  
 172          // Decrypt the options (they must be decrypted in memory)
 173          $this->decryptOptions();
 174  
 175          if ($result) {
 176              // If this record is the default unset the default flag from all other records
 177              $this->switchDefaultRecord();
 178  
 179              // Do I need to generate backup codes?
 180              if ($mustCreateBackupCodes) {
 181                  $this->generateBackupCodes();
 182              }
 183          }
 184  
 185          return $result;
 186      }
 187  
 188      /**
 189       * Method to load a row from the database by primary key and bind the fields to the Table instance properties.
 190       *
 191       * @param   mixed    $keys   An optional primary key value to load the row by, or an array of fields to match.
 192       *                           If not set the instance property value is used.
 193       * @param   boolean  $reset  True to reset the default values before loading the new row.
 194       *
 195       * @return  boolean  True if successful. False if row not found.
 196       *
 197       * @since 4.2.0
 198       * @throws  \InvalidArgumentException
 199       * @throws  RuntimeException
 200       * @throws  \UnexpectedValueException
 201       */
 202      public function load($keys = null, $reset = true)
 203      {
 204          $result = parent::load($keys, $reset);
 205  
 206          if ($result) {
 207              $this->decryptOptions();
 208          }
 209  
 210          return $result;
 211      }
 212  
 213      /**
 214       * Method to delete a row from the database table by primary key value.
 215       *
 216       * @param   mixed  $pk  An optional primary key value to delete.  If not set the instance property value is used.
 217       *
 218       * @return  boolean  True on success.
 219       *
 220       * @since 4.2.0
 221       * @throws  \UnexpectedValueException
 222       */
 223      public function delete($pk = null)
 224      {
 225          $record = $this;
 226  
 227          if ($pk != $this->id) {
 228              $record = clone $this;
 229              $record->reset();
 230              $result = $record->load($pk);
 231  
 232              if (!$result) {
 233                  // If the record does not exist I will stomp my feet and deny your request
 234                  throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
 235              }
 236          }
 237  
 238          $user = Factory::getApplication()->getIdentity()
 239              ?? Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
 240  
 241          // The user must be a registered user, not a guest
 242          if ($user->guest) {
 243              throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
 244          }
 245  
 246          // Save flags used onAfterDelete
 247          $this->deleteFlags[$record->id] = [
 248              'default'    => $record->default,
 249              'numRecords' => $this->getNumRecords($record->user_id),
 250              'user_id'    => $record->user_id,
 251              'method'     => $record->method,
 252          ];
 253  
 254          if (\is_null($pk)) {
 255              $pk = [$this->_tbl_key => $this->id];
 256          } elseif (!\is_array($pk)) {
 257              $pk = [$this->_tbl_key => $pk];
 258          }
 259  
 260          $isDeleted = parent::delete($pk);
 261  
 262          if ($isDeleted) {
 263              $this->afterDelete($pk);
 264          }
 265  
 266          return $isDeleted;
 267      }
 268  
 269      /**
 270       * Decrypt the possibly encrypted options
 271       *
 272       * @return void
 273       * @since 4.2.0
 274       */
 275      private function decryptOptions(): void
 276      {
 277          // Try with modern decryption
 278          $decrypted = @json_decode($this->encryptService->decrypt($this->options ?? ''), true);
 279  
 280          if (is_string($decrypted)) {
 281              $decrypted = @json_decode($decrypted, true);
 282          }
 283  
 284          // Fall back to legacy decryption
 285          if (!is_array($decrypted)) {
 286              $decrypted = @json_decode($this->encryptService->decrypt($this->options ?? '', true), true);
 287  
 288              if (is_string($decrypted)) {
 289                  $decrypted = @json_decode($decrypted, true);
 290              }
 291          }
 292  
 293          $this->options = $decrypted ?: [];
 294      }
 295  
 296      /**
 297       * If this record is set to be the default, unset the default flag from the other records for the same user.
 298       *
 299       * @return void
 300       * @since 4.2.0
 301       */
 302      private function switchDefaultRecord(): void
 303      {
 304          if (!$this->default) {
 305              return;
 306          }
 307  
 308          /**
 309           * This record is marked as default, therefore we need to unset the default flag from all other records for this
 310           * user.
 311           */
 312          $db    = $this->getDbo();
 313          $query = $db->getQuery(true)
 314              ->update($db->quoteName('#__user_mfa'))
 315              ->set($db->quoteName('default') . ' = 0')
 316              ->where($db->quoteName('user_id') . ' = :user_id')
 317              ->where($db->quoteName('id') . ' != :id')
 318              ->bind(':user_id', $this->user_id, ParameterType::INTEGER)
 319              ->bind(':id', $this->id, ParameterType::INTEGER);
 320          $db->setQuery($query)->execute();
 321      }
 322  
 323      /**
 324       * Regenerate backup code is the flag is set.
 325       *
 326       * @return void
 327       * @throws Exception
 328       * @since 4.2.0
 329       */
 330      private function generateBackupCodes(): void
 331      {
 332          /** @var MVCFactoryInterface $factory */
 333          $factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();
 334  
 335          /** @var BackupcodesModel $backupCodes */
 336          $backupCodes = $factory->createModel('Backupcodes', 'Administrator');
 337          $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($this->user_id);
 338          $backupCodes->regenerateBackupCodes($user);
 339      }
 340  
 341      /**
 342       * Runs after successfully deleting a record
 343       *
 344       * @param   int|array  $pk  The promary key of the deleted record
 345       *
 346       * @return void
 347       * @since 4.2.0
 348       */
 349      private function afterDelete($pk): void
 350      {
 351          if (is_array($pk)) {
 352              $pk = $pk[$this->_tbl_key] ?? array_shift($pk);
 353          }
 354  
 355          if (!isset($this->deleteFlags[$pk])) {
 356              return;
 357          }
 358  
 359          if (($this->deleteFlags[$pk]['numRecords'] <= 2) && ($this->deleteFlags[$pk]['method'] != 'backupcodes')) {
 360              /**
 361               * This was the second to last MFA record in the database (the last one is the `backupcodes`). Therefore, we
 362               * need to delete the remaining entry and go away. We don't trigger this if the Method we are deleting was
 363               * the `backupcodes` because we might just be regenerating the backup codes.
 364               */
 365              $db    = $this->getDbo();
 366              $query = $db->getQuery(true)
 367                  ->delete($db->quoteName('#__user_mfa'))
 368                  ->where($db->quoteName('user_id') . ' = :user_id')
 369                  ->bind(':user_id', $this->deleteFlags[$pk]['user_id'], ParameterType::INTEGER);
 370              $db->setQuery($query)->execute();
 371  
 372              unset($this->deleteFlags[$pk]);
 373  
 374              return;
 375          }
 376  
 377          // This was the default record. Promote the next available record to default.
 378          if ($this->deleteFlags[$pk]['default']) {
 379              $db    = $this->getDbo();
 380              $query = $db->getQuery(true)
 381                  ->select($db->quoteName('id'))
 382                  ->from($db->quoteName('#__user_mfa'))
 383                  ->where($db->quoteName('user_id') . ' = :user_id')
 384                  ->where($db->quoteName('method') . ' != ' . $db->quote('backupcodes'))
 385                  ->bind(':user_id', $this->deleteFlags[$pk]['user_id'], ParameterType::INTEGER);
 386              $ids   = $db->setQuery($query)->loadColumn();
 387  
 388              if (empty($ids)) {
 389                  return;
 390              }
 391  
 392              $id    = array_shift($ids);
 393              $query = $db->getQuery(true)
 394                  ->update($db->quoteName('#__user_mfa'))
 395                  ->set($db->quoteName('default') . ' = 1')
 396                  ->where($db->quoteName('id') . ' = :id')
 397                  ->bind(':id', $id, ParameterType::INTEGER);
 398              $db->setQuery($query)->execute();
 399          }
 400      }
 401  
 402      /**
 403       * Get the number of MFA records for a give user ID
 404       *
 405       * @param   int  $userId  The user ID to check
 406       *
 407       * @return  integer
 408       *
 409       * @since 4.2.0
 410       */
 411      private function getNumRecords(int $userId): int
 412      {
 413          $db    = $this->getDbo();
 414          $query = $db->getQuery(true)
 415              ->select('COUNT(*)')
 416              ->from($db->quoteName('#__user_mfa'))
 417              ->where($db->quoteName('user_id') . ' = :user_id')
 418              ->bind(':user_id', $userId, ParameterType::INTEGER);
 419          $numOldRecords = $db->setQuery($query)->loadResult();
 420  
 421          return (int) $numOldRecords;
 422      }
 423  }


Generated: Wed Sep 7 05:41:13 2022 Chilli.vc Blog - For Webmaster,Blog-Writer,System Admin and Domainer