Symfony 用户切换:进入、退出、授权限制与会话事件

排查用户看到的问题时,有时需要从当前账号切换到另一个用户,而不必先退出再重新登录。Symfony 的用户模拟功能可以完成这种切换,同时保留返回原操作者身份的能力。

某些认证机制要求每次请求重新传递身份信息,例如 REMOTE_USER;用户模拟与这类机制不兼容。以下配置以核对时显示的 Symfony 8.1 文档为依据,配置中的类、路由和用户字段需要与你的应用对应。本文没有登录任何账户,也没有在应用中执行身份切换。

启用切换并返回原用户

在防火墙中启用 switch_user 监听器。YAML 配置如下:

# config/packages/security.yaml
security:
    # ...

    firewalls:
        main:
            # ...
            switch_user: []

对应的 PHP 配置如下,两种格式选择项目正在使用的一种:

// config/packages/security.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return App::config([
    'security' => [
        // ...
        'firewalls' => [
            'main' => [
                'switch_user' => [],
            ],
        ],
    ],
]);

在当前 URL 后添加 _switch_user 查询参数,值是用户名,或用户提供器用来查找用户的其他字段。例如:

http://example.com/somewhere?_switch_user=thomas

模板中也可以使用 Twig 函数 impersonation_path('thomas') 生成切换路径。

如果希望通过自定义 HTTP 请求头提供用户名,可以修改 parameter。例如,使用 X-Switch-User 请求头时,PHP 中对应的名称为 HTTP_X_SWITCH_USER:

# config/packages/security.yaml
security:
    # ...
    firewalls:
        main:
            # ...
            switch_user: { parameter: X-Switch-User }

PHP 配置:

// config/packages/security.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return App::config([
    'security' => [
        // ...
        'firewalls' => [
            'main' => [
                'switch_user' => [
                    'parameter' => 'X-Switch-User',
                ],
            ],
        ],
    ],
]);

返回原始用户时,使用特殊值 _exit:

http://example.com/somewhere?_switch_user=_exit

Twig 提供 impersonation_exit_path('/somewhere'),可以生成退出模拟的路径。

默认情况下,只有拥有 ROLE_ALLOWED_TO_SWITCH 角色的用户才能使用该功能。可以通过角色层级把该权限授予真正需要排障的人员;启用监听器并不意味着所有登录用户都能切换身份。

判断当前是否处于模拟状态

特殊授权属性 IS_IMPERSONATOR 用于判断当前会话是否正在模拟另一个用户。例如,可以在模板中显示退出链接:

{% if is_granted('IS_IMPERSONATOR') %}
    <a href="{{ impersonation_exit_path(path('homepage')) }}">Exit impersonation</a>
{% endif %}

链接文字在原示例中为英文。它检查的是模拟状态,不应把它当作普通业务角色授予用户。

取得原始操作者

有时需要的是发起模拟的操作者,而不是当前被模拟用户。处于模拟状态时,令牌存储中的令牌是 SwitchUserToken。取出它保存的原始令牌,就能访问原操作者:

// src/Service/SomeService.php
namespace App\Service;

use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
// ...

class SomeService
{
    public function __construct(
        private Security $security,
    ) {
    }

    public function someMethod(): void
    {
        // ...

        $token = $this->security->getToken();
        if ($token instanceof SwitchUserToken) {
            $impersonatorUser = $token->getOriginalToken()->getUser();
        }

        // ...
    }
}

这种区分对于排障记录很有用:业务行为归属于哪个目标账号,与实际上由谁发起操作,是两项不同信息。示例只展示访问原令牌的方法,没有自动创建审计记录。

修改角色与查询参数名

用户切换应只向受限人员开放。可以通过 role 改变要求的角色,通过 parameter 改变查询参数名:

# config/packages/security.yaml
security:
    # ...

    firewalls:
        main:
            # ...
            switch_user: { role: ROLE_ADMIN, parameter: _want_to_be_this_user }

PHP 配置:

// config/packages/security.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
return App::config([
    'security' => [
    // ...
        'firewalls' => [
            'main' => [
                'switch_user' => ['role' => 'ROLE_ADMIN', 'parameter' => '_want_to_be_this_user'],
            ],
        ],
    ],
]);

