読者です 読者をやめる 読者になる 読者になる

It's raining cats and dogs.

無駄なことなんてないはず

標準の認証機構におけるログアウト時のセッション破棄

追記3/12

brtRiverさんからのご指摘があり、もしかしたら下記の記事は若干誤りがあるかもしれません。
後日再度検証してみるので、お気をつけください。お騒がせします。

事の発端

とある案件で、symfonyの標準のログインログアウト処理を調べていたところ、
特に意識せず、デフォルトでsymfonyの認証機構を使っているとログアウト時にセッションを破棄してくれないことに気がついた。

想定される危険性

まぁそうですよね。

セッションハイジャックの防止策

セッションハイジャック対策のひとつで

  • ログアウト時のセッション破棄

という対策がある。

もちろんこれだけやっておけばいいというものではなくて、ただの対策のひとつであるということだけはお間違えないように。

なぜログアウト時にセッションを破棄する必要があるのか?

セッションハイジャックされる方法のひとつとして
例えば、ログイン状態(セッションIDなど)を普通にセキュアでないcookieで持っている場合(以下ログインcookie)、
通信が盗聴されてしまうと当然ログインcookieの中身も見られてしまうわけで、
ログインcookieの中身が見られてしまうと、そのcookieの情報(セッションID)を使って対象のサービスに誰でもログインできてしまう(すなわちハイジャックされる)。

このとき、たとえユーザーが対象のサイトをログアウトしていてもセッションを破棄できていなければ、何かしらの手段でログインしていたときのセッションIDを盗まれた場合、当然そのセッションIDを使ってハイジャックされてしまう。

そもそもの話

そもそもセキュアcookieでログイン状態を持ったほうがいいとか
ログイン時にセッションを新しく発行するとか、あれこれ対策はあるとおもうけど
今回はとりあえずログアウト時のセッション破棄についてフォーカス。

※そのほかの対策に関しては、世の中にたくさん出回っているし、↑のIPAの資料にも記載されているので、いろいろ調べてみてください><

確認した環境

以下のバージョンのsymfonyのソースを流し読み

  • symfony1.1.9
  • symfony1.2.12
  • symfony1.3.9
  • symfony1.4.9

※symfony2系はよくわからんです。

よくあるログイン・ログアウト処理

さっそくsymfonyでよくあるログイン・ログアウトの処理を考えてみる

<?php
// たとえばログイン周りを実装しているactionsクラス
class sessionActions extends sfActions
{
  // ログイン時のアクション
  public function executeLogin()
  {
    // idとパスワードチェックとか
    ...
    // ログイン処理
    $this->getUser()->setAuthenticated(true);
    // 認証を通ったuserモデルとかもきっとsessionに突っ込んでおいたりする。
  }
  // ログアウト時のアクション
  public function executeLogout()
  {
    ...
    // ログアウト処理
    $this->getUser()->setAuthenticated(false);
  }
}

ちょっと解説すると
$this->getUser() ではセッションオブジェクトが取得できる。
symfonyでセッションを扱う場合は、このオブジェクトに対してsetAttribute()などでセッションに情報を保持する。

取得できるオブジェクトはsymfonyではsfUserクラスの派生クラスなんだけど、symfonyの認証機構を使う場合は、
sfUserを継承したsfBasicSecurityUserクラスを継承してmyUserクラスなんてのを作って、それをセッション用として利用する人が多いだろう。
(この指定はapps/hogehoge/config/factories.ymlで設定できる。)
↓こんな感じ。

┌-----------------------┐
│        sfUser         │
├-----------------------┤
│+setAttribute(key, val)│
└-----------------------┘
            ↑
┌-----------------------┐
│  sfBasicSecurityUser  │
├-----------------------┤
│+isAuthenticated(void) │
│+setAuthenticated(bool)│
└-----------------------┘
            ↑
┌-----------------------┐
│        myUser         │
└-----------------------┘

sfBasicSecurityUserクラスでは、setAuthenticated()とかisAuthenticated()のようなログインログアウトなどの認証機構を利用するための機能が備わっている。
ログイン・ログアウトを行うときは、上のコードでも書いたけど、このあたりのメソッドを使うようだ。

なにが問題?

ログアウト処理で

<?php
$this->getUser()->setAuthenticated(false);

とすると何がおきるのか?
↓のコードをみてみる。

<?php
// user/sfUser.class.php
class sfUser
{
  protected $storage = null;
  ...
}
// user/sfBasicSecurityUser.class.php
class sfBasicSecurityUser extends sfUser implements sfSecurityUser
{
  public function setAuthenticated($authenticated)
  {
    if ($this->options['logging'])
    {
      $this->dispatcher->notify(new sfEvent($this, 'application.log', array(sprintf('User is %sauthenticated', $authenticated === true ? '' : 'not '))));
    }

    if ((bool) $authenticated !== $this->authenticated)
    {
      if ($authenticated === true)
      {
        $this->authenticated = true;
      }
      else
      {
        $this->authenticated = false;
        $this->clearCredentials();
      }

      $this->dispatcher->notify(new sfEvent($this, 'user.change_authentication', array('authenticated' => $this->authenticated)));

      // 1. ★ここでfalseしてる
      $this->storage->regenerate(false);
    }
  }
}

なんやかんやで、

<?php
// 1. ★ここでfalseしてる
$this->storage->regenerate(false);

にたどり着く。
で、こいつは何をしているのか?
デフォルトだと、sfSessionStorageとかいうクラスが使用されるみたいので、そのクラスを見てみる。
(このあたりは作るシステムによってちがうとおもう。apps/hogehoge/config/factories.yml参照)

<?php
// storage/sfSessionStorage.class.php
class sfSessionStorage extends sfStorage
{
  public function regenerate($destroy = false)
  {
    if (self::$sessionIdRegenerated)
    {
      return;
    }

    // 2. ★$destroyはfalseが渡ってくる
    session_regenerate_id($destroy);

    self::$sessionIdRegenerated = true;
  }
}

ここだ。

<?php
// 2. ★$destroyはfalseが渡ってくる
session_regenerate_id($destroy);

sfBasicSecurityUserで$this->storage->regenerate(false)というようにfalseを渡しているので
↑の$destroyにはfalseを渡している。
そもそもsession_regenerate_idはどんな関数なのだろうか?

session_regenerate_id() は現在のセッションIDを 新しいものと置き換えます。その際、現在のセッション情報は維持されます。

http://jp.php.net/manual/ja/function.session-regenerate-id.php

なるほど。。。
では第一引数のbooleanはどういう意味があるのだろうか?

関連付けられた古いセッションを削除するかどうか。

http://jp.php.net/manual/ja/function.session-regenerate-id.php

ほ、ほう。。。

なので、session_regenerate_id(false)とすると
セッションは破棄されずに、新しいセッションだけ作ってPHPからは新しく作ったセッションとのヒモ付がされるだけということになる。
ということは、regenerateされる前のセッションIDがわかってしまえば、たとえログアウトしていても、再度ログインできてしまうというわけだ。

解決策

で、どうするか?
いくつか対処の仕方があると思うので、ベストプラクティスとは思わないけど、今回はmyUserでsetAuthenticatedをオーバーライドして対応した。
こういうイメージ↓

┌-----------------------┐
│        sfUser         │
├-----------------------┤
│+setAttribute(key, val)│
└-----------------------┘
            ↑
┌-----------------------┐
│  sfBasicSecurityUser  │
├-----------------------┤
│+isAuthenticated(void) │
│+setAuthenticated(bool)│
└-----------------------┘
            ↑
┌-----------------------┐
│        myUser         │
├-----------------------┤
│+setAuthenticated(bool)│ ←オーバーライド
└-----------------------┘

コードは↓のようにした。まぁsession_regenerate_idにtrueを渡しただけ。

<?php
// user/sfBasicSecurityUser.class.php
class myUser extends sfBasicSecurityUser
{
  // @Override
  public function setAuthenticated($authenticated)
  {
    if ($this->options['logging'])
    {
      $this->dispatcher->notify(new sfEvent($this, 'application.log', array(sprintf('User is %sauthenticated', $authenticated === true ? '' : 'not '))));
    }

    if ((bool) $authenticated !== $this->authenticated)
    {
      if ($authenticated === true)
      {
        $this->authenticated = true;
      }
      else
      {
        $this->authenticated = false;
        $this->clearCredentials();
      }

      $this->dispatcher->notify(new sfEvent($this, 'user.change_authentication', array('authenticated' => $this->authenticated)));

      // ★ここでtrueを渡すようにした
      $this->storage->regenerate(true);
    }
  }
}

ちなみに

sessionはDBとかにも保存する。
symfonyにはDBにセッションを格納するとき用のクラスもあるので、そのクラスも見てみる。

<?php
// storage/sfDatabaseSessionStorage.class.php
abstract class sfDatabaseSessionStorage extends sfSessionStorage
{
  public function regenerate($destroy = false)
  {
    if (self::$sessionIdRegenerated)
    {
      return;
    }

    $currentId = session_id();

    parent::regenerate($destroy);

    $newId = session_id();
    $this->sessionRead($newId);

    // ★セッション用のレコードを消してない!
    return $this->sessionWrite($newId, $this->sessionRead($currentId));
  }
}

確認したわけではないけど、ソースを見るだけだとセッション用のレコードは削除していないと思われる。
残念。

まとめ

ログアウトするときはきちんとセッションを破棄しているかも気にしましょうねって話。


おわり