<?php

/**
 * @package     Joomla.Plugin
 * @subpackage  Multifactorauth.totp
 *
 * @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\Plugin\Multifactorauth\Totp\Extension;

use Joomla\CMS\Encrypt\Totp as TotpHelper;
use Joomla\CMS\Event\MultiFactor\Captive;
use Joomla\CMS\Event\MultiFactor\GetMethod;
use Joomla\CMS\Event\MultiFactor\GetSetup;
use Joomla\CMS\Event\MultiFactor\SaveSetup;
use Joomla\CMS\Event\MultiFactor\Validate;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions;
use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
use Joomla\Component\Users\Administrator\DataShape\SetupRenderOptions;
use Joomla\Component\Users\Administrator\Table\MfaTable;
use Joomla\Event\SubscriberInterface;
use Joomla\Input\Input;
use RuntimeException;

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

/**
 * Joomla! Multi-factor Authentication using Google Authenticator TOTP Plugin
 *
 * @since  3.2
 */
class Totp extends CMSPlugin implements SubscriberInterface
{
    /**
     * Affects constructor behavior. If true, language files will be loaded automatically.
     *
     * @var    boolean
     * @since  3.2
     */
    protected $autoloadLanguage = true;

    /**
     * The MFA Method name handled by this plugin
     *
     * @var   string
     * @since 4.2.0
     */
    private $mfaMethodName = 'totp';


    /**
     * Should I try to detect and register legacy event listeners, i.e. methods which accept unwrapped arguments? While
     * this maintains a great degree of backwards compatibility to Joomla! 3.x-style plugins it is much slower. You are
     * advised to implement your plugins using proper Listeners, methods accepting an AbstractEvent as their sole
     * parameter, for best performance. Also bear in mind that Joomla! 5.x onwards will only allow proper listeners,
     * removing support for legacy Listeners.
     *
     * @var    boolean
     * @since  4.2.0
     *
     * @deprecated  4.3 will be removed in 6.0
     *              Implement your plugin methods accepting an AbstractEvent object
     *              Example:
     *              onEventTriggerName(AbstractEvent $event) {
     *                  $context = $event->getArgument(...);
     *              }
     */
    protected $allowLegacyListeners = false;

    /**
     * Returns an array of events this subscriber will listen to.
     *
     * @return  array
     *
     * @since   4.2.0
     */
    public static function getSubscribedEvents(): array
    {
        return [
            'onUserMultifactorGetMethod' => 'onUserMultifactorGetMethod',
            'onUserMultifactorCaptive'   => 'onUserMultifactorCaptive',
            'onUserMultifactorGetSetup'  => 'onUserMultifactorGetSetup',
            'onUserMultifactorSaveSetup' => 'onUserMultifactorSaveSetup',
            'onUserMultifactorValidate'  => 'onUserMultifactorValidate',
        ];
    }

    /**
     * Gets the identity of this MFA Method
     *
     * @param   GetMethod  $event  The event we are handling
     *
     * @return  void
     * @since   4.2.0
     */
    public function onUserMultifactorGetMethod(GetMethod $event): void
    {
        $event->addResult(
            new MethodDescriptor(
                [
                    'name'      => $this->mfaMethodName,
                    'display'   => Text::_('PLG_MULTIFACTORAUTH_TOTP_METHOD_TITLE'),
                    'shortinfo' => Text::_('PLG_MULTIFACTORAUTH_TOTP_SHORTINFO'),
                    'image'     => 'media/plg_multifactorauth_totp/images/totp.svg',
                ]
            )
        );
    }

