モノノフ日記

普通の日記です

Jobeet - 13日目: ユーザ

http://www.symfony-project.org/jobeet/1_2/Propel/en/13

昨日はたくさんの情報でいっぱいでした。ほんの数行のPHPコードだけで、symfonyのアドミンジェネレータは開発者が短時間でバックエンドのインターフェースを作成するのを可能にしています。
今日は、symfonyにおけるHTTPリクエスト間での永続的なデータの管理方法を見ていきます。ご存知の通り、HTTPプロトコルはステートレスであり、各リクエストは前処理や実処理から独立していることを意味します。現代のウェブサイトはユーザエクスペリエンスをよりよくするためにリクエスト間でデータを持続する方法が必要となります、
ユーザセッションはクッキーを使うことがよく知られています。symfonyでは、開発者はセッションを直接操作する必要はありませんが、アプリケーションのエンドユーザに相当するsfUserオブジェクトを使うことが推奨されています。

ユーザフラッシュ

すでにフラッシュを用いたアクション内でのユーザオブジェクトは確認しました。フラッシュはユーザセッションに保存されたひとときのメッセージであり、次のリクエスト時には自動で削除されています。リダイレクト後、ユーザにメッセージを表示したいときに非常に役に立ちます。アドミンジェネレータはジョブが保存されたり、削除されたり、拡張されるときはいつでもユーザへフィードバックを表示するため、フラッシュを多用しています。

f:id:Kiske:20090313221011p:image

フラッシュはsfUserオブジェクトのsetFlash()メソッドを使ってセットされます。

<?php
// apps/frontend/modules/job/actions/actions.class.php
public function executeExtend(sfWebRequest $request)
{
  $request->checkCSRFProtection();
 
  $job = $this->getRoute()->getObject();
  $this->forward404Unless($job->extend());
 
  $this->getUser()->setFlash('notice', sprintf('Your job validity has been extend until %s.', $job->getExpiresAt('m/d/Y')));
 
  $this->redirect($this->generateUrl('job_show_user', $job));
}

第1引数はフラッシュの識別子であり、第2引数は表示したいメッセージです。セットしたいと思う識別子は何でも定義できます、しかしnoticeとerrorはよく使われる識別子です。(アドミンジェネレータでも広く使われています)
テンプレートにフラッシュメッセージを含ませるのは開発者次第です。Jobeetではlayout.phpで出力させています。

// apps/frontend/templates/layout.php
<?php if ($sf_user->hasFlash('notice')): ?>
  <div class="flash_notice"><?php echo $sf_user->getFlash('notice') ?></div>
<?php endif; ?>
 
<?php if ($sf_user->hasFlash('error')): ?>
  <div class="flash_error"><?php echo $sf_user->getFlash('error') ?></div>
<?php endif; ?>

テンプレート内では、特別なsf_user変数を利用してユーザオブジェクトにアクセスできます。

いくつかのsymfonyのオブジェクトはアクションから明示的なパスは必要とせずいつでもテンプレートからアクセス可能です。
それらはsf_request、sf_user、sf_responseです。

ユーザ属性

残念なことに、Jobeetユーザストーリーはユーザセッションの中に何かデータを保存するということを含んだ要件ではありません。ですから、新しい要件を追加してみましょう。新しい要件はジョブのブラウジングを容易するため、ユーザによって閲覧された最新のジョブ3つをあとでジョブページに戻ってきたときにリンク付きでメニュー内に表示させるようにします。
ユーザがジョブページにアクセスするとき、表示されているジョブオブジェクトをユーザヒストリー内に追加し、セッションに保存することが必要となります。

<?php
// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->job = $this->getRoute()->getObject();
 
    // fetch jobs already stored in the job history
    $jobs = $this->getUser()->getAttribute('job_history', array());
 
    // add the current job at the beginning of the array
    array_unshift($jobs, $this->job->getId());
 
    // store the new job history back into the session
    $this->getUser()->setAttribute('job_history', $jobs);
  }
 
  // ...
}

セッション内に直接JobeetJobオブジェクトを都合よく保存させています。セッション変数はリクエスト間でシリアライズされているため、この動作は全く推奨されません。セッションがロードされると、JobeetJobオブジェクトはシリアライズを解除され、行き詰まります。そうしている間にもセッションは修正されたり、削除されています。

getAttribute(), setAttribute()

