HEX
Server: Apache
System: Linux WWW 6.1.0-40-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.153-1 (2025-09-20) x86_64
User: web11 (1011)
PHP: 8.2.29
Disabled: NONE
Upload Files
File: /var/www/apklausos/application/libraries/PluginManager/SmtpOAuthPluginBase.php
<?php

namespace LimeSurvey\PluginManager;

use League\OAuth2\Client\Grant\RefreshToken;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use PHPMailer\PHPMailer\OAuth;

abstract class SmtpOAuthPluginBase extends EmailPluginBase
{
    const SETUP_STATUS_VALID_REFRESH_TOKEN = 1;
    const SETUP_STATUS_REQUIREMENT_UNMET = 2;
    const SETUP_STATUS_INCOMPLETE_CREDENTIALS = 3;
    const SETUP_STATUS_MISSING_REFRESH_TOKEN = 4;
    const SETUP_STATUS_INVALID_REFRESH_TOKEN = 5;

    /** @var string[] The names of attributes that form part of the credentials set. Example: ['clientId', 'clientSecret'] */
    protected $credentialAttributes = [];

    protected function getRedirectUri()
    {
        return $this->api->createUrl('smtpOAuth/receiveOAuthResponse', []);
    }

    /**
     * Returns the OAuth provider object for the specified credentials
     * @param array<string,mixed> $credentials
     * @return League\OAuth2\Client\Provider\AbstractProvider
     */
    abstract protected function getProvider($credentials);

    /**
     * Returns true if the credentials have changed
     */
    protected function haveCredentialsChanged($oldCredentials, $newCredentials)
    {
        foreach ($this->credentialAttributes as $attribute) {
            if ($oldCredentials[$attribute] != $newCredentials[$attribute]) {
                return true;
            }
        }
        return false;
    }

    /**
     * Clears the stored refresh token
     */
    protected function clearRefreshToken()
    {
        $this->set('refreshToken', null);
        $this->set('refreshTokenMetadata', []);
        $this->set('email', null);
    }

    /**
     * Saves the specified refresh token and associated credentials
     */
    protected function saveRefreshToken($refreshToken, $credentials)
    {
        $this->set('refreshToken', $refreshToken);
        $this->set('refreshTokenMetadata', $credentials);
    }

    /**
     * @inheritdoc
     * Handle changes in credentials
     */
    public function saveSettings($settings)
    {
        // Get the current credentials (before saving new settings)
        $oldCredentials = $this->getCredentials();

        // Save settings
        parent::saveSettings($settings);

        // Get new credentials
        $newCredentials = $this->getCredentials();

        // If credentials changed, we need to clear the stored refresh token
        if ($this->haveCredentialsChanged($oldCredentials, $newCredentials)) {
            $this->clearRefreshToken();
        }
    }

    /**
     * Returns true if the specified refresh token is valid
     * @param string $refreshToken
     * @param array<string,mixed> $credentials
     * @return bool
     */
    protected function validateRefreshToken($refreshToken, $credentials)
    {
        $refreshTokenMetadata = $this->get('refreshTokenMetadata') ?? [];

        // Check that the credentials match the ones used to get the refresh token
        foreach ($this->credentialAttributes as $attribute) {
            if (empty($refreshTokenMetadata[$attribute])) {
                return false;
            }
            if ($credentials[$attribute] != $refreshTokenMetadata[$attribute]) {
                return false;
            }
        }

        $provider = $this->getProvider($credentials);
        try {
            $token = $provider->getAccessToken(
                new RefreshToken(),
                ['refresh_token' => $refreshToken]
            );
            // TODO: Handle token with invalid scope (ie. missing https://mail.google.com/)
        } catch (IdentityProviderException $ex) {
            // Don't do anything. Just leave $token unset.
        }
        return !empty($token);
    }

    /**
     * Returns the credentials according to the plugin specified credential attributes
     * @return array<string,mixed> The credentials array
     * @throws Exception if credentials are incomplete.
     */
    protected function getCredentials()
    {
        $credentials = [];
        foreach ($this->credentialAttributes as $attribute) {
            $credentials[$attribute] = $this->get($attribute);
        }
        //if (!$this->validateCredentials($credentials)) {
        //    // TODO: Should we use a different exception class?
        //    throw new \Exception("Incomplete OAuth settings");
        //}
        return $credentials;
    }

