Caution: You are browsing the legacy symfony 1.x part of this website.

6日目: セキュリティとフォームのバリデーション

1.0
Language

復習

5日目において、テンプレートとアクションの操作に慣れました; フォームとページャはもはや秘密では無くなりました。ログインフォームを構築したあとに、認証されていないユーザーが特定の機能のセットに対してアクセスするのをどのように制限するのか、もしかしたらあなたは私たちに期待したかもしれません。それはまさに今日私たちが実装しようとしていることです。フォームのバリデーションと一緒に行います。アプリケーションをカスタムクラスで拡張するにつれて、symfony bookのカスタムの拡張の章で公開されているコンセプトに満足することでしょう。

ログインフォームのバリデーション

バリデーションファイル

ログインフォームはnicknamepasswordフィールドを持ちます。しかし、ユーザーが不正なデータを投稿したら、何が起きるでしょうか?このケースに対処できるように、/frontend/modules/user/validateディレクトリでlogin.ymlファイルを作成します(loginはバリデートするアクション名)。次のコードを追加します:

methods:
  post: [nickname, password]

names:
  nickname:
    required:     true
    required_msg: your nickname is required
    validators:   nicknameValidator

  password:
    required:     true
    required_msg: your password is required

nicknameValidator:
    class:        sfStringValidator
    param:
      min:        5
      min_error:  nickname must be 5 or more characters

最初に、methodヘッダーの下で、フォームのメソッドに対してバリデートされるフィールドのリストが定義されます(POSTメソッドだけを定義します。GETメソッドはログインフォームを表示しいバリデーションを必要としないからです)。それからnamesヘッダーの下で、チェックされるべきそれぞれのフィールドのための要求がエラーメッセージに対応して、リストとして表示されます。結局は、'nickname'フィールドは特別のバリデーションのセットを持つように宣言されているので、これらはヘッダー対応の下で詳細に記述されます。この例では、sfStringValidatorはsymfonyに組み込まれているバリデータで文字列のフォーマットをチェックします(デフォルトのバリデータはsymfony bookのフォームをバリデートする方法で説明されています)。

エラーの処理

ではユーザーが間違ったデータを入力したら何が起きるでしょうか?login.ymlファイルで書かれた設定を見つけられずsymfonyコントローラはform_tag 引数で予定されたexecuteLogin()メソッドの代わりにuserActionsクラスのhandleErrorLogin()メソッドへリクエストを渡します。このメソッドが存在しなければ、デフォルトのふるまいはloginError.phpテンプレートを表示します。デフォルトのHandleError()メソッドが返すのはそういうわけです:

public function handleError()
{
  return sfView::ERROR;
}

これはまったく新しいテンプレートです。しかし、私たちはむしろ問題のあるフォールドに近いメッセージでログインフォームを再表示することを望みます。ではログインエラー表示のふるまいを修正しましょう。この場合、loginSuccess.phpテンプレートです:

public function handleErrorLogin()
{
  return sfView::SUCCESS;
}

note

アクションの名前にリンクする命名ルール、returnの値とテンプレートファイルはsymfony bookのビューの章に公開されています。

テンプレートエラーヘルパー

loginSuccess.phpテンプレートが再び呼び出されたとき、エラーが表示されます。その目的のためにValidationヘルパーグループのform_error()ヘルパーを使用します。テンプレートの2つのform-rowのdivを次のように変更してください:

<?php use_helper('Validation') ?>
 
<div class="form-row">
  <?php echo form_error('nickname') ?>
  <label for="nickname">nickname:</label>
  <?php echo input_tag('nickname', $sf_params->get('nickname')) ?>
</div>
 
<div class="form-row">
  <?php echo form_error('password') ?>
  <label for="password">password:</label>
  <?php echo input_password_tag('password') ?>
</div>

エラーがパラメータとして渡されたフィールドで宣言された場合form_error()ヘルパーはlogin.ymlで定義されたエラーメッセージを出力します。

5文字以下のニックネームが入力を試すか、2つのフィールドの1つを省略することでフォームバリデーションをテストしましょう。エラーメッセージは上述のconcernedフィールドを表示します:

