在Drupal/PHP/Symfony环境下实现无用户交互的后台OAuth2授权流程
在Drupal/PHP/Symfony环境下实现无用户交互的后台OAuth2授权流程
嘿,我看你现在的OAuth2流程是靠手动触发控制器路由来运行的,需要用户跳转到授权页面才能完成。你想要用户登录Drupal之后,自动在后台悄咪咪完成整个授权、拿token、同步用户信息的流程,完全不需要用户手动操作对吧?没问题,我来一步步帮你实现这个需求~
一、核心思路:利用Drupal的用户登录事件触发流程
Drupal提供了UserEvents::LOGIN事件,当用户成功登录时会自动触发。我们可以创建一个事件订阅者,在这个事件里启动OAuth2流程,替代原来的手动控制器触发逻辑。
不过这里要提前说明:标准的授权码流程需要用户跳转确认授权,要实现无交互,你得确保你的OAuth2授权服务器支持以下两种方式之一:
- 支持静默授权(通过
prompt=none参数跳过用户确认) - 支持资源所有者密码凭证授权(仅适合内部信任的服务,用用户账号密码直接换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:单行文本字段,存储AccessTokenfield_oauth_refresh_token:单行文本字段,存储RefreshTokenfield_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(); }
三、关键注意事项
- 授权服务器兼容性:必须确保你的OAuth2授权服务器支持
prompt=none参数的静默授权,否则无交互流程无法实现。 - 安全最佳实践:始终使用依赖注入,避免
\Drupal::静态调用;不要存储用户密码,静默授权是最安全的无交互方案。 - 日志与调试:添加详细的日志记录,方便排查授权失败的问题。
内容来源于stack exchange
相关产品推荐
相关产品推荐

