nextcloud_webdav_client-1.0.x-dev/src/Service/NextCloudOAuth2Manager.php

src/Service/NextCloudOAuth2Manager.php
<?php

namespace Drupal\nextcloud_webdav_client\Service;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Component\Datetime\TimeInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\RequestOptions;

/**
 * OAuth2 token manager for NextCloud WebDAV authentication.
 */
class NextCloudOAuth2Manager {

  /**
   * The config factory.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * The HTTP client.
   *
   * @var \GuzzleHttp\ClientInterface
   */
  protected $httpClient;

  /**
   * The logger channel.
   *
   * @var \Drupal\Core\Logger\LoggerChannelInterface
   */
  protected $logger;

  /**
   * The time service.
   *
   * @var \Drupal\Component\Datetime\TimeInterface
   */
  protected $time;

  /**
   * Constructs a NextCloudOAuth2Manager object.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The config factory.
   * @param \GuzzleHttp\ClientInterface $http_client
   *   The HTTP client.
   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
   *   The logger factory.
   * @param \Drupal\Component\Datetime\TimeInterface $time
   *   The time service.
   */
  public function __construct(
    ConfigFactoryInterface $config_factory,
    ClientInterface $http_client,
    LoggerChannelFactoryInterface $logger_factory,
    TimeInterface $time
  ) {
    $this->configFactory = $config_factory;
    $this->httpClient = $http_client;
    $this->logger = $logger_factory->get('nextcloud_webdav_client');
    $this->time = $time;
  }

  /**
   * Gets the current access token.
   *
   * Automatically refreshes the token if it's expired or about to expire.
   *
   * @return string|null
   *   The access token, or NULL if unavailable.
   */
  public function getAccessToken(): ?string {
    $config = $this->configFactory->get('nextcloud_webdav_client.settings');
    $access_token = $config->get('oauth2_access_token');
    $token_expiry = $config->get('oauth2_token_expiry');

    // If no token exists, return null.
    if (empty($access_token)) {
      return NULL;
    }

    // Check if token is expired or about to expire (within 5 minutes).
    $current_time = $this->time->getRequestTime();
    $buffer_time = 300; // 5 minutes buffer.

    if (!empty($token_expiry) && ($current_time + $buffer_time) >= $token_expiry) {
      $this->logger->info('Access token expired or about to expire, attempting refresh.');

      if ($this->refreshAccessToken()) {
        // Get the newly refreshed token.
        $config = $this->configFactory->get('nextcloud_webdav_client.settings');
        $access_token = $config->get('oauth2_access_token');
      }
      else {
        $this->logger->warning('Failed to refresh access token.');
        return NULL;
      }
    }

    return $access_token;
  }

