You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

在Drupal/PHP/Symfony环境下实现无用户交互的后台OAuth2授权流程

在Drupal/PHP/Symfony环境下实现无用户交互的后台OAuth2授权流程

嘿,我看你现在的OAuth2流程是靠手动触发控制器路由来运行的,需要用户跳转到授权页面才能完成。你想要用户登录Drupal之后,自动在后台悄咪咪完成整个授权、拿token、同步用户信息的流程,完全不需要用户手动操作对吧?没问题,我来一步步帮你实现这个需求~

一、核心思路:利用Drupal的用户登录事件触发流程

Drupal提供了UserEvents::LOGIN事件,当用户成功登录时会自动触发。我们可以创建一个事件订阅者,在这个事件里启动OAuth2流程,替代原来的手动控制器触发逻辑。

不过这里要提前说明:标准的授权码流程需要用户跳转确认授权,要实现无交互,你得确保你的OAuth2授权服务器支持以下两种方式之一:

  1. 支持静默授权(通过prompt=none参数跳过用户确认)
  2. 支持资源所有者密码凭证授权(仅适合内部信任的服务,用用户账号密码直接换token)

接下来我们用最安全的静默授权方案来实现:

二、具体实现步骤

1. 创建用户登录事件订阅者

这个订阅者会在用户登录时自动执行OAuth2流程,同时跳过已经有有效token的用户:

<?php

namespace Drupal\my_module\EventSubscriber;

use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\user\UserEvents;
use Drupal\user\Event\UserLoginEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Drupal\my_module\Service\OAuthClient;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;

class OAuthLoginSubscriber implements EventSubscriberInterface {
  use StringTranslationTrait;

  /** @var OAuthClient */
  protected $oauthClient;

  /** @var MessengerInterface */
  protected $messenger;

  /** @var EntityTypeManagerInterface */
  protected $entityTypeManager;

  /** @var LoggerChannelFactoryInterface */
  protected $loggerFactory;

  public function __construct(
    OAuthClient $oauth_client,
    MessengerInterface $messenger,
    EntityTypeManagerInterface $entity_type_manager,
    LoggerChannelFactoryInterface $logger_factory
  ) {
    $this->oauthClient = $oauth_client;
    $this->messenger = $messenger;
    $this->entityTypeManager = $entity_type_manager;
    $this->loggerFactory = $logger_factory->get('my_module');
  }

  /**
   * 注册要监听的事件
   */
  public static function getSubscribedEvents() {
    $events[UserEvents::LOGIN][] = ['onUserLogin', 0];
    return $events;
  }

  /**
   * 用户登录时触发的逻辑
   */
  public function onUserLogin(UserLoginEvent $event) {
    $account = $event->getAccount();
    
    // 先检查用户是否已有有效token,避免重复执行
    if ($this->hasValidOAuthToken($account)) {
      $this->loggerFactory->info('用户 @name 已有有效OAuth Token,跳过流程', ['@name' => $account->getDisplayName()]);
      return;
    }

    try {
      // 1. 静默获取Access Token
      $token = $this->oauthClient->getSilentAccessToken($account);
      if (!$token || !isset($token['access_token'])) {
        throw new \Exception('静默获取Token失败');
      }

      // 2. 同步第三方用户信息
      $userInfo = $this->oauthClient->getUserInfo($token['access_token']);
      if (empty($userInfo)) {
        throw new \Exception('无法获取第三方用户信息');
      }

      // 3. 存储Token和用户信息到用户实体(替代session存储,持久化保存)
      $this->saveOAuthDataToUser($account, $token, $userInfo);

      $this->messenger->addStatus($this->t('已成功关联第三方账号'));
      $this->loggerFactory->info('用户 @name 完成OAuth自动关联流程', ['@name' => $account->getDisplayName()]);
    } catch (\Exception $e) {
      $this->messenger->addError($this->t('第三方账号关联失败:@msg', ['@msg' => $e->getMessage()]));
      $this->loggerFactory->error('用户 @name 的OAuth流程失败:@msg', [
        '@name' => $account->getDisplayName(),
        '@msg' => $e->getMessage()
      ]);
    }
  }

