[ Index ] |
PHP Cross Reference of Joomla 4.2.2 documentation |
[Summary view] [Print] [Text view]
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Wed Sep 7 05:41:13 2022 | Chilli.vc Blog - For Webmaster,Blog-Writer,System Admin and Domainer |