  /**
   * Refreshes the access token using the refresh token.
   *
   * @return bool
   *   TRUE if the token was refreshed successfully, FALSE otherwise.
   */
  public function refreshAccessToken(): bool {
    $config = $this->configFactory->get('nextcloud_webdav_client.settings');
    $refresh_token = $config->get('oauth2_refresh_token');
    $token_endpoint = $config->get('oauth2_token_endpoint');
    $client_id = $config->get('oauth2_client_id');
    $client_secret = $config->get('oauth2_client_secret');
    $use_index_php = $config->get('oauth2_use_index_php');

    if (empty($refresh_token) || empty($token_endpoint)) {
      $this->logger->error('Cannot refresh token: missing refresh token or token endpoint.');
      return FALSE;
    }

    // Add /index.php/ prefix if configured.
    if ($use_index_php) {
      $token_endpoint = $this->addIndexPhpPrefix($token_endpoint);
    }

    try {
      $response = $this->httpClient->request('POST', $token_endpoint, [
        RequestOptions::FORM_PARAMS => [
          'grant_type' => 'refresh_token',
          'refresh_token' => $refresh_token,
          'client_id' => $client_id,
          'client_secret' => $client_secret,
        ],
        RequestOptions::HEADERS => [
          'Accept' => 'application/json',
        ],
      ]);

      $data = json_decode($response->getBody()->getContents(), TRUE);

      if (empty($data['access_token'])) {
        $this->logger->error('Token refresh response missing access_token.');
        return FALSE;
      }

      // Calculate token expiry time.
      $expires_in = $data['expires_in'] ?? 3600;
      $token_expiry = $this->time->getRequestTime() + $expires_in;

      // Update configuration with new tokens.
      $editable_config = $this->configFactory->getEditable('nextcloud_webdav_client.settings');
      $editable_config
        ->set('oauth2_access_token', $data['access_token'])
        ->set('oauth2_token_expiry', $token_expiry);

      // Update refresh token if a new one was provided.
      if (!empty($data['refresh_token'])) {
        $editable_config->set('oauth2_refresh_token', $data['refresh_token']);
      }

      $editable_config->save();

      $this->logger->info('Access token refreshed successfully.');
      return TRUE;
    }
    catch (RequestException $e) {
      $this->logger->error('Error refreshing access token: @message', [
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
    catch (\Exception $e) {
      $this->logger->error('Unexpected error refreshing access token: @message', [
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
  }

  /**
   * Checks if the current token is valid.
   *
   * @return bool
   *   TRUE if a valid token exists, FALSE otherwise.
   */
  public function isTokenValid(): bool {
    $config = $this->configFactory->get('nextcloud_webdav_client.settings');
    $access_token = $config->get('oauth2_access_token');
    $token_expiry = $config->get('oauth2_token_expiry');

    if (empty($access_token)) {
      return FALSE;
    }

    // Check if token is expired.
    $current_time = $this->time->getRequestTime();
    if (!empty($token_expiry) && $current_time >= $token_expiry) {
      return FALSE;
    }

    return TRUE;
  }

  /**
   * Initiates the OAuth2 authorization flow.
   *
   * @param string $redirect_uri
   *   The callback URI to redirect to after authorization.
   * @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session
   *   The session service for CSRF state storage.
   *
   * @return string
   *   The authorization URL to redirect the user to.
   */
  public function initiateOAuth2Flow(string $redirect_uri, $session): string {
    $config = $this->configFactory->get('nextcloud_webdav_client.settings');
    $authorize_endpoint = $config->get('oauth2_authorize_endpoint');
    $client_id = $config->get('oauth2_client_id');
    $scopes = $config->get('oauth2_scopes') ?: 'openid profile email';
    $use_index_php = $config->get('oauth2_use_index_php');

    // Add /index.php/ prefix if configured.
    if ($use_index_php) {
      $authorize_endpoint = $this->addIndexPhpPrefix($authorize_endpoint);
    }

    // Generate cryptographically secure state for CSRF protection.
    $state = bin2hex(random_bytes(32));
    
    // Store state in session for validation in callback.
    $session->set('nextcloud_oauth2_state', $state);
    $session->set('nextcloud_oauth2_state_time', time());

    $params = [
      'response_type' => 'code',
      'client_id' => $client_id,
      'redirect_uri' => $redirect_uri,
      'scope' => $scopes,
      'state' => $state,
    ];

    return $authorize_endpoint . '?' . http_build_query($params);
  }

  /**
   * Exchanges an authorization code for access and refresh tokens.
   *
   * @param string $code
   *   The authorization code from the OAuth2 provider.
   * @param string $redirect_uri
   *   The redirect URI used in the authorization request.
   *
   * @return bool
   *   TRUE if tokens were obtained successfully, FALSE otherwise.
   */
  public function exchangeAuthorizationCode(string $code, string $redirect_uri): bool {
    $config = $this->configFactory->get('nextcloud_webdav_client.settings');
    $token_endpoint = $config->get('oauth2_token_endpoint');
    $client_id = $config->get('oauth2_client_id');
    $client_secret = $config->get('oauth2_client_secret');
    $use_index_php = $config->get('oauth2_use_index_php');

    if (empty($token_endpoint) || empty($client_id)) {
      $this->logger->error('Cannot exchange code: missing token endpoint or client ID.');
      return FALSE;
    }

    // Add /index.php/ prefix if configured.
    if ($use_index_php) {
      $token_endpoint = $this->addIndexPhpPrefix($token_endpoint);
    }

    try {
      $response = $this->httpClient->request('POST', $token_endpoint, [
        RequestOptions::FORM_PARAMS => [
          'grant_type' => 'authorization_code',
          'code' => $code,
          'redirect_uri' => $redirect_uri,
          'client_id' => $client_id,
          'client_secret' => $client_secret,
        ],
        RequestOptions::HEADERS => [
          'Accept' => 'application/json',
        ],
      ]);

      $data = json_decode($response->getBody()->getContents(), TRUE);

      if (empty($data['access_token'])) {
        $this->logger->error('Token exchange response missing access_token.');
        return FALSE;
      }

      // Calculate token expiry time.
      $expires_in = $data['expires_in'] ?? 3600;
      $token_expiry = $this->time->getRequestTime() + $expires_in;

      // Store tokens in configuration.
      $editable_config = $this->configFactory->getEditable('nextcloud_webdav_client.settings');
      $editable_config
        ->set('oauth2_access_token', $data['access_token'])
        ->set('oauth2_token_expiry', $token_expiry);

      if (!empty($data['refresh_token'])) {
        $editable_config->set('oauth2_refresh_token', $data['refresh_token']);
      }

      $editable_config->save();

      $this->logger->info('OAuth2 authorization successful, tokens stored.');
      return TRUE;
    }
    catch (RequestException $e) {
      $this->logger->error('Error exchanging authorization code: @message', [
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
    catch (\Exception $e) {
      $this->logger->error('Unexpected error exchanging authorization code: @message', [
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
  }

  /**
   * Clears all stored OAuth2 tokens.
   *
   * This effectively revokes the authorization.
   */
  public function clearTokens(): void {
    $config = $this->configFactory->getEditable('nextcloud_webdav_client.settings');
    $config
      ->clear('oauth2_access_token')
      ->clear('oauth2_refresh_token')
      ->clear('oauth2_token_expiry')
      ->save();

    $this->logger->info('OAuth2 tokens cleared.');
  }

  /**
   * Gets the token expiry timestamp.
   *
   * @return int|null
   *   The token expiry timestamp, or NULL if not set.
   */
  public function getTokenExpiry(): ?int {
    $config = $this->configFactory->get('nextcloud_webdav_client.settings');
    return $config->get('oauth2_token_expiry');
  }

  /**
   * Checks if OAuth2 is properly configured.
   *
   * @return bool
   *   TRUE if OAuth2 configuration is complete, FALSE otherwise.
   */
  public function isConfigured(): bool {
    $config = $this->configFactory->get('nextcloud_webdav_client.settings');

    $client_id = $config->get('oauth2_client_id');
    $client_secret = $config->get('oauth2_client_secret');
    $token_endpoint = $config->get('oauth2_token_endpoint');
    $authorize_endpoint = $config->get('oauth2_authorize_endpoint');

    return !empty($client_id)
      && !empty($client_secret)
      && !empty($token_endpoint)
      && !empty($authorize_endpoint);
  }

  /**
   * Adds /index.php/ prefix to a URL if not already present.
   *
   * @param string $url
   *   The URL to process.
   *
   * @return string
   *   The URL with /index.php/ prefix.
   */
  protected function addIndexPhpPrefix(string $url): string {
    // Parse the URL.
    $parts = parse_url($url);
    
    if (!isset($parts['path'])) {
      return $url;
    }

    // Check if /index.php/ is already in the path.
    if (strpos($parts['path'], '/index.php/') !== FALSE) {
      return $url;
    }

    // Add /index.php/ at the beginning of the path.
    $parts['path'] = '/index.php' . $parts['path'];

    // Rebuild the URL.
    $result = $parts['scheme'] . '://' . $parts['host'];
    if (isset($parts['port'])) {
      $result .= ':' . $parts['port'];
    }
    $result .= $parts['path'];
    if (isset($parts['query'])) {
      $result .= '?' . $parts['query'];
    }
    if (isset($parts['fragment'])) {
      $result .= '#' . $parts['fragment'];
    }

    return $result;
  }

  /**
   * Gets the access token for a specific user.
   *
   * Automatically refreshes the token if it's expired or about to expire.
   *
   * @param \Drupal\user\UserInterface $user
   *   The user.
   *
   * @return string|null
   *   The access token, or NULL if unavailable.
   */
  public function getUserAccessToken(\Drupal\user\UserInterface $user): ?string {
    /** @var \Drupal\nextcloud_webdav_client\NextCloudUserTokenStorageInterface $storage */
    $storage = \Drupal::entityTypeManager()->getStorage('nextcloud_user_token');
    $token_entity = $storage->loadByUser($user);

    if (!$token_entity) {
      return NULL;
    }

    // Check if expired, refresh if needed.
    if ($token_entity->isExpired()) {
      $this->logger->info('User @uid access token expired or about to expire, attempting refresh.', [
        '@uid' => $user->id(),
      ]);

      if ($this->refreshUserAccessToken($user)) {
        $token_entity = $storage->loadByUser($user);
      }
      else {
        $this->logger->warning('Failed to refresh access token for user @uid.', [
          '@uid' => $user->id(),
        ]);
        return NULL;
      }
    }

    return $token_entity->getAccessToken();
  }

  /**
   * Refreshes the access token for a specific user.
   *
   * @param \Drupal\user\UserInterface $user
   *   The user.
   *
   * @return bool
   *   TRUE if the token was refreshed successfully, FALSE otherwise.
   */
  public function refreshUserAccessToken(\Drupal\user\UserInterface $user): bool {
    /** @var \Drupal\nextcloud_webdav_client\NextCloudUserTokenStorageInterface $storage */
    $storage = \Drupal::entityTypeManager()->getStorage('nextcloud_user_token');
    $token_entity = $storage->loadByUser($user);

    if (!$token_entity || !$token_entity->getRefreshToken()) {
      $this->logger->error('Cannot refresh token for user @uid: missing token entity or refresh token.', [
        '@uid' => $user->id(),
      ]);
      return FALSE;
    }

    $config = $this->configFactory->get('nextcloud_webdav_client.settings');
    $token_endpoint = $config->get('oauth2_token_endpoint');
    $client_id = $config->get('oauth2_client_id');
    $client_secret = $config->get('oauth2_client_secret');
    $use_index_php = $config->get('oauth2_use_index_php');

    if (empty($token_endpoint)) {
      $this->logger->error('Cannot refresh token for user @uid: missing token endpoint.', [
        '@uid' => $user->id(),
      ]);
      return FALSE;
    }

    // Add /index.php/ prefix if configured.
    if ($use_index_php) {
      $token_endpoint = $this->addIndexPhpPrefix($token_endpoint);
    }

    try {
      $response = $this->httpClient->request('POST', $token_endpoint, [
        RequestOptions::FORM_PARAMS => [
          'grant_type' => 'refresh_token',
          'refresh_token' => $token_entity->getRefreshToken(),
          'client_id' => $client_id,
          'client_secret' => $client_secret,
        ],
        RequestOptions::HEADERS => [
          'Accept' => 'application/json',
        ],
      ]);

      $data = json_decode($response->getBody()->getContents(), TRUE);

      if (empty($data['access_token'])) {
        $this->logger->error('Token refresh response missing access_token for user @uid.', [
          '@uid' => $user->id(),
        ]);
        return FALSE;
      }

      // Calculate token expiry time.
      $expires_in = $data['expires_in'] ?? 3600;
      $token_expiry = $this->time->getRequestTime() + $expires_in;

      // Update token entity.
      $token_data = [
        'access_token' => $data['access_token'],
        'token_expiry' => $token_expiry,
      ];

      if (!empty($data['refresh_token'])) {
        $token_data['refresh_token'] = $data['refresh_token'];
      }

      $storage->updateToken($token_entity, $token_data);

      $this->logger->info('Access token refreshed successfully for user @uid.', [
        '@uid' => $user->id(),
      ]);
      return TRUE;
    }
    catch (RequestException $e) {
      $this->logger->error('Error refreshing access token for user @uid: @message', [
        '@uid' => $user->id(),
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
    catch (\Exception $e) {
      $this->logger->error('Unexpected error refreshing access token for user @uid: @message', [
        '@uid' => $user->id(),
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
  }

  /**
   * Initiates the OAuth2 authorization flow for a specific user.
   *
   * @param \Drupal\user\UserInterface $user
   *   The user.
   * @param string $redirect_uri
   *   The callback URI to redirect to after authorization.
   * @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session
   *   The session service for CSRF state storage.
   *
   * @return string
   *   The authorization URL to redirect the user to.
   */
  public function initiateUserOAuth2Flow(\Drupal\user\UserInterface $user, string $redirect_uri, $session): string {
    // For per-user mode, the flow is the same, just different redirect handling.
    return $this->initiateOAuth2Flow($redirect_uri, $session);
  }

  /**
   * Exchanges an authorization code for tokens for a specific user.
   *
   * @param \Drupal\user\UserInterface $user
   *   The user.
   * @param string $code
   *   The authorization code from the OAuth2 provider.
   * @param string $redirect_uri
   *   The redirect URI used in the authorization request.
   *
   * @return bool
   *   TRUE if tokens were obtained successfully, FALSE otherwise.
   */
  public function exchangeAuthorizationCodeForUser(\Drupal\user\UserInterface $user, string $code, string $redirect_uri): bool {
    $config = $this->configFactory->get('nextcloud_webdav_client.settings');
    $token_endpoint = $config->get('oauth2_token_endpoint');
    $client_id = $config->get('oauth2_client_id');
    $client_secret = $config->get('oauth2_client_secret');
    $use_index_php = $config->get('oauth2_use_index_php');

    if (empty($token_endpoint) || empty($client_id)) {
      $this->logger->error('Cannot exchange code for user @uid: missing token endpoint or client ID.', [
        '@uid' => $user->id(),
      ]);
      return FALSE;
    }

    // Add /index.php/ prefix if configured.
    if ($use_index_php) {
      $token_endpoint = $this->addIndexPhpPrefix($token_endpoint);
    }

    try {
      $response = $this->httpClient->request('POST', $token_endpoint, [
        RequestOptions::FORM_PARAMS => [
          'grant_type' => 'authorization_code',
          'code' => $code,
          'redirect_uri' => $redirect_uri,
          'client_id' => $client_id,
          'client_secret' => $client_secret,
        ],
        RequestOptions::HEADERS => [
          'Accept' => 'application/json',
        ],
      ]);

      $data = json_decode($response->getBody()->getContents(), TRUE);

      if (empty($data['access_token'])) {
        $this->logger->error('Token exchange response missing access_token for user @uid.', [
          '@uid' => $user->id(),
        ]);
        return FALSE;
      }

      // Calculate token expiry time.
      $expires_in = $data['expires_in'] ?? 3600;
      $token_expiry = $this->time->getRequestTime() + $expires_in;

      // Get NextCloud username from config (can be enhanced later to fetch from user info endpoint).
      $nextcloud_username = $user->getAccountName();

      // Store tokens for this user.
      /** @var \Drupal\nextcloud_webdav_client\NextCloudUserTokenStorageInterface $storage */
      $storage = \Drupal::entityTypeManager()->getStorage('nextcloud_user_token');
      $token_data = [
        'access_token' => $data['access_token'],
        'refresh_token' => $data['refresh_token'] ?? NULL,
        'token_expiry' => $token_expiry,
        'nextcloud_username' => $nextcloud_username,
      ];

      $storage->createForUser($user, $token_data);

      $this->logger->info('OAuth2 authorization successful for user @uid, tokens stored.', [
        '@uid' => $user->id(),
      ]);
      return TRUE;
    }
    catch (RequestException $e) {
      $this->logger->error('Error exchanging authorization code for user @uid: @message', [
        '@uid' => $user->id(),
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
    catch (\Exception $e) {
      $this->logger->error('Unexpected error exchanging authorization code for user @uid: @message', [
        '@uid' => $user->id(),
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc