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

use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\CMS\WebAuthn\Server;
use Joomla\Plugin\Multifactorauth\Webauthn\CredentialRepository;
use Joomla\Session\SessionInterface;
use Laminas\Diactoros\ServerRequestFactory;
use Webauthn\AttestedCredentialData;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;

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

/**
 * Helper class to aid in credentials creation (link an authenticator to a user account)
 *
 * @since 4.2.0
 */
abstract class Credentials
{
    /**
     * Authenticator registration step 1: create a public key for credentials attestation.
     *
     * The result is a JSON string which can be used in Javascript code with navigator.credentials.create().
     *
     * @param   User   $user   The Joomla user to create the public key for
     *
     * @return  string
     * @throws  \Exception  On error
     * @since   4.2.0
     */
    public static function requestAttestation(User $user): string
    {
        $publicKeyCredentialCreationOptions = self::getWebauthnServer($user->id)
            ->generatePublicKeyCredentialCreationOptions(
                self::getUserEntity($user),
                PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
                self::getPubKeyDescriptorsForUser($user),
                new AuthenticatorSelectionCriteria(
                    AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE,
                    false,
                    AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED
                ),
                new AuthenticationExtensionsClientInputs()
            );

        // Save data in the session
        $session = Factory::getApplication()->getSession();

        $session->set(
            'plg_multifactorauth_webauthn.publicKeyCredentialCreationOptions',
            base64_encode(serialize($publicKeyCredentialCreationOptions))
        );
        $session->set('plg_multifactorauth_webauthn.registration_user_id', $user->id);

        return json_encode($publicKeyCredentialCreationOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }

    /**
     * Authenticator registration step 2: verify the credentials attestation by the authenticator
     *
     * This returns the attested credential data on success.
     *
     * An exception will be returned on error. Also, under very rare conditions, you may receive NULL instead of
     * attested credential data which means that something was off in the returned data from the browser.
     *
     * @param   string   $data   The JSON-encoded data returned by the browser during the authentication flow
     *
     * @return  AttestedCredentialData|null
     * @throws  \Exception  When something does not check out
     * @since   4.2.0
     */
    public static function verifyAttestation(string $data): ?PublicKeyCredentialSource
    {
        $session = Factory::getApplication()->getSession();

        // Retrieve the PublicKeyCredentialCreationOptions object created earlier and perform sanity checks
        $encodedOptions = $session->get('plg_multifactorauth_webauthn.publicKeyCredentialCreationOptions', null);

        if (empty($encodedOptions)) {
            throw new \RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_NO_PK'));
        }

        try {
            $publicKeyCredentialCreationOptions = unserialize(base64_decode($encodedOptions));
        } catch (\Exception $e) {
            $publicKeyCredentialCreationOptions = null;
        }

        if (!\is_object($publicKeyCredentialCreationOptions) || !($publicKeyCredentialCreationOptions instanceof PublicKeyCredentialCreationOptions)) {
            throw new \RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_NO_PK'));
        }

        // Retrieve the stored user ID and make sure it's the same one in the request.
        $storedUserId = $session->get('plg_multifactorauth_webauthn.registration_user_id', 0);
        $myUser       = Factory::getApplication()->getIdentity()
            ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
        $myUserId     = $myUser->id;

        if (($myUser->guest) || ($myUserId != $storedUserId)) {
            throw new \RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_USER'));
        }

        return self::getWebauthnServer($myUser->id)->loadAndCheckAttestationResponse(
            base64_decode($data),
            $publicKeyCredentialCreationOptions,
            ServerRequestFactory::fromGlobals()
        );
    }

    /**
     * Authentication step 1: create a challenge for key verification
     *
     * @param   int  $userId  The user ID to create a WebAuthn PK for
     *
     * @return  string
     * @throws  \Exception  On error
     * @since   4.2.0
     */
    public static function requestAssertion(int $userId): string
    {
        $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);

        $publicKeyCredentialRequestOptions = self::getWebauthnServer($userId)
            ->generatePublicKeyCredentialRequestOptions(
                PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED,
                self::getPubKeyDescriptorsForUser($user)
            );

        // Save in session. This is used during the verification stage to prevent replay attacks.
        /** @var SessionInterface $session */
        $session = Factory::getApplication()->getSession();
        $session->set('plg_multifactorauth_webauthn.publicKeyCredentialRequestOptions', base64_encode(serialize($publicKeyCredentialRequestOptions)));
        $session->set('plg_multifactorauth_webauthn.userHandle', $userId);
        $session->set('plg_multifactorauth_webauthn.userId', $userId);

        // Return the JSON encoded data to the caller
        return json_encode($publicKeyCredentialRequestOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }

    /**
     * Authentication step 2: Checks if the browser's response to our challenge is valid.
     *
     * @param   string   $response   Base64-encoded response
     *
     * @return  void
     * @throws  \Exception  When something does not check out.
     * @since   4.2.0
     */
    public static function verifyAssertion(string $response): void
    {
        /** @var SessionInterface $session */
        $session = Factory::getApplication()->getSession();

        $encodedPkOptions = $session->get('plg_multifactorauth_webauthn.publicKeyCredentialRequestOptions', null);
        $userHandle       = $session->get('plg_multifactorauth_webauthn.userHandle', null);
        $userId           = $session->get('plg_multifactorauth_webauthn.userId', null);

        $session->set('plg_multifactorauth_webauthn.publicKeyCredentialRequestOptions', null);
        $session->set('plg_multifactorauth_webauthn.userHandle', null);
        $session->set('plg_multifactorauth_webauthn.userId', null);

        if (empty($userId)) {
            throw new \RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
        }

        // Make sure the user exists
        $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);

        if ($user->id != $userId) {
            throw new \RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
        }

        // Make sure the user is ourselves (we cannot perform MFA on behalf of another user!)
        $currentUser = Factory::getApplication()->getIdentity()
            ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);