識別子が既知であるため、sfUser::getAttribute()メソッドはユーザセッションから値を取得します。逆にsetAttribute()メソッドは与えられた識別子でセッション内にPHPの値を保存します。
もし識別子がまだ定義されていないなら、getAttribute()メソッドは任意のデフォルト値を返します。

getAttribute()メソッドのデフォルト値のショートカットはこうなります。

<?php
if (!$value = $this->getAttribute('job_history'))
{
  $value = array();
}
myUserクラス

関連性の分離をより重視するため、myUserクラスへコードを移します。myUserクラスはアプリケーション特有の動作とともにsymfony標準のsfUserクラスをオーバーライドします。

<?php
// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->job = $this->getRoute()->getObject();
 
    $this->getUser()->addJobToHistory($this->job);
  }
 
  // ...
}
 
// apps/frontend/lib/myUser.class.php
class myUser extends sfBasicSecurityUser
{
  public function addJobToHistory(JobeetJob $job)
  {
    $ids = $this->getAttribute('job_history', array());
 
    if (!in_array($job->getId(), $ids))
    {
      array_unshift($ids, $job->getId());
 
      $this->setAttribute('job_history', array_slice($ids, 0, 3));
    }
  }
}

コードは全ての必要要件を考慮した変更となります。

  • !in_array($job->getId(), $ids)
    • ジョブはヒストリーに2回保存することは不可
  • array_slice($ids, 0, 3)
    • ユーザによって閲覧された最新のジョブ3つだけを表示

レイアウトでは$sf_content変数が出力される前に、下記コードを追加します。

// apps/frontend/templates/layout.php
<div id="job_history">
  Recent viewed jobs:
  <ul>
    <?php foreach ($sf_user->getJobHistory() as $job): ?>
      <li>
        <?php echo link_to($job->getPosition().' - '.$job->getCompany(), 'job_show_user', $job) ?>
      </li>
    <?php endforeach; ?>
  </ul>
</div>
 
<div class="content">
  <?php echo $sf_content ?>
</div>

レイアウト内では、現在のジョブヒストリーを取得するため、getJobHistory()という新しいメソッドを使います。

<?php
// apps/frontend/lib/myUser.class.php
class myUser extends sfBasicSecurityUser
{
  public function getJobHistory()
  {
    $ids = $this->getAttribute('job_history', array());
 
    return JobeetJobPeer::retrieveByPKs($ids);
  }
 
  // ...
}

getJobHistory()メソッドは1回のメソッドコールでいくつかのJobeetJobオブジェクトを取得するために、PropelのretrieveByPKs()メソッドを使います。

f:id:Kiske:20090313221546p:image

sfParameterHolder

ジョブヒストリーAPIを完成させるため、ヒストリーをリセットするためのメソッドを追加しましょう。

<?php
// apps/frontend/lib/myUser.class.php
class myUser extends sfBasicSecurityUser
{
  public function resetJobHistory()
  {
    $this->getAttributeHolder()->remove('job_history');
  }
 
  // ...
}

ユーザ属性はsfParameterHolderクラスのオブジェクトで管理されています。getAttribute()とsetAttribute()メソッドはgetParameterHolder()->get()とgetParameterHolder()->set()のプロキシメソッドです。remove()メソッドはsfUserオブジェクト内にプロキシメソッドが無いので、パラメータフォルダオブジェクトを直接使う必要があります。

sfParameterHolderクラスはsfRequestクラスがパラメータを保存するのにも使われます。

アプリケーションセキュリティ

認証

その他たくさんのsymfonyの要素のように、セキュリティはYAMLファイル(security.yml)で管理されます。例えば、バックエンドアプリケーションのconfig/ディレクトリ内でデフォルト設定を見れます。

# apps/backend/config/security.yml
default:
  is_secure: off

is_secureエントリをオンに切り替えると、バックエンドアプリケーション全体がユーザ認証が必要となります。

f:id:Kiske:20090313221707p:image

YAMLファイル内で、boolean値はtrueとfalseの組み合わせか、onとoffの組み合わせで表現されます。

Webデバッグツールバーのlogsを見てみると、defaultActionsクラスのexecuteLogin()メソッドがアクセスしようとした全ページで呼ばれているのに気が付くでしょう。

f:id:Kiske:20090313221735p:image

認証されていないユーザがセキュアなアクションへアクセスしようとするとき、symfonyはsettings.ymlで設定されているログインアクションへリクエストをフォワードします。

all:
  .actions:
    login_module: default
    login_action: login

