<?php

/**
 * @package     Joomla.Plugin
 * @subpackage  Multifactorauth.webauthn
 *
 * @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\Webauthn;

use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Table\MfaTable;
use RuntimeException;
use Webauthn\AttestationStatement\AttestationStatement;
use Webauthn\AttestedCredentialData;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialSourceRepository;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\TrustPath\EmptyTrustPath;

/**
 * Implementation of the credentials repository for the WebAuthn library.
 *
 * Important assumption: interaction with Webauthn through the library is only performed for the currently logged in
 * user. Therefore all Methods which take a credential ID work by checking the Joomla MFA records of the current
 * user only. This is a necessity. The records are stored encrypted, therefore we cannot do a partial search in the
 * table. We have to load the records, decrypt them and inspect them. We cannot do that for thousands of records but
 * we CAN do that for the few records each user has under their account.
 *
 * This behavior can be changed by passing a user ID in the constructor of the class.
 *
 * @since 4.2.0
 */
class CredentialRepository implements PublicKeyCredentialSourceRepository
{
    /**
     * The user ID we will operate with
     *
     * @var   integer
     * @since 4.2.0
     */
    private $userId = 0;

    /**
     * CredentialRepository constructor.
     *
     * @param   int  $userId  The user ID this repository will be working with.
     *
     * @throws \Exception
     * @since 4.2.0
     */
    public function __construct(int $userId = 0)
    {
        if (empty($userId)) {
            $user = Factory::getApplication()->getIdentity()
                ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);

            $userId = $user->id;
        }

        $this->userId = $userId;
    }

    /**
     * Finds a WebAuthn record given a credential ID
     *
     * @param   string  $publicKeyCredentialId  The public credential ID to look for
     *
     * @return  PublicKeyCredentialSource|null
     * @since   4.2.0
     */
    public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
    {
        $publicKeyCredentialUserEntity = new PublicKeyCredentialUserEntity('', $this->userId, '', '');
        $credentials                   = $this->findAllForUserEntity($publicKeyCredentialUserEntity);

        foreach ($credentials as $record) {
            if ($record->getAttestedCredentialData()->getCredentialId() != $publicKeyCredentialId) {
                continue;
            }

            return $record;
        }

        return null;
    }

    /**
     * Find all WebAuthn entries given a user entity
     *
     * @param   PublicKeyCredentialUserEntity  $publicKeyCredentialUserEntity The user entity to search by
     *
     * @return  array|PublicKeyCredentialSource[]
     * @throws  \Exception
     * @since   4.2.0
     */
    public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
    {
        if (empty($publicKeyCredentialUserEntity)) {
            $userId = $this->userId;
        } else {
            $userId = $publicKeyCredentialUserEntity->getId();
        }

        $return = [];

        $results = MfaHelper::getUserMfaRecords($userId);

        if (count($results) < 1) {
            return $return;
        }

        /** @var MfaTable $result */
        foreach ($results as $result) {
            $options = $result->options;

            if (!is_array($options) || empty($options)) {
                continue;
            }

            if (!isset($options['attested']) && !isset($options['pubkeysource'])) {
                continue;
            }

            if (isset($options['attested']) && is_string($options['attested'])) {
                $options['attested'] = json_decode($options['attested'], true);

                $return[$result->id] = $this->attestedCredentialToPublicKeyCredentialSource(
                    AttestedCredentialData::createFromArray($options['attested']),
                    $userId
                );
            } elseif (isset($options['pubkeysource']) && is_string($options['pubkeysource'])) {
                $options['pubkeysource'] = json_decode($options['pubkeysource'], true);
                $return[$result->id]     = PublicKeyCredentialSource::createFromArray($options['pubkeysource']);
            } elseif (isset($options['pubkeysource']) && is_array($options['pubkeysource'])) {
                $return[$result->id] = PublicKeyCredentialSource::createFromArray($options['pubkeysource']);
            }
        }

        return $return;
    }

    /**
     * Converts a legacy AttestedCredentialData object stored in the database into a PublicKeyCredentialSource object.
     *
     * This makes several assumptions which can be problematic and the reason why the WebAuthn library version 2 moved
     * away from attested credentials to public key credential sources:
     *
     * - The credential is always of the public key type (that's safe as the only option supported)
     * - You can access it with any kind of authenticator transport: USB, NFC, Internal or Bluetooth LE (possibly
     * dangerous)
     * - There is no attestations (generally safe since browsers don't seem to support attestation yet)
     * - There is no trust path (generally safe since browsers don't seem to provide one)
     * - No counter was stored (dangerous since it can lead to replay attacks).
     *
     * @param   AttestedCredentialData  $record  Legacy attested credential data object
     * @param   int                     $userId  User ID we are getting the credential source for
     *
     * @return  PublicKeyCredentialSource
     * @since   4.2.0
     */
    private function attestedCredentialToPublicKeyCredentialSource(AttestedCredentialData $record, int $userId): PublicKeyCredentialSource
    {
        return new PublicKeyCredentialSource(
            $record->getCredentialId(),
            PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
            [
                PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_USB,
                PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_NFC,
                PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_INTERNAL,
                PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_BLE,
            ],
            AttestationStatement::TYPE_NONE,
            new EmptyTrustPath(),
            $record->getAaguid(),
            $record->getCredentialPublicKey(),
            $userId,
            0
        );
    }

    /**
     * Save a WebAuthn record
     *
     * @param   PublicKeyCredentialSource  $publicKeyCredentialSource  The record to save
     *
     * @return  void
     * @throws  \Exception
     * @since   4.2.0
     */
    public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
    {
        // I can only create or update credentials for the user this class was created for
        if ($publicKeyCredentialSource->getUserHandle() != $this->userId) {
            throw new RuntimeException('Cannot create or update WebAuthn credentials for a different user.', 403);
        }

        // Do I have an existing record for this credential?
        $recordId                      = null;
        $publicKeyCredentialUserEntity = new PublicKeyCredentialUserEntity('', $this->userId, '', '');
        $credentials                   = $this->findAllForUserEntity($publicKeyCredentialUserEntity);

        foreach ($credentials as $id => $record) {
            if ($record->getAttestedCredentialData()->getCredentialId() != $publicKeyCredentialSource->getAttestedCredentialData()->getCredentialId()) {
                continue;
            }

            $recordId = $id;

            break;
        }

        // Create or update a record
        /** @var MVCFactoryInterface $factory */
        $factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();
        /** @var MfaTable $mfaTable */
        $mfaTable = $factory->createTable('Mfa', 'Administrator');

        if ($recordId) {
            $mfaTable->load($recordId);

            $options = $mfaTable->options;

            if (isset($options['attested'])) {
                unset($options['attested']);
            }

            $options['pubkeysource'] = $publicKeyCredentialSource;
            $mfaTable->save(
                [
                    'options' => $options
                ]
            );
        } else {
            $mfaTable->reset();
            $mfaTable->save(
                [
                    'user_id' => $this->userId,
                    'title'   => 'WebAuthn auto-save',
                    'method'  => 'webauthn',
                    'default' => 0,
                    'options' => ['pubkeysource' => $publicKeyCredentialSource],
                ]
            );
        }
    }
}