        if ($currentUser->id != $userId) {
            throw new \RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
        }

        // Make sure the public key credential request options in the session are valid
        $serializedOptions                 = base64_decode($encodedPkOptions);
        $publicKeyCredentialRequestOptions = unserialize($serializedOptions);

        if (
            !\is_object($publicKeyCredentialRequestOptions)
            || empty($publicKeyCredentialRequestOptions)
            || !($publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions)
        ) {
            throw new \RuntimeException(Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
        }

        // Unserialize the browser response data
        $data = base64_decode($response);

        self::getWebauthnServer($user->id)->loadAndCheckAssertionResponse(
            $data,
            $publicKeyCredentialRequestOptions,
            self::getUserEntity($user),
            ServerRequestFactory::fromGlobals()
        );
    }

    /**
     * Get the user's avatar (through Gravatar)
     *
     * @param   User   $user   The Joomla user object
     * @param   int    $size   The dimensions of the image to fetch (default: 64 pixels)
     *
     * @return  string  The URL to the user's avatar
     *
     * @since 4.2.0
     */
    private static function getAvatar(User $user, int $size = 64)
    {
        $scheme    = Uri::getInstance()->getScheme();
        $subdomain = ($scheme == 'https') ? 'secure' : 'www';

        return sprintf('%s://%s.gravatar.com/avatar/%s.jpg?s=%u&d=mm', $scheme, $subdomain, md5($user->email), $size);
    }

    /**
     * Get a WebAuthn user entity for a Joomla user
     *
     * @param   User   $user  The user to get an entity for
     *
     * @return  PublicKeyCredentialUserEntity
     * @since   4.2.0
     */
    private static function getUserEntity(User $user): PublicKeyCredentialUserEntity
    {
        return new PublicKeyCredentialUserEntity(
            $user->username,
            $user->id,
            $user->name,
            self::getAvatar($user, 64)
        );
    }

    /**
     * Get the WebAuthn library server object
     *
     * @param   int|null  $userId  The user ID holding the list of valid authenticators
     *
     * @return  Server
     * @since   4.2.0
     */
    private static function getWebauthnServer(?int $userId): Server
    {
        /** @var CMSApplication $app */
        try {
            $app      = Factory::getApplication();
            $siteName = $app->get('sitename');
        } catch (\Exception $e) {
            $siteName = 'Joomla! Site';
        }

        // Credentials repository
        $repository = new CredentialRepository($userId);

        // Relaying Party -- Our site
        $rpEntity = new PublicKeyCredentialRpEntity(
            $siteName ?? 'Joomla! Site',
            Uri::getInstance()->toString(['host']),
            ''
        );

        $refClass       = new \ReflectionClass(Server::class);
        $refConstructor = $refClass->getConstructor();
        $params         = $refConstructor->getParameters();

        if (\count($params) === 3) {
            // WebAuthn library 2, 3
            $server = new Server($rpEntity, $repository, null);
        } else {
            // WebAuthn library 4 (based on the deprecated comments in library version 3)
            $server = new Server($rpEntity, $repository);
        }

        // Ed25519 is only available with libsodium
        if (!\function_exists('sodium_crypto_sign_seed_keypair')) {
            $server->setSelectedAlgorithms(['RS256', 'RS512', 'PS256', 'PS512', 'ES256', 'ES512']);
        }

        return $server;
    }

    /**
     * Returns an array of the PK credential descriptors (registered authenticators) for the given user.
     *
     * @param   User   $user  The user to get the descriptors for
     *
     * @return  PublicKeyCredentialDescriptor[]
     * @since   4.2.0
     */
    private static function getPubKeyDescriptorsForUser(User $user): array
    {
        $userEntity  = self::getUserEntity($user);
        $repository  = new CredentialRepository($user->id);
        $descriptors = [];
        $records     = $repository->findAllForUserEntity($userEntity);

        foreach ($records as $record) {
            $descriptors[] = $record->getPublicKeyCredentialDescriptor();
        }

        return $descriptors;
    }
}
