first commit
This commit is contained in:
427
administrator/components/com_users/src/Table/MfaTable.php
Normal file
427
administrator/components/com_users/src/Table/MfaTable.php
Normal file
@ -0,0 +1,427 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Administrator
|
||||
* @subpackage com_users
|
||||
*
|
||||
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\Users\Administrator\Table;
|
||||
|
||||
use Joomla\CMS\Date\Date;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\CMS\User\CurrentUserInterface;
|
||||
use Joomla\CMS\User\CurrentUserTrait;
|
||||
use Joomla\CMS\User\UserFactoryAwareInterface;
|
||||
use Joomla\CMS\User\UserFactoryAwareTrait;
|
||||
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
|
||||
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
|
||||
use Joomla\Component\Users\Administrator\Service\Encrypt;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
use Joomla\Database\ParameterType;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Table for the Multi-Factor Authentication records
|
||||
*
|
||||
* @property int $id Record ID.
|
||||
* @property int $user_id User ID
|
||||
* @property string $title Record title.
|
||||
* @property string $method MFA Method (corresponds to one of the plugins).
|
||||
* @property int $default Is this the default Method?
|
||||
* @property array $options Configuration options for the MFA Method.
|
||||
* @property string $created_on Date and time the record was created.
|
||||
* @property string $last_used Date and time the record was last used successfully.
|
||||
* @property int $tries Counter for unsuccessful tries
|
||||
* @property string $last_try Date and time of the last unsuccessful try
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
class MfaTable extends Table implements CurrentUserInterface, UserFactoryAwareInterface
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
use UserFactoryAwareTrait;
|
||||
|
||||
/**
|
||||
* Delete flags per ID, set up onBeforeDelete and used onAfterDelete
|
||||
*
|
||||
* @var array
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private $deleteFlags = [];
|
||||
|
||||
/**
|
||||
* Encryption service
|
||||
*
|
||||
* @var Encrypt
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private $encryptService;
|
||||
|
||||
/**
|
||||
* Indicates that columns fully support the NULL value in the database
|
||||
*
|
||||
* @var boolean
|
||||
* @since 4.2.0
|
||||
*/
|
||||
// phpcs:ignore
|
||||
protected $_supportNullValue = true;
|
||||
|
||||
/**
|
||||
* Table constructor
|
||||
*
|
||||
* @param DatabaseDriver $db Database driver object
|
||||
* @param ?DispatcherInterface $dispatcher Events dispatcher object
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db, DispatcherInterface $dispatcher = null)
|
||||
{
|
||||
parent::__construct('#__user_mfa', 'id', $db, $dispatcher);
|
||||
|
||||
$this->encryptService = new Encrypt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to store a row in the database from the Table instance properties.
|
||||
*
|
||||
* If a primary key value is set the row with that primary key value will be updated with the instance property values.
|
||||
* If no primary key value is set a new row will be inserted into the database with the properties from the Table instance.
|
||||
*
|
||||
* @param boolean $updateNulls True to update fields even if they are null.
|
||||
*
|
||||
* @return boolean True on success.
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function store($updateNulls = true)
|
||||
{
|
||||
// Encrypt the options before saving them
|
||||
$this->options = $this->encryptService->encrypt(json_encode($this->options ?: []));
|
||||
|
||||
// Set last_used date to null if empty or zero date
|
||||
if (!((int) $this->last_used)) {
|
||||
$this->last_used = null;
|
||||
}
|
||||
|
||||
$records = MfaHelper::getUserMfaRecords($this->user_id);
|
||||
|
||||
if ($this->id) {
|
||||
// Existing record. Remove it from the list of records.
|
||||
$records = array_filter(
|
||||
$records,
|
||||
function ($rec) {
|
||||
return $rec->id != $this->id;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Update the dates on a new record
|
||||
if (empty($this->id)) {
|
||||
$this->created_on = Date::getInstance()->toSql();
|
||||
$this->last_used = null;
|
||||
}
|
||||
|
||||
// Do I need to mark this record as the default?
|
||||
if ($this->default == 0) {
|
||||
$hasDefaultRecord = array_reduce(
|
||||
$records,
|
||||
function ($carry, $record) {
|
||||
return $carry || ($record->default == 1);
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
$this->default = $hasDefaultRecord ? 0 : 1;
|
||||
}
|
||||
|
||||
// Let's find out if we are saving a new MFA method record without having backup codes yet.
|
||||
$mustCreateBackupCodes = false;
|
||||
|
||||
if (empty($this->id) && $this->method !== 'backupcodes') {
|
||||
// Do I have any backup records?
|
||||
$hasBackupCodes = array_reduce(
|
||||
$records,
|
||||
function (bool $carry, $record) {
|
||||
return $carry || $record->method === 'backupcodes';
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
$mustCreateBackupCodes = !$hasBackupCodes;
|
||||
|
||||
// If the only other entry is the backup records one I need to make this the default method
|
||||
if ($hasBackupCodes && \count($records) === 1) {
|
||||
$this->default = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the record
|
||||
try {
|
||||
$result = parent::store($updateNulls);
|
||||
} catch (\Throwable $e) {
|
||||
$this->setError($e->getMessage());
|
||||
|
||||
$result = false;
|
||||
}
|
||||
|
||||
// Decrypt the options (they must be decrypted in memory)
|
||||
$this->decryptOptions();
|
||||
|
||||
if ($result) {
|
||||
// If this record is the default unset the default flag from all other records
|
||||
$this->switchDefaultRecord();
|
||||
|
||||
// Do I need to generate backup codes?
|
||||
if ($mustCreateBackupCodes) {
|
||||
$this->generateBackupCodes();
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to load a row from the database by primary key and bind the fields to the Table instance properties.
|
||||
*
|
||||
* @param mixed $keys An optional primary key value to load the row by, or an array of fields to match.
|
||||
* If not set the instance property value is used.
|
||||
* @param boolean $reset True to reset the default values before loading the new row.
|
||||
*
|
||||
* @return boolean True if successful. False if row not found.
|
||||
*
|
||||
* @since 4.2.0
|
||||
* @throws \InvalidArgumentException
|
||||
* @throws \RuntimeException
|
||||
* @throws \UnexpectedValueException
|
||||
*/
|
||||
public function load($keys = null, $reset = true)
|
||||
{
|
||||
$result = parent::load($keys, $reset);
|
||||
|
||||
if ($result) {
|
||||
$this->decryptOptions();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to delete a row from the database table by primary key value.
|
||||
*
|
||||
* @param mixed $pk An optional primary key value to delete. If not set the instance property value is used.
|
||||
*
|
||||
* @return boolean True on success.
|
||||
*
|
||||
* @since 4.2.0
|
||||
* @throws \UnexpectedValueException
|
||||
*/
|
||||
public function delete($pk = null)
|
||||
{
|
||||
$record = $this;
|
||||
|
||||
if ($pk != $this->id) {
|
||||
$record = clone $this;
|
||||
$record->reset();
|
||||
$result = $record->load($pk);
|
||||
|
||||
if (!$result) {
|
||||
// If the record does not exist I will stomp my feet and deny your request
|
||||
throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
|
||||
}
|
||||
}
|
||||
|
||||
$user = $this->getCurrentUser();
|
||||
|
||||
// The user must be a registered user, not a guest
|
||||
if ($user->guest) {
|
||||
throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
|
||||
}
|
||||
|
||||
// Save flags used onAfterDelete
|
||||
$this->deleteFlags[$record->id] = [
|
||||
'default' => $record->default,
|
||||
'numRecords' => $this->getNumRecords($record->user_id),
|
||||
'user_id' => $record->user_id,
|
||||
'method' => $record->method,
|
||||
];
|
||||
|
||||
if (\is_null($pk)) {
|
||||
$pk = [$this->_tbl_key => $this->id];
|
||||
} elseif (!\is_array($pk)) {
|
||||
$pk = [$this->_tbl_key => $pk];
|
||||
}
|
||||
|
||||
$isDeleted = parent::delete($pk);
|
||||
|
||||
if ($isDeleted) {
|
||||
$this->afterDelete($pk);
|
||||
}
|
||||
|
||||
return $isDeleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the possibly encrypted options
|
||||
*
|
||||
* @return void
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function decryptOptions(): void
|
||||
{
|
||||
// Try with modern decryption
|
||||
$decrypted = @json_decode($this->encryptService->decrypt($this->options ?? ''), true);
|
||||
|
||||
if (\is_string($decrypted)) {
|
||||
$decrypted = @json_decode($decrypted, true);
|
||||
}
|
||||
|
||||
// Fall back to legacy decryption
|
||||
if (!\is_array($decrypted)) {
|
||||
$decrypted = @json_decode($this->encryptService->decrypt($this->options ?? '', true), true);
|
||||
|
||||
if (\is_string($decrypted)) {
|
||||
$decrypted = @json_decode($decrypted, true);
|
||||
}
|
||||
}
|
||||
|
||||
$this->options = $decrypted ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* If this record is set to be the default, unset the default flag from the other records for the same user.
|
||||
*
|
||||
* @return void
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function switchDefaultRecord(): void
|
||||
{
|
||||
if (!$this->default) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* This record is marked as default, therefore we need to unset the default flag from all other records for this
|
||||
* user.
|
||||
*/
|
||||
$db = $this->getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__user_mfa'))
|
||||
->set($db->quoteName('default') . ' = 0')
|
||||
->where($db->quoteName('user_id') . ' = :user_id')
|
||||
->where($db->quoteName('id') . ' != :id')
|
||||
->bind(':user_id', $this->user_id, ParameterType::INTEGER)
|
||||
->bind(':id', $this->id, ParameterType::INTEGER);
|
||||
$db->setQuery($query)->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate backup code is the flag is set.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function generateBackupCodes(): void
|
||||
{
|
||||
/** @var MVCFactoryInterface $factory */
|
||||
$factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();
|
||||
|
||||
/** @var BackupcodesModel $backupCodes */
|
||||
$backupCodes = $factory->createModel('Backupcodes', 'Administrator');
|
||||
$user = $this->getUserFactory()->loadUserById($this->user_id);
|
||||
$backupCodes->regenerateBackupCodes($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs after successfully deleting a record
|
||||
*
|
||||
* @param int|array $pk The promary key of the deleted record
|
||||
*
|
||||
* @return void
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function afterDelete($pk): void
|
||||
{
|
||||
if (\is_array($pk)) {
|
||||
$pk = $pk[$this->_tbl_key] ?? array_shift($pk);
|
||||
}
|
||||
|
||||
if (!isset($this->deleteFlags[$pk])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (($this->deleteFlags[$pk]['numRecords'] <= 2) && ($this->deleteFlags[$pk]['method'] != 'backupcodes')) {
|
||||
/**
|
||||
* This was the second to last MFA record in the database (the last one is the `backupcodes`). Therefore, we
|
||||
* need to delete the remaining entry and go away. We don't trigger this if the Method we are deleting was
|
||||
* the `backupcodes` because we might just be regenerating the backup codes.
|
||||
*/
|
||||
$db = $this->getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->delete($db->quoteName('#__user_mfa'))
|
||||
->where($db->quoteName('user_id') . ' = :user_id')
|
||||
->bind(':user_id', $this->deleteFlags[$pk]['user_id'], ParameterType::INTEGER);
|
||||
$db->setQuery($query)->execute();
|
||||
|
||||
unset($this->deleteFlags[$pk]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// This was the default record. Promote the next available record to default.
|
||||
if ($this->deleteFlags[$pk]['default']) {
|
||||
$db = $this->getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__user_mfa'))
|
||||
->where($db->quoteName('user_id') . ' = :user_id')
|
||||
->where($db->quoteName('method') . ' != ' . $db->quote('backupcodes'))
|
||||
->bind(':user_id', $this->deleteFlags[$pk]['user_id'], ParameterType::INTEGER);
|
||||
$ids = $db->setQuery($query)->loadColumn();
|
||||
|
||||
if (empty($ids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id = array_shift($ids);
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__user_mfa'))
|
||||
->set($db->quoteName('default') . ' = 1')
|
||||
->where($db->quoteName('id') . ' = :id')
|
||||
->bind(':id', $id, ParameterType::INTEGER);
|
||||
$db->setQuery($query)->execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of MFA records for a give user ID
|
||||
*
|
||||
* @param int $userId The user ID to check
|
||||
*
|
||||
* @return integer
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function getNumRecords(int $userId): int
|
||||
{
|
||||
$db = $this->getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__user_mfa'))
|
||||
->where($db->quoteName('user_id') . ' = :user_id')
|
||||
->bind(':user_id', $userId, ParameterType::INTEGER);
|
||||
$numOldRecords = $db->setQuery($query)->loadResult();
|
||||
|
||||
return (int) $numOldRecords;
|
||||
}
|
||||
}
|
||||
131
administrator/components/com_users/src/Table/NoteTable.php
Normal file
131
administrator/components/com_users/src/Table/NoteTable.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Administrator
|
||||
* @subpackage com_users
|
||||
*
|
||||
* @copyright (C) 2011 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\Users\Administrator\Table;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\CMS\User\CurrentUserInterface;
|
||||
use Joomla\CMS\User\CurrentUserTrait;
|
||||
use Joomla\CMS\Versioning\VersionableTableInterface;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* User notes table class
|
||||
*
|
||||
* @since 2.5
|
||||
*/
|
||||
class NoteTable extends Table implements VersionableTableInterface, CurrentUserInterface
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
|
||||
/**
|
||||
* Indicates that columns fully support the NULL value in the database
|
||||
*
|
||||
* @var boolean
|
||||
* @since 4.0.0
|
||||
*/
|
||||
protected $_supportNullValue = true;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param DatabaseDriver $db Database connector object
|
||||
* @param ?DispatcherInterface $dispatcher Event dispatcher for this table
|
||||
*
|
||||
* @since 2.5
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db, DispatcherInterface $dispatcher = null)
|
||||
{
|
||||
$this->typeAlias = 'com_users.note';
|
||||
parent::__construct('#__user_notes', 'id', $db, $dispatcher);
|
||||
|
||||
$this->setColumnAlias('published', 'state');
|
||||
}
|
||||
|
||||
/**
|
||||
* Overloaded store method for the notes table.
|
||||
*
|
||||
* @param boolean $updateNulls Toggle whether null values should be updated.
|
||||
*
|
||||
* @return boolean True on success, false on failure.
|
||||
*
|
||||
* @since 2.5
|
||||
*/
|
||||
public function store($updateNulls = true)
|
||||
{
|
||||
$date = Factory::getDate()->toSql();
|
||||
$userId = $this->getCurrentUser()->get('id');
|
||||
|
||||
if (!((int) $this->review_time)) {
|
||||
$this->review_time = null;
|
||||
}
|
||||
|
||||
if ($this->id) {
|
||||
// Existing item
|
||||
$this->modified_time = $date;
|
||||
$this->modified_user_id = $userId;
|
||||
} else {
|
||||
// New record.
|
||||
$this->created_time = $date;
|
||||
$this->created_user_id = $userId;
|
||||
$this->modified_time = $date;
|
||||
$this->modified_user_id = $userId;
|
||||
}
|
||||
|
||||
// Attempt to store the data.
|
||||
return parent::store($updateNulls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to perform sanity checks on the Table instance properties to ensure they are safe to store in the database.
|
||||
*
|
||||
* @return boolean True if the instance is sane and able to be stored in the database.
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function check()
|
||||
{
|
||||
try {
|
||||
parent::check();
|
||||
} catch (\Exception $e) {
|
||||
$this->setError($e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->modified_time)) {
|
||||
$this->modified_time = $this->created_time;
|
||||
}
|
||||
|
||||
if (empty($this->modified_user_id)) {
|
||||
$this->modified_user_id = $this->created_user_id;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type alias for the history table
|
||||
*
|
||||
* @return string The alias as described above
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function getTypeAlias()
|
||||
{
|
||||
return $this->typeAlias;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user