    /**
     * Checks if the credentials are valid.
     * By default, it checks if the credentials are not empty.
     * @param array<string,mixed> $credentials
     * @return bool True if the credentials are valid, false otherwise.
     */
    protected function validateCredentials($credentials)
    {
        $incomplete = false;
        foreach ($this->credentialAttributes as $attribute) {
            if (empty($credentials[$attribute])) {
                $incomplete = true;
                break;
            }
        }
        return !$incomplete;
    }

    /**
     * Default handler for the afterReceiveOAuthResponse event
     */
    public function afterReceiveOAuthResponse()
    {
        $event = $this->getEvent();
        if (empty($event)) {
            throw new \CHttpException(403);
        }
        $code = $event->get('code');
        $credentials = $this->getCredentials();

        $this->retrieveRefreshToken($code, $credentials);
    }

    /**
     * Retrieve and store the refresh token
     */
    private function retrieveRefreshToken($code, $credentials)
    {
        $provider = $this->getProvider($credentials);

        // Get an access token (using the authorization code grant)
        $authOptions = $this->getAuthorizationOptions();
        $authOptions = array_merge($authOptions ?? [], ['code' => $code]);
        $token = $provider->getAccessToken('authorization_code', $authOptions);
        $refreshToken = $token->getRefreshToken();

        // Store the token and related credentials (so we can later check if the token belongs to the saved settings)
        $this->saveRefreshToken($refreshToken, $credentials);

        // Do additional processing
        $this->afterRefreshTokenRetrieved($provider, $token);
    }

    /**
     * This method is invoked after the refresh token is retrieved.
     * The default implementation tries to retrieve the email address of the authenticated user.
     * You may override this method to do additional processing.
     * @param AbstractProvider $provider
     * @param AccessTokenInterface $token
     */
    protected function afterRefreshTokenRetrieved($provider, $token)
    {
        $owner = $provider->getResourceOwner($token);
        $this->set('email', $owner->getEmail());
    }

    /**
     * Default handler for beforeRedirectToAuthPage
     */
    public function beforeRedirectToAuthPage()
    {
        $event = $this->getEvent();
        if (empty($event)) {
            throw new \CHttpException(403);
        }
        $credentials = $this->getCredentials();
        $provider = $this->getProvider($credentials);
        $options = $this->getAuthorizationOptions();
        $authUrl = $provider->getAuthorizationUrl($options);
        $event->set('authUrl', $authUrl);
        $event->set('state', $provider->getState());
    }

    /**
     * Returns the OAuth options for authorization (like the scope)
     * @return array<string,mixed>
     */
    abstract protected function getAuthorizationOptions();

    /**
     * Returns true if the plugin is active
     * @return bool
     */
    protected function isActive()
    {
        $pluginModel = \Plugin::model()->findByPk($this->id);
        return $pluginModel->active;
    }

    /**
     * Returns the setup status of the plugin.
     */
    protected function getSetupStatus()
    {
        $credentials = $this->getCredentials();
        if (!$this->validateCredentials($credentials)) {
            return self::SETUP_STATUS_INCOMPLETE_CREDENTIALS;
        }

        $refreshToken = $this->get('refreshToken');
        if (empty($refreshToken)) {
            return self::SETUP_STATUS_MISSING_REFRESH_TOKEN;
        }

        if (!$this->validateRefreshToken($refreshToken, $credentials)) {
            return self::SETUP_STATUS_INVALID_REFRESH_TOKEN;
        }

        return self::SETUP_STATUS_VALID_REFRESH_TOKEN;
    }

    protected function getSetupStatusDescription($setupStatus)
    {
        switch ($setupStatus) {
            case self::SETUP_STATUS_INCOMPLETE_CREDENTIALS:
                return gT("Currently saved credentials are incomplete.");
            case self::SETUP_STATUS_MISSING_REFRESH_TOKEN:
                return gT("No OAuth token.");
            case self::SETUP_STATUS_INVALID_REFRESH_TOKEN:
                return gT("The saved token isn't valid. You need to get a new one.");
            case self::SETUP_STATUS_VALID_REFRESH_TOKEN:
                return gT("Configuration is complete.");
            default:
                return '';
        }
    }

    protected function getHealthStatusClass($setupStatus)
    {
        switch ($setupStatus) {
            case self::SETUP_STATUS_INCOMPLETE_CREDENTIALS:
            case self::SETUP_STATUS_MISSING_REFRESH_TOKEN:
            case self::SETUP_STATUS_INVALID_REFRESH_TOKEN:
                return 'danger';
            case self::SETUP_STATUS_VALID_REFRESH_TOKEN:
                return 'success';
            default:
                return '';
        }
    }