  /**
   * 检查用户是否有有效OAuth Token
   */
  protected function hasValidOAuthToken(AccountInterface $account) {
    $user = $this->entityTypeManager->getStorage('user')->load($account->id());
    if (!$user) return false;
    
    // 假设你已创建了以下用户字段:
    // - field_oauth_access_token: 存储AccessToken
    // - field_oauth_token_expiry: 存储Token过期时间戳
    $token = $user->get('field_oauth_access_token')->value;
    $expiry = $user->get('field_oauth_token_expiry')->value;
    return !empty($token) && $expiry > time();
  }

  /**
   * 保存OAuth数据到用户实体
   */
  protected function saveOAuthDataToUser(AccountInterface $account, array $token, array $userInfo) {
    $user = $this->entityTypeManager->getStorage('user')->load($account->id());
    if (!$user) return;

    $user->set('field_oauth_access_token', $token['access_token']);
    if (isset($token['refresh_token'])) {
      $user->set('field_oauth_refresh_token', $token['refresh_token']);
    }
    if (isset($token['expires_in'])) {
      $user->set('field_oauth_token_expiry', time() + $token['expires_in']);
    }
    $user->set('field_oauth_userinfo', json_encode($userInfo));
    $user->save();
  }
}

2. 调整OAuth客户端服务,支持静默授权

修改你现有的OAuthClient类,添加静默授权的方法,同时修正原代码中的笔误:

<?php

namespace Drupal\my_module\Service;

use GuzzleHttp\ClientFactory;
use Drupal\Core\Config\ConfigFactoryInterface;
use GuzzleHttp\Exception\RequestException;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Session\AccountInterface;

class OAuthClient {
  protected $httpClient;
  protected $openIdConfig;

  public function __construct(ClientFactory $http_client_factory, ConfigFactoryInterface $config_factory) {
    $this->httpClient = $http_client_factory->fromOptions([
      'headers' => [
        'Content-Type' => 'application/x-www-form-urlencoded',
        'Accept' => 'application/json',
      ],
    ]);
    $this->openIdConfig = $config_factory->get('openid_connect.client.generic');
  }

  // ... 保留你已有的getAuthorizationUrl、getAccessToken方法 ...

  /**
   * 静默获取AccessToken(无用户交互)
   */
  public function getSilentAccessToken(AccountInterface $account) {
    $settings = $this->openIdConfig->get('settings');
    // 构造静默授权URL:prompt=none 表示跳过用户确认
    $params = [
      'response_type' => 'code',
      'client_id' => $settings['client_id'],
      'scope' => 'openid profile email',
      'redirect_uri' => $this->getRedirectUri(),
      'prompt' => 'none',
      'login_hint' => $account->getEmail(), // 可选:告诉授权服务器用户邮箱,提高成功率
    ];
    $authUrl = $settings['authorization_endpoint'] . '?' . http_build_query($params);

    try {
      // 发送静默授权请求
      $response = $this->httpClient->get($authUrl);
      parse_str(parse_url($response->getEffectiveUri(), PHP_URL_QUERY), $query);
      
      if (empty($query['code'])) {
        throw new \Exception('未获取到授权Code');
      }

      // 用Code换Token(复用已有的getAccessToken方法)
      return $this->getAccessToken($query['code']);
    } catch (RequestException $e) {
      $error = $e->hasResponse() ? Json::decode($e->getResponse()->getBody()->getContents()) : [];
      $msg = $error['error_description'] ?? $e->getMessage();
      \Drupal::logger('oauth')->error('静默授权失败:@msg', ['@msg' => $msg]);
      return false;
    }
  }

  /**
   * 修正原代码的笔误:$access_toke → $access_token
   */
  public function getUserInfo($access_token) {
    try {
      $settings = $this->openIdConfig->get('settings');
      $response = $this->httpClient->get($settings['userinfo_endpoint'], [
        'headers' => [
          'Authorization' => 'Bearer ' . $access_token,
        ],
      ]);
      return Json::decode($response->getBody()->getContents());
    } catch (RequestException $e) {
      $msg = $e->hasResponse() ? $e->getResponse()->getBody()->getContents() : $e->getMessage();
      \Drupal::logger('oauth')->error('获取用户信息失败:@msg', ['@msg' => $msg]);
      return [];
    }
  }