ログインフォームのエラー

現在パスワードは強制です。しかし、データーベースにはパスワードがありません! どんなパスワードでも入力した直後にログインが成功します。しかしそれはセキュアなプロセスでありませんよね?

スタイルエラー

フォームをテストしてエラーを得たのであれば、エラーが上記のキャプチャーのものと同じ方法で表されていないことに気づいているでしょう。それは .form_errorクラス(web/main.css)のスタイルを定義したからです。.form_errorクラスはform_error()ヘルパーによって生成されたフォームエラーのデフォルトクラスです:

.form_error
{
  padding-left: 85px;
  color: #d8732f;
}

ユーザーを認証する

カスタムバリデータ

loginアクションに入力されたニックネームの存在について昨日のチェックを覚えているでしょうか?フォームバリデーションのように聞こえるでしょう。このコードはアクションから持ち出され、カスタムバリデータに含まれています。複雑だと思いますか?実際にはそうではないです。次のようにlogin.ymlのバリデーションファイルを編集してください:

...
names:
  nickname:
    required:      true
    required_msg:  your nickname is required
    validators:    [nicknameValidator, userValidator]
...
userValidator:
    class:         myLoginValidator
    param:
      password:    password
      login_error: this account does not exist or you entered a wrong password

myLoginValidatorクラスのnicknameフィールドのための新しいバリデータを追加しました。このバリデータはまだ存在していません。しかし、ユーザーを十分に認証するためのパスワードが必要になることは私たちは知っています。ですので、passwordラベルでパラメータとして渡されます。

パスワードの保存

しかし、少しお待ちください。テストデータと同じように、データモデルにパスワードの設定がありませんので定義しましょう。しかし、データベースに平文のパスワードを保存することはセキュリティの理由からバッドアイディアであることは分かっています。ハッシュ化されたランダムキーと同じようにパスワードのsha1ハッシュを保存します。'salt'プロセスに慣れていませんでしたら、パスワードクラッキングの練習を確認してください。

schema.xmlを開き、Userテーブルに次のコラムを追加してください:

<column name="email" type="varchar" size="100" />
<column name="sha1_password" type="varchar" size="40" />
<column name="salt" type="varchar" size="32" />

symfony propel-build-modelでPropelモデルをリビルドしてください。手動かsymfony prpel-build-sqlの後に生成されたlib.model.schema.sqlを使うかでデータベースに2つのカラムも追加します。askeet/lib/mode/User.phpを開き、setPassword()メソッドを追加してください:

public function setPassword($password)
{
  $salt = md5(rand(100000, 999999).$this->getNickname().$this->getEmail());
  $this->setSalt($salt);
  $this->setSha1Password(sha1($salt.$password));
}

この関数はパスワード保存を直接シミュレートします。しかし、代わりに saltランダムキー(ハッシュ化されたランダムな文字列による32文字)を保存し、パスワードをハッシュ化(40文字)します。

テストデータにパスワードを追加する

3日目のテストデータファイルを覚えていますか? テストユーザーにパスワードとEメールを追加しましょう。askeet/fixtures/test_data.ymlを開き次のように修正します:

User:
  ...
  fabien:
    nickname:   fabpot
    first_name: Fabien
    last_name:  Potencier
    password:   symfony
    email:      [email protected]

  francois:
    nickname:   francoisz
    first_name: François
    last_name:  Zaninotto
    password:   adventcal
    email:      [email protected]

Userクラスに対してsetPassword()メソッドが定義されたので、sfPropelDataオブジェクトは呼び出されたときスキーマで定義された新しいsha1_passwordとsaltカラムを正しく投入します:

$ php batch/load_data.php

note

sfPropelDataオブジェクトが'real'データベースカラムにバインドされていないメソッドを取り扱うことができることを注目してください(伝統的なSQLダンプを上回ります!)。

どのようにして可能であるのか悩んでいましたら、symfony bookのデータベース投入の章をご覧ください。

note

