<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Core\Authentication\Mfa\Provider;

use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderInterface;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Crypto\HashAlgo;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\View\ViewFactoryData;
use TYPO3\CMS\Core\View\ViewFactoryInterface;

/**
 * MFA provider for time-based one-time password authentication
 *
 * @internal should only be used by the TYPO3 Core
 */
final readonly class TotpProvider implements MfaProviderInterface
{
    private const int MAX_ATTEMPTS = 3;

    public function __construct(
        private Context $context,
        private HashService $hashService,
        private ViewFactoryInterface $viewFactory,
    ) {}

    /**
     * Check if a TOTP is given in the current request
     */
    public function canProcess(ServerRequestInterface $request): bool
    {
        return $this->getTotp($request) !== '';
    }

    /**
     * Evaluate if the provider is activated by checking the
     * active state and the secret from the provider properties.
     */
    public function isActive(MfaProviderPropertyManager $propertyManager): bool
    {
        return (bool)$propertyManager->getProperty('active')
            && $propertyManager->getProperty('secret', '') !== '';
    }

    /**
     * Evaluate if the provider is temporarily locked by checking
     * the current attempts state from the provider properties.
     */
    public function isLocked(MfaProviderPropertyManager $propertyManager): bool
    {
        $attempts = (int)$propertyManager->getProperty('attempts', 0);
        // Assume the provider is locked in case the maximum attempts are exceeded.
        // A provider however can only be locked if set up - an entry exists in database.
        return $propertyManager->hasProviderEntry() && $attempts >= self::MAX_ATTEMPTS;
    }

    /**
     * Verify the given TOTP and update the provider properties in case the TOTP is valid.
     */
    public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
    {
        if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) {
            // Can not verify an inactive or locked provider
            return false;
        }

        $totp = $this->getTotp($request);
        $secret = $propertyManager->getProperty('secret', '');
        $verified = GeneralUtility::makeInstance(Totp::class, $secret)->verifyTotp($totp, 2);
        if (!$verified) {
            $attempts = $propertyManager->getProperty('attempts', 0);
            $propertyManager->updateProperties(['attempts' => ++$attempts]);
            return false;
        }
        $propertyManager->updateProperties([
            'attempts' => 0,
            'lastUsed' => $this->context->getPropertyFromAspect('date', 'timestamp'),
        ]);
        return true;
    }

    /**
     * Activate the provider by checking the necessary parameters,
     * verifying the TOTP and storing the provider properties.
     */
    public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
    {
        if ($this->isActive($propertyManager)) {
            // Can not activate an active provider
            return false;
        }

        if (!$this->canProcess($request)) {
            // Return since the request can not be processed by this provider
            return false;
        }

        $secret = (string)($request->getParsedBody()['secret'] ?? '');
        $checksum = (string)($request->getParsedBody()['checksum'] ?? '');
        if ($secret === '' || !hash_equals($this->hashService->hmac($secret, 'totp-setup', HashAlgo::SHA3_256), $checksum)) {
            // Return since the request does not contain the initially created secret
            return false;
        }

        $totpInstance = GeneralUtility::makeInstance(Totp::class, $secret);
        if (!$totpInstance->verifyTotp($this->getTotp($request), 2)) {
            // Return since the given TOTP could not be verified
            return false;
        }

        // If valid, prepare the provider properties to be stored
        $properties = ['secret' => $secret, 'active' => true];
        if (($name = (string)($request->getParsedBody()['name'] ?? '')) !== '') {
            $properties['name'] = $name;
        }

        // Usually there should be no entry if the provider is not activated, but to prevent the
        // provider from being unable to activate again, we update the existing entry in such case.
        return $propertyManager->hasProviderEntry()
            ? $propertyManager->updateProperties($properties)
            : $propertyManager->createProviderEntry($properties);
    }

    /**
     * Handle the save action by updating the provider properties
     */
    public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
    {
        if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) {
            // Can not update an inactive or locked provider
            return false;
        }
        $name = (string)($request->getParsedBody()['name'] ?? '');
        if ($name !== '') {
            return $propertyManager->updateProperties(['name' => $name]);
        }
        // Provider properties successfully updated
        return true;
    }

    /**
     * Handle the unlock action by resetting the attempts provider property
     */
    public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
    {
        if (!$this->isActive($propertyManager) || !$this->isLocked($propertyManager)) {
            // Can not unlock an inactive or not locked provider
            return false;
        }
        // Reset the attempts
        return $propertyManager->updateProperties(['attempts' => 0]);
    }

    /**
     * Handle the deactivate action. For security reasons, the provider entry
     * is completely deleted and setting up this provider again, will therefore
     * create a brand-new entry.
     */
    public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
    {
        if (!$this->isActive($propertyManager)) {
            // Can not deactivate an inactive provider
            return false;
        }
        // Delete the provider entry
        return $propertyManager->deleteProviderEntry();
    }

    /**
     * Initialize view and forward to the appropriate implementation
     * based on the view type to be returned.
     */
    public function handleRequest(
        ServerRequestInterface $request,
        MfaProviderPropertyManager $propertyManager,
        MfaViewType $type
    ): ResponseInterface {
        $viewFactoryData = new ViewFactoryData(
            templateRootPaths: ['EXT:core/Resources/Private/Templates'],
            partialRootPaths: ['EXT:core/Resources/Private/Partials'],
            layoutRootPaths: ['EXT:core/Resources/Private/Layouts'],
            request: $request,
        );
        $view = $this->viewFactory->create($viewFactoryData);
        switch ($type) {
            case MfaViewType::SETUP:
                // Generate a new shared secret, generate the otpauth URL and create a qr-code for improved usability.
                $userData = $propertyManager->getUser()->user ?? [];
                $secret = Totp::generateEncodedSecret([(string)($userData['uid'] ?? ''), (string)($userData['username'] ?? '')]);
                $totpInstance = GeneralUtility::makeInstance(Totp::class, $secret);
                $totpAuthUrl = $totpInstance->getTotpAuthUrl(
                    (string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? 'TYPO3'),
                    (string)($userData['email'] ?? '') ?: (string)($userData['username'] ?? '')
                );
                $view->assignMultiple([
                    'secret' => $secret,
                    'totpAuthUrl' => $totpAuthUrl,
                    'qrCode' => $this->getSvgQrCode($totpAuthUrl),
                    // Generate hmac of the secret to prevent it from being changed in the setup from
                    'checksum' => $this->hashService->hmac($secret, 'totp-setup', HashAlgo::SHA3_256),
                    'providerIdentifier' => $propertyManager->getIdentifier(),
                ]);
                return new HtmlResponse($view->render('Authentication/MfaProvider/Totp/Setup'));
            case MfaViewType::EDIT:
                $view->assignMultiple([
                    'name' => $propertyManager->getProperty('name'),
                    'lastUsed' => $this->getDateTime($propertyManager->getProperty('lastUsed', 0)),
                    'updated' => $this->getDateTime($propertyManager->getProperty('updated', 0)),
                    'providerIdentifier' => $propertyManager->getIdentifier(),
                ]);
                return new HtmlResponse($view->render('Authentication/MfaProvider/Totp/Edit'));
            default: // MfaViewType::AUTH
                $view->assignMultiple([
                    'isLocked' => $this->isLocked($propertyManager),
                    'providerIdentifier' => $propertyManager->getIdentifier(),
                ]);
                return new HtmlResponse($view->render('Authentication/MfaProvider/Totp/Auth'));
        }
    }

    /**
     * Internal helper method for fetching the TOTP from the request
     */
    private function getTotp(ServerRequestInterface $request): string
    {
        return trim((string)($request->getQueryParams()['totp'] ?? $request->getParsedBody()['totp'] ?? ''));
    }

    /**
     * Internal helper method for generating a svg QR-code for TOTP applications
     */
    private function getSvgQrCode(string $content): string
    {
        $qrCodeRenderer = new ImageRenderer(new RendererStyle(225, 4), new SvgImageBackEnd());
        return (new Writer($qrCodeRenderer))->writeString($content);
    }

    /**
     * Return the timestamp as local time (date string) by applying the globally configured format
     */
    private function getDateTime(int $timestamp): string
    {
        if ($timestamp === 0) {
            return '';
        }
        return date(
            $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'],
            $timestamp
        ) ?: '';
    }
}