    protected function getHealthStatusIcon($statusClass)
    {
        $icon = '';
        switch ($statusClass) {
            case 'danger':
                $icon = "ri-close-fill text-{$statusClass}";
                break;
            case 'success':
                $icon = "ri-check-fill text-{$statusClass}";
                break;
            case 'warning':
                $icon = "ri-alert-fill text-{$statusClass}";
                break;
        }

        return !empty($icon) ? "<span class=\"{$icon}\"></span>" : '';
    }

    /**
     * @inheritdoc
     */
    public function getHealthStatusText()
    {
        // Check prerequisites
        if (!(isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on')) {
            return $this->getHealthStatusIcon('danger') . " " . gT("OAuth authentication requires the application to be served over HTTPS.");
        }

        $setupStatus = $this->getSetupStatus();
        $statusClass = $this->getHealthStatusClass($setupStatus);
        $statusText = $this->getHealthStatusIcon($statusClass) . " " . $this->getSetupStatusDescription($setupStatus);

        if (
            $setupStatus === self::SETUP_STATUS_MISSING_REFRESH_TOKEN
            || $setupStatus === self::SETUP_STATUS_INVALID_REFRESH_TOKEN
            || $setupStatus === self::SETUP_STATUS_VALID_REFRESH_TOKEN
        ) {
            $getTokenUrl = $this->api->createUrl('smtpOAuth/prepareRefreshTokenRequest', ['plugin' => get_class($this)]);
            $getTokenLink = ' <a href="' . $getTokenUrl . '">' . gT("Get new token") . '</a>';
            $statusText .= $getTokenLink;
        }

        return $statusText;
    }

    /**
     * @inheritdoc
     */
    public function getPluginSettings($getValues = true)
    {
        $settings = parent::getPluginSettings($getValues);

        // Add the "current email" setting if the email for the current token is set
        $emailAddress = $this->get('email');
        if (!empty($emailAddress)) {
            $settings['currentEmail'] = [
                'type' => 'string',
                'label' => gT('Token owner email address'),
                'help' => gT('This is the email address used to create the current authentication token. Please note that all emails will be sent from this address.'),
                'htmlOptions' => [
                    'readonly' => true,
                ],
                'current' => $emailAddress,
            ];
        }

        // Add the "information" setting
        $statusAlert = $this->getSetupStatusAlert();
        if (!empty($statusAlert)) {
            $settings['information'] = [
                'type' => 'info',
                'content' => $statusAlert,
            ];
        }

        return $settings;
    }

    /**
     * Renders the contents of the "information" setting depending on
     * settings and token validity.
     * @return string
     */
    protected function getSetupStatusAlert()
    {
        $setupStatus = $this->getSetupStatus();

        // Don't show alert for successful setup
        if ($setupStatus === self::SETUP_STATUS_VALID_REFRESH_TOKEN) {
            return '';
        }

        $statusClass = $this->getHealthStatusClass($setupStatus);
        $statusText = $this->getHealthStatusIcon($statusClass) . " " . $this->getSetupStatusDescription($setupStatus);

        return "<div class=\"alert alert-{$statusClass}\">{$statusText}</div>";
    }

    /**
     * Default handler for the MailerConstruct event, triggered during LimeMailer initialization.
     * The event is expected to be dispatched specifically for the selected email plugin.
     */
    public function MailerConstruct()
    {
        $event = $this->getEvent();
        $mailer = $event->get('mailer');
        $this->setupMailer($mailer);
    }

    /**
     * Applies the basic OAuth configuration to the given LimeMailer
     * @param LimeMailer $mailer
     */
    protected function setupMailer($mailer)
    {
        $mailer->IsSMTP();
        $mailer->SMTPAuth = true;
        $mailer->Username = null;
        $mailer->Password = null;
        $mailer->AuthType = 'XOAUTH2';
        $config = $this->getOAuthConfigForMailer();
        if (empty($config)) {
            throw new \Exception('Invalid OAuth configuration');
        }
        $mailer->setOAuth(new OAuth($config));
    }

    /**
     * Returns the OAuth configuration for PHPMailer
     * @return array<string,mixed>|null
     */
    abstract protected function getOAuthConfigForMailer();
}