'Anonymous Coward'のログインを禁止するので、パスワードを定義する必要はありません。あなたがここで提供された2つのパスワードを私たちの銀行のアカウントで試さないことを本当に評価したいと思います。これらは機密情報です!

カスタムバリデータ

ではこのカスタムのmyLoginValidatorを書きましょう。モジュールがアクセス可能なlib/ディレクトリに作成することができます。(つまり、askeet/lib/、またはaskeet/apps/frontend/lib/またはaskeet/apps/frontend/modules/user/lib/)今やアプリケーション全体に渡るバリデータと考えられています。ですので myLoginValidator.class.phpaskeet/apps/frontend/lib/ディレクトリで作成されます:

<?php
 
class myLoginValidator extends sfValidator
{    
  public function initialize($context, $parameters = null)
  {
    // 親クラスを初期化する
    parent::initialize($context);
 
    // デフォルトを設定する
    $this->setParameter('login_error', 'Invalid input');
 
    $this->getParameterHolder()->add($parameters);
 
    return true;
  }
 
  public function execute(&$value, &$error)
  {
    $password_param = $this->getParameter('password');
    $password = $this->getContext()->getRequest()->getParameter($password_param);
 
    $login = $value;
 
    // anonymousは実際のユーザーではない
    if ($login == 'anonymous')
    {
      $error = $this->getParameter('login_error');
      return false;
    }
 
    $c = new Criteria();
    $c->add(UserPeer::NICKNAME, $login);
    $user = UserPeer::doSelectOne($c);
 
    // nicknameが存在するか?
    if ($user)
    {
      // passwordはOKか?
      if (sha1($user->getSalt().$password) == $user->getSha1Password())
      {
        $this->getContext()->getUser()->setAuthenticated(true);
        $this->getContext()->getUser()->addCredential('subscriber');
 
        $this->getContext()->getUser()->setAttribute('subscriber_id', $user->getId(), 'subscriber');
        $this->getContext()->getUser()->setAttribute('nickname', $user->getNickname(), 'subscriber');
 
        return true;
      }
    }
 
    $error = $this->getParameter('login_error');
    return false;
  }
}

バリデータが要求されたとき、- ログインフォームの投稿の後で - initialize()メソッドが最初に呼び出されます。このメソッドはデフォルトのlogin_errorメッセージ('Invalid Input')を初期化し、パラメータ(param:の下: login.ymlファイルへのヘッダー)をパラメータホルダオブジェクトに統合します。

それでexcecute()メソッドは...実行されました。$password_parampasswordヘッダーの下にあるlogin.ymlに提供されるフィールド名です。リクエストパラメータからの値を取り出すフィールド名として使用されます。$passwordはユーザーによって入力されたパスワードを含みます。$valueはカレントフィールドの値を取ります。そしてmyLoginValidatorクラスはnicknameフィールドのために呼び出されます。$loginはユーザーによって入力されたニックネームを含みます。最後です! これでバリデータはユーザーを実際にバリデートするために必要なデータを持ちます。

次のコードはloginアクションによって始まります。しかし、加えて、パスワードバリデートのテスト(前回は常にtrue)は埋め込まれています: ユーザーによって入力されたパスワードのハッシュ(データベースに保存されたsalt)はユーザーのハッシュ化されたパスワードと比較されます。

ログインとパスワードが正しい場合、バリデータはtrueを返し、フォームのターゲットアクション(executeLogin())は実行されます。そうでなければfalseを返し、handleErrorLogin()が実行されます。

アクションからコードを除去する

バリデーションのコードはバリデータの内側に設置されているので、loginアクションからこれを取り除く必要があります。実際に、POSTメソッドでアクションが呼び出されたときには、バリデータがリクエストをバリデートしたことを意味し、これはユーザーが正しいということです。この場合においてアクションがすべき唯一のことはrefererページにリダイレクトすることを意味します:

