social_auth_entra_id-1.0.x-dev/src/Controller/SocialAuthEntraIdController.php

src/Controller/SocialAuthEntraIdController.php
<?php

namespace Drupal\social_auth_entra_id\Controller;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Routing\TrustedRedirectResponse;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\Core\Url;
use Drupal\user\Entity\User;
use GuzzleHttp\ClientInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;

/**
 * Controller for handling Microsoft Entra ID.
 *
 * Social authentication redirects and callbacks.
 */
class SocialAuthEntraIdController implements ContainerInjectionInterface {
  use StringTranslationTrait;

  /**
   * Configuration factory service.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * HTTP client service for making external requests.
   *
   * @var \GuzzleHttp\ClientInterface
   */
  protected $httpClient;

  /**
   * Messenger service for displaying messages.
   *
   * @var \Drupal\Core\Messenger\MessengerInterface
   */
  protected $messenger;

  /**
   * Language manager service.
   *
   * @var \Drupal\Core\Language\LanguageManagerInterface
   */
  protected $languageManager;

  /**
   * Logger factory service for logging errors.
   *
   * @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
   */
  protected $loggerFactory;

  /**
   * Constructs a SocialAuthEntraIdController object.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   Configuration factory service.
   * @param \GuzzleHttp\ClientInterface $http_client
   *   HTTP client service.
   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
   *   Messenger service.
   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
   *   Language manager service.
   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
   *   Logger factory service.
   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
   *   String translation service.
   */
  public function __construct(
    ConfigFactoryInterface $config_factory,
    ClientInterface $http_client,
    MessengerInterface $messenger,
    LanguageManagerInterface $language_manager,
    LoggerChannelFactoryInterface $logger_factory,
    TranslationInterface $string_translation,
  ) {
    $this->configFactory = $config_factory;
    $this->httpClient = $http_client;
    $this->messenger = $messenger;
    $this->languageManager = $language_manager;
    $this->loggerFactory = $logger_factory;
    $this->setStringTranslation($string_translation);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('config.factory'),
      $container->get('http_client'),
      $container->get('messenger'),
      $container->get('language_manager'),
      $container->get('logger.factory'),
      $container->get('string_translation')
    );
  }

  /**
   * Redirects the user to the Microsoft Entra ID login page.
   *
   * Initiates the OAuth 2.0 authorization flow by redirecting to Microsoft's
   * login page. Generates and stores CSRF state and nonce tokens in session
   * for security validation during callback.
   *
   * @return \Drupal\Core\Routing\TrustedRedirectResponse|\Symfony\Component\HttpFoundation\RedirectResponse
   *   A trusted redirect response to the Microsoft Entra ID login URL,
   *   or redirect to login page if configuration is missing.
   */
  public function redirectToMicrosoft() {
    // Load module configuration.
    $config = $this->configFactory->get('social_auth_entra_id.settings');
    $client_id = $config->get('client_id');
    $tenant_id = $config->get('tenant_id');
    $account_type = $config->get('account_type') ?? 'organization';

    // Determine the endpoint based on account type.
    // organization: Use specific tenant ID for work/school accounts.
    // common: Support both organizational and personal accounts.
    // consumers: Support only personal Microsoft accounts.
    $endpoint = ($account_type === 'organization') ? $tenant_id : $account_type;

    // Validate required configuration exists.
    // Tenant ID is only required for organization account type.
    if (empty($client_id) || ($account_type === 'organization' && empty($tenant_id))) {
      $this->messenger->addError($this->t('Empty configuration. Please contact the site administrator.'));
      $current_language = $this->languageManager->getCurrentLanguage()->getId();
      $response = new RedirectResponse(Url::fromRoute('user.login', [], ['language' => $current_language])->toString());
      // Prevent caching of error responses.
      $response->setMaxAge(0);
      $response->headers->addCacheControlDirective('no-cache', TRUE);
      return $response;
    }

    // Generate CSRF state token (64 char hex) for OAuth flow protection.
    // This prevents attackers from initiating unauthorized authentication.
    $state = bin2hex(random_bytes(32));
    $_SESSION['entra_id_oauth_state'] = $state;

    // Generate nonce (64 char hex) for ID token replay protection.
    // Microsoft will include this in the ID token for validation.
    $nonce = bin2hex(random_bytes(32));
    $_SESSION['entra_id_oauth_nonce'] = $nonce;

    // Generate absolute callback URL for Microsoft to redirect back to.
    // IMPORTANT: Use toString() (without bubbleable metadata) so we get a
    // plain string URL suitable for query parameters and persistence.
    // Store exact value in session to reuse during token exchange, ensuring
    // perfect match with the value used in the authorization request.
    $redirect_uri = Url::fromRoute('social_auth_entra_id.callback', [], ['absolute' => TRUE])->toString();
    $_SESSION['entra_id_redirect_uri'] = $redirect_uri;

    // Persist the chosen endpoint for use in callback (defensive against any
    // mid-flow config changes that could cause mismatches).
    $_SESSION['entra_id_oauth_endpoint'] = $endpoint;

    // Request OpenID Connect and Microsoft Graph scopes for authentication.
    // - openid, profile, email: Required for OIDC ID token and basic claims.
    // - User.Read: Required to call Microsoft Graph /me for display name.
    $scopes = 'openid profile email User.Read';

    // Build Microsoft Entra ID authorization endpoint URL.
    // Uses OAuth 2.0 Authorization Code flow with PKCE protection.
    // Endpoint varies based on account type (organization/common/consumers).
    $url = "https://login.microsoftonline.com/$endpoint/oauth2/v2.0/authorize?" . http_build_query([
      'client_id' => $client_id,
      'response_type' => 'code',
      'redirect_uri' => $redirect_uri,
      'scope' => $scopes,
      'state' => $state,
      'nonce' => $nonce,
    ]);

    // Use TrustedRedirectResponse to allow external Microsoft domain.
    $response = new TrustedRedirectResponse($url);

    // Ensure this response is never cached.
    // OAuth flows require fresh session state on every request.
    $response->setMaxAge(0);
    $response->setSharedMaxAge(0);
    $response->headers->addCacheControlDirective('no-cache', TRUE);
    $response->headers->addCacheControlDirective('no-store', TRUE);
    $response->headers->addCacheControlDirective('must-revalidate', TRUE);

    return $response;
  }

  /**
   * Handles the callback from Microsoft Entra ID after user authorization.
   *
   * Processes the OAuth 2.0 callback with authorization code, validates
   * security tokens, extracts verified user information from ID token,
   * and authenticates or creates the user account in Drupal.
   *
   * Security features:
   * - CSRF protection via state parameter validation
   * - Token replay prevention via nonce validation
   * - ID token signature and claims validation
   * - Email verification from cryptographically signed claims
   * - Domain allowlisting
   * - User blocking checks
   * - Administrator protection
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The incoming request object containing authorization code and state.
   *
   * @return \Symfony\Component\HttpFoundation\RedirectResponse
   *   A redirect response to appropriate page after processing:
   *   - Front page on success
   *   - Login page on security violation
   *   - Front page on error
   */
  public function handleMicrosoftCallback(Request $request) {
    // Extract OAuth callback parameters.
    $code = $request->query->get('code');
    $state = $request->query->get('state');

    // SECURITY: Validate state parameter to prevent CSRF attacks.
    // The state must match what we stored in session during redirect.
    if (empty($_SESSION['entra_id_oauth_state']) || $state !== $_SESSION['entra_id_oauth_state']) {
      $this->messenger->addError($this->t('Invalid state parameter. Possible CSRF attack.'));
      $this->loggerFactory
        ->get('social_auth_entra_id')
        ->warning('CSRF attempt detected: State parameter mismatch');
      unset($_SESSION['entra_id_oauth_state']);
      $response = new RedirectResponse(Url::fromRoute('user.login')->toString());
      $response->setMaxAge(0);
      $response->headers->addCacheControlDirective('no-cache', TRUE);
      return $response;
    }

    // Clear state token after successful validation (one-time use).
    unset($_SESSION['entra_id_oauth_state']);

    // Proceed only if authorization code is present.
    if ($code) {
      // Load all required configuration settings.
      $config = $this->configFactory->get('social_auth_entra_id.settings');
      $client_id = $config->get('client_id');
      $client_secret = $config->get('client_secret');
      $tenant_id = $config->get('tenant_id');
      $account_type = $config->get('account_type') ?? 'organization';
      // Reuse the exact redirect_uri used for the authorization request if
      // available to avoid any mismatch (scheme/host/basepath/lang).
      $redirect_uri = isset($_SESSION['entra_id_redirect_uri']) ? (string) $_SESSION['entra_id_redirect_uri'] : Url::fromRoute('social_auth_entra_id.callback', [], ['absolute' => TRUE])->toString();

      // Determine the endpoint based on account type, preferring the value
      // persisted during the authorization redirect.
      $endpoint = isset($_SESSION['entra_id_oauth_endpoint'])
        ? (string) $_SESSION['entra_id_oauth_endpoint']
        : (($account_type === 'organization') ? $tenant_id : $account_type);

      // Determine if new users should be auto-registered.
      $login_behavior = $config->get('login_behavior');

      // Parse and normalize allowed email domains.
      // Accepts comma-separated and/or one-per-line entries.
      // Splits on commas and newlines, trims, lowercases, and filters empties.
      $allowed_domains_raw = $config->get('allowed_domains') ?? '';
      $allowed_domains_tokens = preg_split('/[\r\n,]+/', (string) $allowed_domains_raw);
      $allowed_domains = array_filter(array_map(function ($domain) {
        return strtolower(trim($domain));
      }, $allowed_domains_tokens ?: []));

      try {
        // Exchange authorization code for access token and ID token.
        // Uses OAuth 2.0 token endpoint with client credentials.
        // Endpoint varies based on account type
        // (organization/common/consumers).
        $token_url = "https://login.microsoftonline.com/$endpoint/oauth2/v2.0/token";
        $response = $this->httpClient->post($token_url, [
          'form_params' => [
            'client_id' => $client_id,
            'client_secret' => $client_secret,
            'code' => $code,
            'redirect_uri' => $redirect_uri,
            'grant_type' => 'authorization_code',
          ],
        ]);

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

        // Verify both tokens are present in response.
        if (isset($data['access_token']) && isset($data['id_token'])) {
          // Parse and validate JWT ID token structure.
          // JWT format: base64(header).base64(payload).base64(signature)
          $id_token_parts = explode('.', $data['id_token']);
          if (count($id_token_parts) !== 3) {
            throw new \Exception('Invalid ID token format.');
          }

          // Decode the JWT payload (middle section).
          // Uses URL-safe base64 decoding (-_ instead of +/).
          $id_token_payload = json_decode(base64_decode(strtr($id_token_parts[1], '-_', '+/')), TRUE);

          if (!$id_token_payload) {
            throw new \Exception('Failed to decode ID token payload.');
          }

          // Validate critical ID token claims for security.
          // SECURITY: These validations prevent token forgery and misuse.
          // 1. Verify audience (aud) claim matches our client_id.
          // Prevents tokens issued for other apps from being accepted.
          if (empty($id_token_payload['aud']) || $id_token_payload['aud'] !== $client_id) {
            throw new \Exception('ID token audience mismatch.');
          }

          // 2. Verify issuer (iss) claim is from Microsoft.
          // Prevents tokens from malicious issuers.
          // Expected issuer varies by account type:
          // - organization: microsoft.com/{tenant_id}/v2.0
          // - common/consumers: microsoft.com/{token_tenant}/v2.0
          if (empty($id_token_payload['iss'])) {
            throw new \Exception('ID token issuer missing.');
          }
          // For organization accounts, validate exact tenant match.
          if ($account_type === 'organization') {
            $expected_issuer = "https://login.microsoftonline.com/$tenant_id/v2.0";
            if ($id_token_payload['iss'] !== $expected_issuer) {
              throw new \Exception('ID token issuer mismatch.');
            }
          }
          else {
            // For common/consumers, validate issuer domain only.
            if (!preg_match('#^https://login\.microsoftonline\.com/[^/]+/v2\.0$#', $id_token_payload['iss'])) {
              throw new \Exception('ID token issuer invalid.');
            }
          }

          // 3. Verify token expiration (exp) claim.
          // Prevents use of old/expired tokens.
          if (empty($id_token_payload['exp']) || $id_token_payload['exp'] < time()) {
            throw new \Exception('ID token has expired.');
          }

          // 4. Verify nonce to prevent token replay attacks.
          // Nonce must match what we sent in authorization request.
          if (!empty($_SESSION['entra_id_oauth_nonce'])) {
            if (empty($id_token_payload['nonce']) || $id_token_payload['nonce'] !== $_SESSION['entra_id_oauth_nonce']) {
              unset($_SESSION['entra_id_oauth_nonce']);
              throw new \Exception('ID token nonce mismatch.');
            }
            // Clear nonce after validation (one-time use).
            unset($_SESSION['entra_id_oauth_nonce']);
          }

          // Extract verified email from ID token claims.
          // SECURITY: Use ID token claims, NOT Graph API profile email.
          // Microsoft does not validate the 'mail' field in user profiles,
          // allowing attackers to impersonate users by setting arbitrary
          // emails. ID token claims are cryptographically signed and
          // verified by Microsoft.
          // Priority: email > preferred_username > unique_name.
          $user_email = NULL;
          if (!empty($id_token_payload['email'])) {
            $user_email = $id_token_payload['email'];
          }
          elseif (!empty($id_token_payload['preferred_username'])) {
            $user_email = $id_token_payload['preferred_username'];
          }
          elseif (!empty($id_token_payload['unique_name'])) {
            $user_email = $id_token_payload['unique_name'];
          }

          if (!$user_email) {
            throw new \Exception('No verified email found in ID token.');
          }

          // Normalize email to lowercase for case-insensitive comparison.
          // Prevents bypass via case variations
          // (user@example.com vs User@Example.COM).
          $user_email = strtolower(trim($user_email));

          // Validate email format to prevent malformed addresses.
          if (!filter_var($user_email, FILTER_VALIDATE_EMAIL)) {
            throw new \Exception('Invalid email format in ID token.');
          }

          // Fetch additional profile data from Microsoft Graph API.
          // Used only for display name, NOT for email (security risk).
          // If this call fails due to missing consent/permissions, proceed
          // without it and derive a username from the email address.
          $profile_data = [];
          try {
            $profile_response = $this->httpClient->get('https://graph.microsoft.com/v1.0/me', [
              'headers' => ['Authorization' => 'Bearer ' . $data['access_token']],
            ]);
            $profile_data = json_decode($profile_response->getBody()->getContents(), TRUE) ?? [];
          }
          catch (\Exception $graph_exception) {
            // Log at notice level; do not block login for missing Graph scopes.
            $this->loggerFactory
              ->get('social_auth_entra_id')
              ->notice('Microsoft Graph /me request failed: @message', [
                '@message' => $graph_exception->getMessage(),
              ]);
          }

          // Process authentication with verified email.
          if ($user_email) {
            // Extract and normalize email domain for allowlist check.
            $user_email_domain = strtolower(substr(strrchr($user_email, "@"), 1));

            // Check domain allowlist if configured.
            if (!empty($allowed_domains) && !in_array($user_email_domain, $allowed_domains)) {
              $this->messenger->addError($this->t('Your email domain is not allowed.'));
              $response = new RedirectResponse(Url::fromRoute('<front>')->toString());
              $response->setMaxAge(0);
              $response->headers->addCacheControlDirective('no-cache', TRUE);
              return $response;
            }

            // Check if user account already exists.
            $existing_user = user_load_by_mail($user_email);

            // Load security configuration for privileged account protection.
            $block_user_1 = $config->get('block_user_1') ?? TRUE;
            $block_admin_role = $config->get('block_admin_role') ?? FALSE;

            // Perform security checks on existing user accounts.
            if ($existing_user) {
              // 1. Check if Drupal account is blocked/disabled.
              // Respects site-wide user blocking.
              if ($existing_user->isBlocked()) {
                $this->messenger->addError($this->t('This account has been blocked.'));
                $this->loggerFactory
                  ->get('social_auth_entra_id')
                  ->warning('Blocked user login attempt via Entra ID for email: @email, IP: @ip', [
                    '@email' => $user_email,
                    '@ip' => $request->getClientIp(),
                  ]);
                $response = new RedirectResponse(Url::fromRoute('user.login')->toString());
                $response->setMaxAge(0);
                $response->headers->addCacheControlDirective('no-cache', TRUE);
                return $response;
              }

              // 2. Check if user ID 1 (root admin) login is blocked.
              // SECURITY: Prevents SSO attacks on most privileged account.
              if ($block_user_1 && $existing_user->id() == 1) {
                $this->messenger->addError($this->t('The root administrator account cannot log in via Entra ID.'));
                $this->loggerFactory
                  ->get('social_auth_entra_id')
                  ->warning('Blocked user 1 login attempt via Entra ID for email: @email, IP: @ip', [
                    '@email' => $user_email,
                    '@ip' => $request->getClientIp(),
                  ]);
                $response = new RedirectResponse(Url::fromRoute('user.login')->toString());
                $response->setMaxAge(0);
                $response->headers->addCacheControlDirective('no-cache', TRUE);
                return $response;
              }

              // 3. Check if administrator role login is blocked.
              // SECURITY: Optional protection for all admin accounts.
              if ($block_admin_role && $existing_user->hasRole('administrator')) {
                $this->messenger->addError($this->t('Administrator accounts cannot log in via Entra ID.'));
                $this->loggerFactory
                  ->get('social_auth_entra_id')
                  ->warning('Blocked administrator role login attempt via Entra ID for email: @email, IP: @ip', [
                    '@email' => $user_email,
                    '@ip' => $request->getClientIp(),
                  ]);
                $response = new RedirectResponse(Url::fromRoute('user.login')->toString());
                $response->setMaxAge(0);
                $response->headers->addCacheControlDirective('no-cache', TRUE);
                return $response;
              }
            }

            // Handle user registration if account doesn't exist and
            // auto-registration is enabled.
            if (!$existing_user && $login_behavior == 'register_and_login') {
              // Generate base username from display name or email part.
              $base_username = $profile_data['displayName'] ?? explode('@', $user_email)[0];

              // Sanitize username to remove invalid characters.
              // Allows Unicode, alphanumeric, @, _, ., ', and -.
              $base_username = preg_replace('/[^\x{80}-\x{F7} a-z0-9@_.\'-]/i', '', $base_username);
              $base_username = trim($base_username);

              // Ensure username uniqueness by appending counter if needed.
              // Prevents username collision errors.
              $username = $base_username;
              $counter = 1;
              while (user_load_by_name($username)) {
                $username = $base_username . '_' . $counter;
                $counter++;
              }

              // Create new Drupal user account.
              $new_user = User::create([
                'name' => $username,
                'mail' => $user_email,
                'status' => 1,
              ]);
              $new_user->save();

              // Log user in and show success message.
              user_login_finalize($new_user);
              $this->messenger->addStatus($this->t('Account created and logged in.'));
            }
            elseif ($existing_user) {
              // Log in existing user that passed all security checks.
              user_login_finalize($existing_user);
              $this->messenger->addStatus($this->t('Logged in successfully.'));
            }
            else {
              // User doesn't exist and auto-registration is disabled.
              // Use generic error to prevent account enumeration attacks.
              $this->messenger->addError($this->t('Unable to log in. Please contact the site administrator.'));
              $this->loggerFactory
                ->get('social_auth_entra_id')
                ->notice('Login attempt for non-existent account: @email', ['@email' => $user_email]);
              $response = new RedirectResponse(Url::fromRoute('<front>')->toString());
              $response->setMaxAge(0);
              $response->headers->addCacheControlDirective('no-cache', TRUE);
              return $response;
            }
          }
          else {
            throw new \Exception('User email not found.');
          }
        }
        else {
          throw new \Exception('Access token missing.');
        }
      }
      catch (\Exception $e) {
        // Handle all authentication errors gracefully.
        // Show generic message to prevent information leakage.
        $this->messenger->addError($this->t('An error occurred.'));

        // Log detailed error with context for debugging.
        $this->loggerFactory
          ->get('social_auth_entra_id')
          ->error('Microsoft Entra callback error: @message', [
            '@message' => $e->getMessage(),
            'ip' => $request->getClientIp(),
            'timestamp' => time(),
          ]);

        // SECURITY: Add delay to prevent timing attacks and rate limit abuse.
        // Makes brute force attacks slower.
        sleep(2);
      }
    }
    else {
      // Authorization code not present in callback.
      $this->messenger->addError($this->t('Authorization code missing.'));
    }

    // Default redirect to front page after callback processing.
    $response = new RedirectResponse(Url::fromRoute('<front>')->toString());

    // Ensure callback responses are never cached.
    // OAuth callbacks contain sensitive state validation.
    $response->setMaxAge(0);
    $response->setSharedMaxAge(0);
    $response->headers->addCacheControlDirective('no-cache', TRUE);
    $response->headers->addCacheControlDirective('no-store', TRUE);
    $response->headers->addCacheControlDirective('must-revalidate', TRUE);

    return $response;
  }

}

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

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