    /**
     * Returns the information which allows Joomla to render the Captive MFA page. This is the page
     * which appears right after you log in and asks you to validate your login with MFA.
     *
     * @param   Captive  $event  The event we are handling
     *
     * @return  void
     * @since   4.2.0
     */
    public function onUserMultifactorCaptive(Captive $event): void
    {
        /**
         * @var   MfaTable $record The record currently selected by the user.
         */
        $record = $event['record'];

        // Make sure we are actually meant to handle this Method
        if ($record->method !== $this->mfaMethodName) {
            return;
        }

        $event->addResult(
            new CaptiveRenderOptions(
                [
                    // Custom HTML to display above the MFA form
                    'pre_message' => Text::_('PLG_MULTIFACTORAUTH_TOTP_CAPTIVE_PROMPT'),
                    // How to render the MFA code field. "input" (HTML input element) or "custom" (custom HTML)
                    'field_type' => 'input',
                    // The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type.
                    'input_type' => 'text',
                    // The attributes for the HTML input box.
                    'input_attributes' => [
                        'pattern' => "{0,9}", 'maxlength' => "6", 'inputmode' => "numeric",
                    ],
                    // Placeholder text for the HTML input box. Leave empty if you don't need it.
                    'placeholder' => '',
                    // Label to show above the HTML input box. Leave empty if you don't need it.
                    'label' => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_LABEL'),
                    // Custom HTML. Only used when field_type = custom.
                    'html' => '',
                    // Custom HTML to display below the MFA form
                    'post_message' => '',
                ]
            )
        );
    }

    /**
     * Returns the information which allows Joomla to render the MFA setup page. This is the page
     * which allows the user to add or modify a MFA Method for their user account. If the record
     * does not correspond to your plugin return an empty array.
     *
     * @param   GetSetup  $event  The event we are handling
     *
     * @return  void
     * @since   4.2.0
     */
    public function onUserMultifactorGetSetup(GetSetup $event): void
    {
        /**
         * @var   MfaTable $record The record currently selected by the user.
         */
        $record = $event['record'];

        // Make sure we are actually meant to handle this Method
        if ($record->method !== $this->mfaMethodName) {
            return;
        }

        $totp = new TotpHelper();

        // Load the options from the record (if any)
        $options      = $this->decodeRecordOptions($record);
        $key          = $options['key'] ?? '';
        $session      = $this->getApplication()->getSession();
        $isConfigured = !empty($key);

        // If there's a key in the session use that instead.
        $sessionKey = $session->get('com_users.totp.key', null);

        if (!empty($sessionKey)) {
            $key = $sessionKey;
        }

        // If there's still no key in the options, generate one and save it in the session
        if (empty($key)) {
            $key = $totp->generateSecret();
            $session->set('com_users.totp.key', $key);
        }

        // Generate a QR code for the key
        $user     = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($record->user_id);
        $hostname = Uri::getInstance()->toString(['host']);
        $otpURL   = sprintf("otpauth://totp/%s@%s?secret=%s", $user->username, $hostname, $key);
        $document = $this->getApplication()->getDocument();
        $wam      = $document->getWebAssetManager();

        $document->addScriptOptions('plg_multifactorauth_totp.totp.qr', $otpURL);

        $wam->getRegistry()->addExtensionRegistryFile('plg_multifactorauth_totp');
        $wam->useScript('plg_multifactorauth_totp.setup');

        $event->addResult(
            new SetupRenderOptions(
                [
                    'default_title' => Text::_('PLG_MULTIFACTORAUTH_TOTP_METHOD_TITLE'),
                    'pre_message'   => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_INSTRUCTIONS'),
                    'table_heading' => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_HEADING'),
                    'tabular_data'  => [
                        ''                                                      => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_SUBHEAD'),
                        Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_KEY') => $key,
                        Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_QR')  => "<span id=\"users-mfa-totp-qrcode\" />",
                        Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK')
                        => Text::sprintf('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK_TEXT', $otpURL) .
                            '<br/><small>' . Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK_NOTE') . '</small>',
                    ],
                    'hidden_data' => [
                        'key' => $key,
                    ],
                    'input_type'       => $isConfigured ? 'hidden' : 'text',
                    'input_attributes' => [
                        'pattern' => "{0,9}", 'maxlength' => "6", 'inputmode' => "numeric",
                    ],
                    'input_value' => '',
                    'placeholder' => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_PLACEHOLDER'),
                    'label'       => Text::_('PLG_MULTIFACTORAUTH_TOTP_LBL_LABEL'),
                ]
            )
        );
    }

