<?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\Model;

use Joomla\CMS\Crypt\Crypt;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\User\User;
use Joomla\Component\Users\Administrator\Table\MfaTable;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Model for managing backup codes
 *
 * @since 4.2.0
 */
class BackupcodesModel extends BaseDatabaseModel
{
    /**
     * Caches the backup codes per user ID
     *
     * @var  array
     * @since 4.2.0
     */
    protected $cache = [];

    /**
     * Get the backup codes record for the specified user
     *
     * @param   User|null   $user   The user in question. Use null for the currently logged in user.
     *
     * @return  MfaTable|null  Record object or null if none is found
     * @throws  \Exception
     * @since 4.2.0
     */
    public function getBackupCodesRecord(User $user = null): ?MfaTable
    {
        // Make sure I have a user
        if (empty($user)) {
            $user = $this->getCurrentUser();
        }

        /** @var MfaTable $record */
        $record = $this->getTable('Mfa', 'Administrator');
        $loaded = $record->load(
            [
                'user_id' => $user->id,
                'method'  => 'backupcodes',
            ]
        );

        if (!$loaded) {
            $record = null;
        }

        return $record;
    }

    /**
     * Generate a new set of backup codes for the specified user. The generated codes are immediately saved to the
     * database and the internal cache is updated.
     *
     * @param   User|null   $user   Which user to generate codes for?
     *
     * @return void
     * @throws \Exception
     * @since 4.2.0
     */
    public function regenerateBackupCodes(User $user = null): void
    {
        // Make sure I have a user
        if (empty($user)) {
            $user = $this->getCurrentUser();
        }

        // Generate backup codes
        $backupCodes = [];

        for ($i = 0; $i < 10; $i++) {
            // Each backup code is 2 groups of 4 digits
            $backupCodes[$i] = sprintf('%04u%04u', random_int(0, 9999), random_int(0, 9999));
        }

        // Save the backup codes to the database and update the cache
        $this->saveBackupCodes($backupCodes, $user);
    }

    /**
     * Saves the backup codes to the database
     *
     * @param   array       $codes   An array of exactly 10 elements
     * @param   User|null   $user    The user for which to save the backup codes
     *
     * @return  boolean
     * @throws  \Exception
     * @since 4.2.0
     */
    public function saveBackupCodes(array $codes, ?User $user = null): bool
    {
        // Make sure I have a user
        if (empty($user)) {
            $user = $this->getCurrentUser();
        }

        // Try to load existing backup codes
        $existingCodes = $this->getBackupCodes($user);
        $jNow          = Date::getInstance();

        /** @var MfaTable $record */
        $record = $this->getTable('Mfa', 'Administrator');

        if (is_null($existingCodes)) {
            $record->reset();

            $newData = [
                'user_id'    => $user->id,
                'title'      => Text::_('COM_USERS_USER_BACKUPCODES'),
                'method'     => 'backupcodes',
                'default'    => 0,
                'created_on' => $jNow->toSql(),
                'options'    => $codes,
            ];
        } else {
            $record->load(
                [
                    'user_id' => $user->id,
                    'method'  => 'backupcodes',
                ]
            );

            $newData = [
                'options' => $codes,
            ];
        }

        $saved = $record->save($newData);

        if (!$saved) {
            return false;
        }

        // Finally, update the cache
        $this->cache[$user->id] = $codes;

        return true;
    }

    /**
     * Returns the backup codes for the specified user. Cached values will be preferentially returned, therefore you
     * MUST go through this model's Methods ONLY when dealing with backup codes.
     *
     * @param   User|null   $user   The user for which you want the backup codes
     *
     * @return  array|null  The backup codes, or null if they do not exist
     * @throws  \Exception
     * @since 4.2.0
     */
    public function getBackupCodes(User $user = null): ?array
    {
        // Make sure I have a user
        if (empty($user)) {
            $user = $this->getCurrentUser();
        }

        if (isset($this->cache[$user->id])) {
            return $this->cache[$user->id];
        }

        // If there is no cached record try to load it from the database
        $this->cache[$user->id] = null;

        // Try to load the record
        /** @var MfaTable $record */
        $record = $this->getTable('Mfa', 'Administrator');
        $loaded = $record->load(
            [
                'user_id' => $user->id,
                'method'  => 'backupcodes',
            ]
        );

        if ($loaded) {
            $this->cache[$user->id] = $record->options;
        }

        return $this->cache[$user->id];
    }

    /**
     * Check if the provided string is a backup code. If it is, it will be removed from the list (replaced with an empty
     * string) and the codes will be saved to the database. All comparisons are performed in a timing safe manner.
     *
     * @param   string      $code   The code to check
     * @param   User|null   $user   The user to check against
     *
     * @return  boolean
     * @throws  \Exception
     * @since 4.2.0
     */
    public function isBackupCode($code, ?User $user = null): bool
    {
        // Load the backup codes
        $codes = $this->getBackupCodes($user) ?: array_fill(0, 10, '');

        // Keep only the numbers in the provided $code
        $code = filter_var($code, FILTER_SANITIZE_NUMBER_INT);
        $code = trim($code);

        // Check if the code is in the array. We always check against ten codes to prevent timing attacks which
        // determine the amount of codes.
        $result = false;

        // The two arrays let us always add an element to an array, therefore having PHP expend the same amount of time
        // for the correct code, the incorrect codes and the fake codes.
        $newArray   = [];
        $dummyArray = [];

        $realLength = count($codes);
        $restLength = 10 - $realLength;

        for ($i = 0; $i < $realLength; $i++) {
            if (hash_equals($codes[$i], $code)) {
                // This may seem redundant but makes sure both branches of the if-block are isochronous
                $result       = $result || true;
                $newArray[]   = '';
                $dummyArray[] = $codes[$i];
            } else {
                // This may seem redundant but makes sure both branches of the if-block are isochronous
                $result       = $result || false;
                $dummyArray[] = '';
                $newArray[]   = $codes[$i];
            }
        }

        /**
         * This is an intentional waste of time, symmetrical to the code above, making sure
         * evaluating each of the total of ten elements takes the same time. This code should never
         * run UNLESS someone messed up with our backup codes array and it no longer contains 10
         * elements.
         */
        $otherResult = false;

        $temp1 = '';

        for ($i = 0; $i < 10; $i++) {
            $temp1[$i] = random_int(0, 99999999);
        }

        for ($i = 0; $i < $restLength; $i++) {
            if (Crypt::timingSafeCompare($temp1[$i], $code)) {
                $otherResult  = $otherResult || true;
                $newArray[]   = '';
                $dummyArray[] = $temp1[$i];
            } else {
                $otherResult  = $otherResult || false;
                $newArray[]   = '';
                $dummyArray[] = $temp1[$i];
            }
        }

        // This last check makes sure than an empty code does not validate
        $result = $result && !hash_equals('', $code);

        // Save the backup codes
        $this->saveBackupCodes($newArray, $user);

        // Finally return the result
        return $result;
    }
}
