Symfony認証でパスワード無しでログインする方法

脆弱性の話のようなタイトルですが、例えば管理とユーザーのページが別々に存在していて、運用のために管理画面から任意のユーザーの画面にアクセスしたい場合です。ユーザーのパスワードはハッシュ化されてわからないので、パスワードなしでログインすることになります。

> 【追記2022.03.28】ものすごく簡単に実装できるようになってました。
> - [How to Impersonate a User](https://symfony.com/doc/current/security/impersonating_user.html)


参考にしたのは、SymfonyのGitHubでの「[Sharing security context across multiple firewalls #11836](https://github.com/symfony/symfony/issues/11836)」というIssueです。またSymfony3.2での動作を確認してます。

## security.yml

次のような`security.yml`があったとします。Guardを使って認証してますが、おそらくそこは関係ないはず。

`AppBundle:User\User`がユーザーのエンティティを表しているとします。
管理者は`admin`にログインした後、任意のユーザーにログインします。それには`user`というファイヤウォールを突破する必要があります。

```yaml
security:
    providers:
        user_db_provider:
            entity:
                class: AppBundle:User\User
                property: mail
    firewalls:
        admin:
            pattern: ^/admin/
            # ... 省略
        user:
            context: user    # <- これがログインするコンテクスト
            pattern: ^/user/
            anonymous: ~
            guard:
                authenticators:
                    - app.tanto_authenticator
    access_control:
        - { path: ^/user/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/user/, roles: ROLE_USER}
```

ポイントは`context:`で指定している文字列（`user`）です。

## Controllerのコード

管理者用コントローラーのサンプルコードです。


`loginAction`を使って`user_id`のユーザーにログインします。最初にユーザーのエンティティを取得してから、`loginToContext`でログインできます。

```php
    /**
     * @Config\Route("/admin/user/{user_id}/login", name="admin-user-login")
     * @param int     $user_id
     * @return Response
     */
    public function loginAction($user_id)
    {
        $user = $this->getDoctrine()->getManager()
             ->getRepository(User::class)->find($user_id);
        if (!$user) {
            return $this->redirectToUserList('ユーザーを読み込めません');
        }
        $this->loginToContext($user, 'user');
        return $this->redirectToRoute('user-top'); // ユーザー画面にリダイレクト
    }
    /**
     * @param UserInterface $user
     * @param string        $context
     * @param array         $roles
     */
    private function loginToContext(UserInterface $user, $context, $roles = [])
    {
        $roles   = $roles ?: $user->getRoles();
        $token   = new UsernamePasswordToken($user, null, $context, $roles);
        $session = $this->get('session');
        $session->set('_security_'.$context, serialize($token));
        $session->save();
    }
```

`loginToContextメソッド`をそのまま解説すると、

* `UsernamePasswordToken`オブジェクトを作成して、
* `_security_user`というセッション名でオブジェクトを保存すると
* `user`という名前のcontextにログインできます。

この方法の注意点としては、現在ログイン中の`$context`を変更することはできません。あくまでadminにログインしている状態で、userにログインする方法です。


### 現在のコンテクストを更新する

※番外編

例えば、アクティベーションなどの処理でユーザーのロールを変更する場合です。現在のSecurity.contextで、トークンを再登録することになります。

```php
    /**
     * @param UserInterface $user
     * @param string        $context
     * @param array         $roles
     */
    private function updateContext(UserInterface $user, $context, $roles = [])
    {
        $roles   = $roles ?: $user->getRoles();
        $token   = new UsernamePasswordToken($user, null, $context, $roles);
        $storage = $this->get('security.token_storage');
        $storage->getToken()->setAuthenticated(false);
        $storage->setToken($token);
    }
```

要は、`setAuthenticated(false)`として一旦トークンを無効にしてから、`setToken($token) `で再設定します。先の直接セッションを設定する方法だと、その後のタイミングで元のトークンに戻ってしまうみたいです。


## Firewall Context

ところでcontextって何でしょうね？

Symfonyのドキュメントだと「[SecurityBundle Configuration ("security"): Firewall Context](http://symfony.com/doc/current/reference/configuration/security.html#reference-security-firewall-context)」というのがあります。

次のように`context`は省略が可能で、指定されていない場合はファイヤウォール名（この場合は`user`）となるとのこと。

```yaml
security:
    firewalls:
        user:    # コンテキストがないので、この名前が使われる。
            pattern: ^/user/
            anonymous: ~
            guard:
                authenticators:
                    - app.tanto_authenticator
```

> でも結局コンテクストが何かはわからないですね。ログイン情報をセッションに保存するときの名前としか。でもGuardとContextで違うエンティティを使ったりしたら、どんな挙動になるんでしょうね。

ちなみにトークンからコンテクストを取得するには、`getProviderKey()`が使えます。

```php
    $storage = $this->get('security.token_storage');
    $context = $storage->getToken()->getProviderKey();
```

がAPIとしては存在しないので、使うのは避けたほうが良いかも。
そもそもセッションを直接扱うとか、このあたりの処理はハックな感じがします。