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


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

参考にしたのは、SymfonyのGitHubでの「Sharing security context across multiple firewalls #11836」というIssueです。またSymfony3.2での動作を確認してます。

security.yml

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

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

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でログインできます。

    /**
     * @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で、トークンを再登録することになります。

    /**
     * @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」というのがあります。

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

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

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

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

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

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