  // ... 保留你已有的getRedirectUri方法 ...
}

3. 注册事件订阅者服务

在你的模块my_module.services.yml中添加事件订阅者的服务声明:

services:
  my_module.oauth_client:
    class: Drupal\my_module\Service\OAuthClient
    arguments:
      - '@http_client_factory'
      - '@config.factory'
  # 新增事件订阅者服务
  my_module.oauth_login_subscriber:
    class: Drupal\my_module\EventSubscriber\OAuthLoginSubscriber
    arguments:
      - '@my_module.oauth_client'
      - '@messenger'
      - '@entity_type.manager'
      - '@logger.factory'
    tags:
      - { name: event_subscriber }

4. 准备用户字段存储OAuth数据

为了持久化保存Token和用户信息,你需要在Drupal用户实体上创建以下字段(通过后台「结构 > 账户设置 > 管理字段」添加):

  • field_oauth_access_token:单行文本字段,存储AccessToken
  • field_oauth_refresh_token:单行文本字段,存储RefreshToken
  • field_oauth_token_expiry:整数字段,存储Token过期时间戳
  • field_oauth_userinfo:多行文本字段,存储第三方用户信息的JSON字符串

5. 可选:Token过期自动刷新

为了避免Token过期后流程中断,你可以添加一个Cron任务,定期刷新即将过期的Token:

<?php

namespace Drupal\my_module\Cron;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\my_module\Service\OAuthClient;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;

class OAuthTokenRefreshCron {
  protected $entityTypeManager;
  protected $oauthClient;
  protected $loggerFactory;

  public function __construct(
    EntityTypeManagerInterface $entity_type_manager,
    OAuthClient $oauth_client,
    LoggerChannelFactoryInterface $logger_factory
  ) {
    $this->entityTypeManager = $entity_type_manager;
    $this->oauthClient = $oauth_client;
    $this->loggerFactory = $logger_factory->get('my_module');
  }

  public function refreshExpiringTokens() {
    // 查询24小时内即将过期的Token
    $query = $this->entityTypeManager->getStorage('user')->getQuery()
      ->condition('field_oauth_token_expiry', time() + 86400, '<=')
      ->condition('field_oauth_refresh_token', '', '!=');
    $uids = $query->execute();

    foreach ($this->entityTypeManager->getStorage('user')->loadMultiple($uids) as $user) {
      try {
        $newToken = $this->oauthClient->refreshAccessToken($user->get('field_oauth_refresh_token')->value);
        if (!$newToken) throw new \Exception('刷新Token失败');

        $user->set('field_oauth_access_token', $newToken['access_token']);
        if (isset($newToken['refresh_token'])) {
          $user->set('field_oauth_refresh_token', $newToken['refresh_token']);
        }
        $user->set('field_oauth_token_expiry', time() + $newToken['expires_in']);
        $user->save();
      } catch (\Exception $e) {
        $this->loggerFactory->error('用户 @name 的Token刷新失败:@msg', [
          '@name' => $user->getDisplayName(),
          '@msg' => $e->getMessage()
        ]);
      }
    }
  }
}

然后在my_module.module中注册Cron钩子:

/**
 * Implements hook_cron().
 */
function my_module_cron() {
  \Drupal::service('my_module.oauth_token_refresh_cron')->refreshExpiringTokens();
}

三、关键注意事项

  1. 授权服务器兼容性:必须确保你的OAuth2授权服务器支持prompt=none参数的静默授权,否则无交互流程无法实现。
  2. 安全最佳实践:始终使用依赖注入,避免\Drupal::静态调用;不要存储用户密码,静默授权是最安全的无交互方案。
  3. 日志与调试:添加详细的日志记录,方便排查授权失败的问题。

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.08 08:33:04