    /**
     * Parse the input from the MFA setup page and return the configuration information to be saved to the database. If
     * the information is invalid throw a RuntimeException to signal the need to display the editor page again. The
     * message of the exception will be displayed to the user. If the record does not correspond to your plugin return
     * an empty array.
     *
     * @param   SaveSetup  $event  The event we are handling
     *
     * @return  void The configuration data to save to the database
     * @since   4.2.0
     */
    public function onUserMultifactorSaveSetup(SaveSetup $event): void
    {
        /**
         * @var   MfaTable $record The record currently selected by the user.
         * @var   Input    $input  The user input you are going to take into account.
         */
        $record = $event['record'];
        $input  = $event['input'];

        // Make sure we are actually meant to handle this Method
        if ($record->method != $this->mfaMethodName) {
            return;
        }

        // Load the options from the record (if any)
        $options    = $this->decodeRecordOptions($record);
        $optionsKey = $options['key'] ?? '';
        $key        = $optionsKey;
        $session    = $this->getApplication()->getSession();

        // If there is no key in the options fetch one from the session
        if (empty($key)) {
            $key = $session->get('com_users.totp.key', null);
        }

        // If there is still no key in the options throw an error
        if (empty($key)) {
            throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }

        /**
         * If the code is empty but the key already existed in $options someone is simply changing the title / default
         * Method status. We can allow this and stop checking anything else now.
         */
        $code = $input->getInt('code');

        if (empty($code) && !empty($optionsKey)) {
            $event->addResult($options);

            return;
        }

        // In any other case validate the submitted code
        $totp    = new TotpHelper();
        $isValid = $totp->checkCode($key, $code);

        if (!$isValid) {
            throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_TOTP_ERR_VALIDATIONFAILED'), 500);
        }

        // The code is valid. Unset the key from the session.
        $session->set('com_users.totp.key', null);

        // Return the configuration to be serialized
        $event->addResult(
            [
                'key' => $key,
            ]
        );
    }

    /**
     * Validates the Multi-factor Authentication code submitted by the user in the Multi-Factor
     * Authentication page. If the record does not correspond to your plugin return FALSE.
     *
     * @param   Validate  $event  The event we are handling
     *
     * @return  void
     * @since   4.2.0
     */
    public function onUserMultifactorValidate(Validate $event): void
    {
        /**
         * @var   MfaTable $record The MFA Method's record you're validating against
         * @var   User     $user   The user record
         * @var   string   $code   The submitted code
         */
        $record = $event['record'];
        $user   = $event['user'];
        $code   = $event['code'];

        // Make sure we are actually meant to handle this Method
        if ($record->method !== $this->mfaMethodName) {
            $event->addResult(false);

            return;
        }

        // Double check the MFA Method is for the correct user
        if ($user->id != $record->user_id) {
            $event->addResult(false);

            return;
        }

        // Load the options from the record (if any)
        $options = $this->decodeRecordOptions($record);
        $key     = $options['key'] ?? '';

        // If there is no key in the options throw an error
        if (empty($key)) {
            $event->addResult(false);

            return;
        }

        // Check the MFA code for validity
        $event->addResult((new TotpHelper())->checkCode($key, $code));
    }

    /**
     * Decodes the options from a record into an options object.
     *
     * @param   MfaTable  $record  The record to decode options for
     *
     * @return  array
     * @since   4.2.0
     */
    private function decodeRecordOptions(MfaTable $record): array
    {
        $options = [
            'key' => '',
        ];

        if (!empty($record->options)) {
            $recordOptions = $record->options;

            $options = array_merge($options, $recordOptions);
        }

        return $options;
    }
}