public function executeLogin()
{
  if ($this->getRequest()->getMethod() != sfRequest::POST)
  {
    // フォームを表示する
    $this->getRequest()->getParameterHolder()->set('referer', $this->getRequest()->getReferer());
 
    return sfView::SUCCESS;
  }
  else
  {
    // フォーム投稿を取り扱う
    // 最後のページにリダイレクトする
    return $this->redirect($this->getRequestParameter('referer', '@homepage'));
  }
}

テストユーザーの1つでログインを試して、修正をテストしてください(オートロードする必要のある新しいバリデータクラスを作成したのでキャッシュをクリアした後で)。

アクセスを制限する

アクションへのアクセスを制限したいのであれば、モジュールのconfig/ディレクトリのsecurity.ymlに次のようなコードを追加する必要があります(今はしないでください):

all:
  is_secure:   on
  credentials: subscriber

このようなモジュールのアクションが実行されるときは、ユーザーが認証され、subscriverの認証を持つときのみです。

askeetにおいて、ログインは、新しい質問を投稿するため、質問に対する関心を表す、コメントを評価するときなどに求められます。すべての他のアクションはログインしていないユーザーに対してオープンです。

question/addアクションのアクセス (まだ書かれていません) を制限するために、askeet/apps/frontend/modules/question/configディレクトリに次の security.ymlファイルを追加してください:

add:
  is_secure:   on
  credentials: subscriber

all:
  is_secure:   off

少しのリファクタリングはいかがですか?

今日はほとんど終わりましたが、少しの間、私たちの好きなゲームを遊んでみたいと思います。move-the-code-to-an-unlikely-placeゲームです。

パスワードがバリデートされるときに実行される4行のコードはユーザーにアクセス権限を与え、将来のリクエストのためにidを保存します。myUserクラスのメソッドとみなすことができます(セッションクラスで、Userカラムに対応しているUserクラスではありません)。遊ぶのは簡単です。次のメソッドをaskeet/apps/frontend/lib/myUser.phpクラスに追加してください:

public function signIn($user)
{
  $this->setAttribute('subscriber_id', $user->getId(), 'subscriber');
  $this->setAuthenticated(true);
 
  $this->addCredential('subscriber');
  $this->setAttribute('nickname', $user->getNickname(), 'subscriber');
}
 
public function signOut()
{
  $this->getAttributeHolder()->removeNamespace('subscriber');
 
  $this->setAuthenticated(false);
  $this->clearCredentials();
}

では、myLoginValidatorクラスの$this->getContext()->getUser()で始まる4行を変更してください:

$this->getContext()->getUser()->signIn($user);

そしてuser/logoutアクション(忘れましたか?)も変更してください:

public function executeLogout()
{
  $this->getUser()->signOut();
 
  $this->redirect('@homepage');
}

subscriver_idnicknameセッション属性もゲッターメソッドを通して抽象化されます。myUserクラスに次の3つのメソッドを追加してください:

public function getSubscriberId()
{
  return $this->getAttribute('subscriber_id', '', 'subscriber');
}
 
public function getSubscriber()
{
  return UserPeer::retrieveByPk($this->getSubscriberId());
}
 
public function getNickname()
{
  return $this->getAttribute('nickname', '', 'subscriber');
}

layout.phpでこれらの新しいメソッドの1つを利用できます:

<li><?php echo link_to($sf_user->getAttribute('nickname', '', 'subscriber').' profile', 'user/profile') ?></li>

上記のコードを下記のコードに変更してください

<li><?php echo link_to($sf_user->getNickname().' profile', 'user/profile') ?></li>

修正をテストすることを忘れないでください。以前と同じログインプロセスはまだ動作します - しかし、現在はもっとよいコードで動きます。

それではまた明日

明日は、カスタマイズし、CSSと一貫したコンポーネントをカスタマイズし、ページヘッダーに注意を払うためにビューの設定に少し取り組みます。

release_day_6とタグがついたaskeetのSVNリポジトリから今日の分の全コードをまだダウンロードできることを忘れないでください。askeetに質問をしたり回答をしたくなりましたら、askeetフォーラムに気楽に行ってみてください。21日目のプログラムはまだみなさん次第であることを忘れないでください。