無限に再帰するのを避けるためログインアクションをセキュアにするのは可能性がありません。

4日目で見たように、いくつか場所で同じ設定ファイルが定義されます。security.ymlでも同様のことが起こります。1つのアクションだけやモジュール全体をセキュアにしたり、セキュアでなくするにはモジュール内の config/ ディレクトリにsecurity.ymlファイルを作成します。

index:
  is_secure: off
 
all:
  is_secure: on

デフォルトではmyUserクラスはsfBasicSecurityUserクラスを継承しており、sfUserではありません。sfBasicSecurityUserはユーザの認証や認可を管理するための追加メソッドを備えています。
ユーザ認証を管理するため、isAuthenticated()、setAuthenticated()メソッドを使います。

<?php
if (!$this->getUser()->isAuthenticated())
{
  $this->getUser()->setAuthenticated(true);
}
認可

ユーザを認証するとき、いくつかのアクションへのアクセスは定義された証明書(credentials)によって、さらに限定することができます。ユーザはページにアクセスするために必要な証明書を持つ必要があります。

default:
  is_secure:   off
  credentials: admin

symfonyの証明書システムは非常にシンプルで強力です。証明書はアプリケーションのセキュリティモデル(グループやパーミッションのようなもの)を説明するのに必要なものを示します。

複雑な証明書

security.ymlのcredentialsエントリーは複雑な証明書記述するのに必要なBoolean演算子をサポートします。
もしユーザはAとBの証明書が必要とされるなら、ブラケットで証明書を囲みます。

index:
  credentials: [A, B]

AかBどちらかの証明書が必要なら、2ペアのブラケットで囲みます。

index:
  credentials: [[A, B]]

ブラケットをうまく組み合わせることで、さまざまな種類のBoolean表現が記述することができます。

ユーザの証明書を管理するため、sfBasicSecurityUserはいくつかのメソッドを備えています。

<?php
// Add one or more credentials
$user->addCredential('foo');
$user->addCredentials('foo', 'bar');
 
// Check if the user has a credential
echo $user->hasCredential('foo');                      =>   true
 
// Check if the user has both credentials
echo $user->hasCredential(array('foo', 'bar'));        =>   true
 
// Check if the user has one of the credentials
echo $user->hasCredential(array('foo', 'bar'), false); =>   true
 
// Remove a credential
$user->removeCredential('foo');
echo $user->hasCredential('foo');                      =>   false
 
// Remove all credentials (useful in the logout process)
$user->clearCredentials();
echo $user->hasCredential('bar');                      =>   false

Jobeetのバックエンドでは、いろんな種類の証明書は使っておらず、administratorという1つのみを利用しています。

プラグイン

車輪の再発明をしたくないので、スクラッチからログインアクションの開発することはしません。代わりに、symfonyプラグインをインストールしましょう。
symfonyフレームワークの良い長所の1つがプラグインエコシステムです。日中見ることができるのでプラグインを作ることは非常に簡単です。プラグインはモジュールやassetsの設定を含めれるので、非常に強力です。
今日はバックエンドアプリケーションをセキュアにするためにsfGuardPluginをインストールしてみましょう。

$ php symfony plugin:install sfGuardPlugin

plugin:installタスクは名前でプラグインをインストールします。全てのプラグインは plugins/ ディレクトリ下に保存され、プラグインの名前を使ったディレクトリを持ちます。

plugin:installタスクを動作させるにはPEARをインストールしなければなりません。

plugin:installタスクでプラグインをインストールすると、symfonyは最新の安定バージョンをインストールします。特定のバージョンをインストールする場合、--releaseオプションを指定します。
プラグインページにはsymfonyのバージョンごとに利用可能な全てのプラグインがリストされています。
プラグインはディレクトリへ内蔵するタイプなので、symfonyのWebサイトからパッケージをダウンロードしてきて解凍、またもう1つの方法としてsvn:externalsをリンクさせたSubversionリポジトリを使う方法もあります。

インストール後、プラグインは有効化されるのを念のため覚えておいてください。もし使いたくなければconfig/ProjectConfiguration.class.phpクラスのenableAllPluginsExcept()メソッドを使います。

バックエンドセキュリティ

各プラグインは設定のやり方を説明したREADMEファイルを持っています。
新しいプラグインを設定してみましょう。プラグインはユーザ、グループ、パーミッションを管理するための新しいモデルクラスを備えているので、モデルを再構築する必要があります。

$ php symfony propel:build-all-load --no-confirmation