这样配置后,要求的角色变为 ROLE_ADMIN,参数名变为 _want_to_be_this_user。选择角色时应按项目的权限设计决定,不能仅因为示例使用管理员角色就把所有管理功能与模拟能力绑定在一起。

切换后重定向到指定路由

target_route 可以控制切换后的目标路由。这项功能只适用于有状态的防火墙:

# config/packages/security.yaml
security:
    # ...

    firewalls:
        main:
            # ...
            switch_user: { target_route: app_user_dashboard }

PHP 配置:

// config/packages/security.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return App::config([
    'security' => [
        // ...
        'firewalls' => [
            'main' => [
                'switch_user' => ['target_route' => 'app_user_dashboard'],
            ],
        ],
    ],
]);

app_user_dashboard 是路由名,需要由你的应用定义。该选项控制路由目标,不会替代目标页面自己的访问检查。

用 Voter 限制允许的切换

如果简单的角色检查不够,可以使用安全 Voter。先把 switch_user.role 配置为一个自定义授权属性。名称可以自定,但不能以 ROLE_ 开头,以便交给你的 Voter 处理:

# config/packages/security.yaml
security:
    # ...

    firewalls:
        main:
            # ...
            switch_user: { role: CAN_SWITCH_USER }

PHP 配置:

// config/packages/security.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return App::config([
    'security' => [
        // ...
        'firewalls' => [
            'main' => [
                'switch_user' => ['role' => 'CAN_SWITCH_USER'],
            ],
        ],
    ],
]);

随后创建一个支持这个属性的 Voter,并加入业务需要的授权逻辑。下例保留原文的角色判断,但按 Symfony 8.1 的 Voter 基类补入了 Vote 的导入和可选参数。原页面的三参数 voteOnAttribute 签名未包含这一参数;复制到 8.1 项目时应使用与实际基类兼容的签名。

// src/Security/Voter/SwitchToCustomerVoter.php
namespace App\Security\Voter;

use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\User\UserInterface;

class SwitchToCustomerVoter extends Voter
{
    public function __construct(
        private AccessDecisionManagerInterface $accessDecisionManager,
    ) {
    }

    protected function supports($attribute, $subject): bool
    {
        return in_array($attribute, ['CAN_SWITCH_USER'])
            && $subject instanceof UserInterface;
    }

    protected function voteOnAttribute($attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
    {
        $user = $token->getUser();
        // if the user is anonymous or if the subject is not a user, do not grant access
        if (!$user instanceof UserInterface || !$subject instanceof UserInterface) {
            return false;
        }

        // you can still check for ROLE_ALLOWED_TO_SWITCH
        if ($this->accessDecisionManager->decide($token, ['ROLE_ALLOWED_TO_SWITCH'])) {
            return true;
        }

        // check for any roles you want
        if ($this->accessDecisionManager->decide($token, ['ROLE_TECH_SUPPORT'])) {
            return true;
        }

        /*
         * or use some custom data from your User object
        if ($user->isAllowedToSwitch()) {
            return true;
        }
        */

        return false;
    }
}

Voter 通过 $token->getUser() 取得当前操作者,$subject 是目标用户。原示例允许 ROLE_ALLOWED_TO_SWITCH 或 ROLE_TECH_SUPPORT 的操作者切换;类名虽然包含 Customer,代码仅检查目标实现 UserInterface,没有自动限制目标必须是客户账号。如果需要禁止模拟管理员、限制同一租户或排除特殊账户,必须明确加入针对 $subject 的规则。

示例中注释掉的 isAllowedToSwitch() 展示了另一种业务判断位置,它不是 Symfony 用户接口自带的方法。若 Voter 没有被调用,继续对照使用 Voter 检查权限排查属性、服务注册和决策规则。

跨多个防火墙切换用户

应用可以让多个防火墙通过 context 共享安全上下文。此时要特别检查 switch_user 使用哪个用户提供器。

默认情况下,switch_user 使用所在防火墙配置的提供器。如果原操作者与目标用户来自不同的提供器,退出模拟时就可能失败:监听器尝试通过错误的提供器重新加载原用户。

解决方法是为 switch_user 指定一个链式用户提供器,其中同时包含原操作者与目标用户的提供器:

# config/packages/security.yaml
security:
    providers:
        admin_provider:
            entity:
                class: App\Entity\Admin
                property: username
        user_provider:
            entity:
                class: App\Entity\User
                property: email
        all_users:
            chain:
                providers: ['admin_provider', 'user_provider']

    firewalls:
        admin:
            pattern: ^/admin
            context: my_context
            provider: admin_provider
            switch_user:
                provider: all_users
            # ...
        main:
            pattern: ^/
            context: my_context
            provider: user_provider
            # ...

PHP 配置:

// config/packages/security.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use App\Entity\Admin;
use App\Entity\User;

return App::config([
    'security' => [
        // ...
        'providers' => [
            'admin_provider' => [
                'entity' => [
                    'class' => Admin::class,
                    'property' => 'username',
                ],
            ],
            'user_provider' => [
                'entity' => [
                    'class' => User::class,
                    'property' => 'email',
                ],
            ],
            'all_users' => [
                'chain' => [
                    'providers' => ['admin_provider', 'user_provider'],
                ],
            ],
        ],

        'firewalls' => [
            'admin' => [
                'pattern' => '^/admin',
                'provider' => 'admin_provider',
                'context' => 'my_context',
                'switch_user' => [
                    'provider' => 'all_users',
                ],
            ],
            'main' => [
                'pattern' => '^/',
                'provider' => 'user_provider',
                'context' => 'my_context',
            ],
        ],
    ],
]);

这里的 admin_provider 根据用户名加载 Admin,user_provider 根据邮箱加载 User,all_users 把两者连接起来。监听器因此可以在进入模拟时加载普通用户,在退出时加载管理员。示例中的实体、查询字段、防火墙顺序和共享上下文都需要与实际应用一致。

在切换事件中同步会话信息

在模拟身份完全生效之前,Symfony 会派发 security.switch_user 事件。监听器或订阅器会收到 SwitchUserEvent,可从中取得即将成为目标的用户。

退出模拟也会在完全退出之前派发同一事件;此时目标用户就是恢复后的原始操作者。因此,同一订阅器可以处理进入与退出两种方向。

普通会话区域不会因为用户切换自动更新语言区域。如果应用需要同步 locale,可以订阅这个事件:

// src/EventSubscriber/SwitchUserSubscriber.php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Event\SwitchUserEvent;
use Symfony\Component\Security\Http\SecurityEvents;

class SwitchUserSubscriber implements EventSubscriberInterface
{
    public function onSwitchUser(SwitchUserEvent $event): void
    {
        $request = $event->getRequest();

        if ($request->hasSession() && ($session = $request->getSession())) {
            $session->set(
                '_locale',
                // assuming your User has some getLocale() method
                $event->getTargetUser()->getLocale()
            );
        }
    }

    public static function getSubscribedEvents(): array
    {
        return [
            // constant for security.switch_user
            SecurityEvents::SWITCH_USER => 'onSwitchUser',
        ];
    }
}

示例假设用户对象有 getLocale() 方法。存在会话时,订阅器把目标用户的 locale 写入 _locale。如果采用默认 services.yaml 配置,Symfony 会自动发现服务,在切换时调用 onSwitchUser。更多配置方式见事件与监听器。

来源与许可

原文:How to Impersonate a User,Symfony 文档贡献者;核对时页面标为 Symfony 8.1,核对日期为 2026-10-03。原文明确声明正文与代码示例均采用 Creative Commons BY-SA 3.0。

本中文译文与文内改编代码同样按 CC BY-SA 3.0 提供,保留来源与许可链接。修改包括中文翻译、配置用途说明、8.1 Voter 可选 Vote 参数修正,以及原示例并未限制目标账号类别的提示。全部 18 个原文代码块均保留;除明确标出的 Voter 签名修正外,不改变代码内容。网页导航、广告和贡献者头像未收录。代码仅做静态核对,未启动 Symfony、执行 PHP 或验证真实身份切换;材料不构成运行结果或项目认可。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容