propel:build-all-loadタスクはテーブルを再生成する前に全ての存在するテーブルを削除することを覚えておいてください。これを避けるためにはモデル、フォーム、フィルタをビルドして、そのあとで data/sql/ ディレクトリに保存される生成されたSQL文を実行して、新しいテーブルを作成します。

いつものように、新しいクラスが生成されるときはsymfonyキャッシュを削除する必要があります。

$ php symfony cc

sfGuardPluginはユーザクラスにいくつかのメソッドを追加するので、sfGuardSecurityUserを継承するようにmyUserクラスを変更する必要があります。

<?php
// apps/backend/lib/myUser.class.php
class myUser extends sfGuardSecurityUser
{
}

sfGuardPluginはユーザの認証を行うためにsfGuardAuthモジュール内にサインオンアクションを備えています。
settings.ymlファイルを編集して、ログインページ用のデフォルトアクションを変更します。

# apps/backend/config/settings.yml
all:
  .settings:
    enabled_modules: [default, sfGuardAuth]
 
    # ...
 
  .actions:
    login_module:    sfGuardAuth
    login_action:    signin
 
    # ...

プラグインはプロジェクト内の全てのアプリケーション間で共有されるため、enabled_modules設定に使いたいモジュールを設定することで明示的に有効にする必要があります。

f:id:Kiske:20090313222449p:image

最後のステップでは管理者ユーザを作成します。

$ php symfony guard:create-user fabien SecretPass
$ php symfony guard:promote fabien

sfGuardPluginはコマンドラインからユーザ、グループ、パーミッションを管理するためのタスクを備えています。listタスクを使う事でguard名前空間に所属する全てのタスクを一覧表示させます。

$ php symfony list guard

認証していないユーザの場合、メニューバーを隠す必要があります。

// apps/backend/templates/layout.php
<?php if ($sf_user->isAuthenticated()): ?>
  <div id="menu">
    <ul>
      <li><?php echo link_to('Jobs', '@jobeet_job') ?></li>
      <li><?php echo link_to('Categories', '@jobeet_category') ?></li>
    </ul>
  </div>
<?php endif; ?>

認証されたユーザのときは、メニュー内にログアウトリンクを追加する必要があります。

// apps/backend/templates/layout.php
<li><?php echo link_to('Logout', '@sf_guard_signout') ?></li>

sfGuardPluginで提供される全てルートを一覧表示するには、app:routesタスクを使います

Jobeetバックエンドをさらに良くするために、管理ユーザを管理するための新しいモジュールを追加しましょう。ありがたいことに、sfGuardPluginはそのようなモジュールを備えています。sfGuardAuthモジュールに関しては、settings.ymlで有効にする必要があります。

// apps/backend/config/settings.yml
all:
  .settings:
    enabled_modules: [default, sfGuardAuth, sfGuardUser]

メニューにリンクを追加します。

// apps/backend/templates/layout.php
<li><?php echo link_to('Users', '@sf_guard_user') ?></li>

f:id:Kiske:20090313222953p:image

これで完成です!

ユーザテスト

今日のチュートリアルではユーザテストについてはまだ何も説明していません。symfonyブラウザはクッキーをシミュレートするので、ビルトインされているsfTestUserテスタを使うことでユーザの振る舞いテストが非常に簡単にできます。
今日追加したメニュー要素用に機能テストを更新してみましょう。下記コードをjobモジュールの機能テストの最後に追記してください。

<?php
// test/functional/frontend/jobActionsTest.php
$browser->
  info('4 - User job history')->
 
  loadData()->
  restart()->
 
  info('  4.1 - When the user access a job, it is added to its history')->
  get('/')->
  click('Web Developer', array(), array('position' => 1))->
  get('/')->
  with('user')->begin()->
    isAttribute('job_history', array($browser->getMostRecentProgrammingJob()->getId()))->
  end()->
 
  info('  4.2 - A job is not added twice in the history')->
  click('Web Developer', array(), array('position' => 1))->
  get('/')->
  with('user')->begin()->
    isAttribute('job_history', array($browser->getMostRecentProgrammingJob()->getId()))->
  end()
;

テストを簡単にするため、まずfixturesデータをリロードして、クリーンなセッションでテストをしたいためブラウザをリスタートします。
isAttribute()メソッドは与えられたユーザ属性をチェックします。

sfTesterUserテスタはユーザの認証、認可をテストするためisAuthenticated(), hasCredential()メソッドを備えています。