目次


Agavi を使ってアクセス制御を実装する

スケーラブルな Web アプリケーションを Agavi フレームワークを使って作成する方法を学ぶ

Comments

はじめに

以前に公開した一連の記事では、Agavi MVC フレームワークについて紹介し、Agavi を使うことでスケーラブルな Web アプリケーションを短時間で効率的に作成できることを説明しました。開発フレームワークとして私が Agavi を選択した主な理由の 1 つは、Agavi には高度な入力フィルタリングおよび入力検証システムがあるため、無効な入力やフィルタリングされていない入力をアプリケーションが受け付けないようにすることができるからです。入力のフィルタリングと検証は、セキュアな Web アプリケーションを作成する上で重要な要素です。Agavi を使用すれば、ストリング、数字、タイムスタンプ、E メール・アドレス、ファイルを対象とした組み込みバリデーターやカスタム・バリデーターの機能を利用することができるため、この作業を大幅に単純化することができます。

アプリケーションのセキュリティーに関する Agavi の機能は入力の検証だけではありません。Agavi フレームワークではユーザー認証とアクセス制御のための強力なサブシステムを公開しており、このサブシステムをカスタマイズすることで、ほとんどすべての Web アプリケーションの要件を満たすことができます。このサブシステムは単純なログイン・ベースの認証と複雑なロール・ベースのアクセス制御 (RBAC: Role-Based Access Control) の両方をサポートしており、アプリケーション・レベルで特権を管理、操作するための確実なベースとして利用することができます。この記事では、その詳細について説明します。

基本的な概念を理解する

Agavi アプリケーションのアクションに対してアクセス・ルールを定義しようとする場合、利用できるセキュリティー・レベルが複数あることを最初に理解する必要があります。これらのレベルは、大まかに以下の概念を使って表現することができます。

  • パスワード: パスワード・ベースのアクセスは最も単純なタイプのアクセス制御です。基本的にパスワード・ベースのアクセス制御では、セキュアであると開発者が指定したアクションにアクセスするユーザーに対して、アクセスを許可する前に有効なログイン資格情報のセットを入力するようユーザーに要求します。このタイプのアクセス制御は、複数のレベルの特権を要求しないアプリケーションや、システムにアクセスするユーザーを大まかにユーザーと管理者に分類できる場合に適しています。

  • 特権: 特権ベースのアクセス制御は、パスワード・ベースのアクセス制御よりも詳細なシステムです。このシステムでは、各アクションの実行に必要な特権を開発者が定義します。そしてアクセスを要求するユーザーが必要な特権を所持している場合にのみ、システムはそれらのアクションへのアクセスを許可します。この方法では、認証と承認の両方を使用します。特定のアクションを実行するためには、ユーザーには有効なログイン資格情報のセットが必要なだけではなく、アクションに付随する一連の特権も必要です。ただしこの方式は、ユーザーのタイプと特権レベルが増えてくると、すぐに管理不能になります。

  • ロール: ロール・ベースのアクセス制御 (RBAC) は、上で説明した特権ベースの方法を維持しやすく高度にしたバージョンです。この方式では、アプリケーションに対する一連のユーザー・ロールを定義します。各ロールは一連の特権を表現し、アプリケーションのユーザーには、そのユーザーが実行する必要がある機能に応じて 1 つ以上のロールが割り当てられます。このタイプのアクセス制御は、ユーザー・タイプが複数で特権レベルも複数のアプリケーションに適しています。またこの方式は非常に柔軟であり、認証や承認に関する多様なニーズにも対応することができます。

サンプル・アプリケーションをセットアップする

アクセス制御の実装を始める前に、いくつか注意事項があります。この記事全体をとおして、読者は Apache/PHP/MySQL による実動の開発環境を持ち、SQL と XML の基本を理解しているものとします。また、以下についても理解しているものとします。

  • Agavi を使ったアプリケーション開発についての基本原則
  • アクション、ビュー、モデル、ルートの間のやり取り
  • Agavi アプリケーションでの Doctrine モデルの使い方

これらの事項についてよく理解していない場合には、この記事を読み進める前に Agavi 入門の連載記事 (「参考文献」のリンクを参照) を読んでください。

ステップ 1: 新しいアプリケーションを初期化する

作業の第一歩として、この記事の開発目標のテストベッドとして機能する単純な Agavi アプリケーションをセットアップします。Agavi ビルド・スクリプトを使って新しいプロジェクトを初期化し、下記の入力以外はデフォルトの値を受け入れます。

shell> agavi project-wizard
Project name [New Agavi Project]: ExampleApp
Project prefix (used, for example, in the project base action) []: ExampleApp
Should an Apache .htaccess file with rewrite rules be generated (y/n) [n]? y
...

このセットアップ・ウィザードでは、テスト・アプリケーション用の新しい仮想ホスト (例えば http://example.localhost/) を Apache の構成の中で定義します。これにより、ブラウザーからそのホストにアクセスすれば、Agavi のデフォルトのウェルカム・ページが表示されるようになるはずです (図 1)。

図 1. Agavi アプリケーションのデフォルトのウェルカム・ページ
Agavi アプリケーションのデフォルトのウェルカム・ページ
Agavi アプリケーションのデフォルトのウェルカム・ページ

ステップ 2: 新しいモジュールと、そのモジュールに対応するアクションを追加する

単純にするために、保護の対象となるアクションはすべて、Default モジュール以外のモジュールにあるとします。コマンド・プロンプトに戻り、6 つのアクションを含む新しい Book モジュールを以下の Agavi ビルド・スクリプトを使って作成します。

shell> agavi module-wizard
Module name: Book
Space-separated list of actions to create for Book:
Create Index Delete Display Search Edit
...

6 つのアクション (CreateAction、DeleteAction、DisplayAction、IndexAction、EditAction、SearchAction) に対し、このすぐ後でアクセス制御を追加します。とりあえず、アクションの目的を表す簡単なメッセージによって各アクションの *Success テンプレートを更新します (* は各アクションの名前に置き換わります)。下記は CreateSuccess テンプレートに含まれるメッセージの例です。

If you can see this page, you are authorized to create and add new books to the database.

この時点で、Agavi のドキュメントで推奨されているように、Welcome モジュールを削除する必要もあります。

shell> rm -rf app/modules/Welcome
shell> rm -rf app/pub/welcome

ステップ 3: アプリケーションのルーティング・テーブルを更新する

最後に、$ROOT/app/config/routing.xml でアプリケーションのルーティング・テーブルを更新します。新しいルートを追加し、追加されたルートが新しいアクションを指すようにします (リスト 1)。

リスト 1. サンプル・アプリケーションのルーティング・テーブル
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
 xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
  <ae:configuration>
    <routes>
      <!-- default action for "/" -->
      <route name="index" pattern="^/$" module="Default" action="Index" />

      <!-- action for book pages "/book/*" -->
      <route name="book" pattern="^/book" module="Book">
        <route name=".index" pattern="^/index$" action="Index" />
        <route name=".create" pattern="^/create$" action="Create" />
        <route name=".display" pattern="^/display/(id:\d+)$" action="Display" />
        <route name=".edit" pattern="^/edit/(id:\d+)$" action="Edit" />
        <route name=".delete" pattern="^/delete/(id:\d+)$" action="Delete" />
        <route name=".search" pattern="^/search$" action="Search" />
      </route>

    </routes>
  </ae:configuration>
</ae:configurations>

これで、リスト 1 に追加されたルートを使って、新しく作成されたアクションにアクセスできるはずです。これを検証するために、http://example.localhost/book/create にアクセスし、図 2 のようなページが表示されることを確認します。

図 2. CreateAction のデフォルト・ページ
CreateSuccess テンプレートのデフォルト・ページのスクリーン・キャプチャー
CreateSuccess テンプレートのデフォルト・ページのスクリーン・キャプチャー

同様に、次のセクションに進む前に、リスト 1 に追加された他のルートも検証します、うまくいかない場合には、上記ステップの詳細が Agavi の入門記事の連載第 1 回 (「参考文献」のリンクを参照) に説明されているので、そちらを参照してください。あるいは、この記事の「ダウンロード」セクションからサンプル・アプリケーションの完全なコード・アーカイブをダウンロードすることもできます。

ログイン・アクションとログアウト・アクションを作成する

アプリケーションのアクセス制御がパスワード・ベースであるかロール・ベースであるかによらず、やはりユーザー認証を処理するためのログインおよびログアウトのシステムが必要です。従って、基本的なアプリケーションを作成できたら、次のステップはログインとログアウトのアクションを実装することです。

ステップ 1: ユーザーのデータベースとモデルを初期化する

Web アプリケーションのユーザー情報は通常はデータベースに格納されているため、ここでもユーザー情報をデータベースに格納することにします。まず、ユーザーの資格情報を保持する新しい MySQL テーブルを以下のように作成します。

mysql> CREATE TABLE IF NOT EXISTS `user` (
    -> UserID int(4) NOT NULL AUTO_INCREMENT,
    -> Username varchar(50) CHARACTER SET utf8 NOT NULL,
    -> `Password` text CHARACTER SET utf8 NOT NULL,
    -> PRIMARY KEY (UserID),
    -> UNIQUE KEY Username (Username)
    -> ) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
Query OK, 0 rows affected (0.13 sec)

初期データとして、このテーブルにいくつかのアカウントを挿入します。

mysql> INSERT INTO user (UserID, Username, Password)
      VALUES(1, 'james', PASSWORD('james'));
Query OK, 1 row affected (0.08 sec)

mysql> INSERT INTO user (UserID, Username, Password)
      VALUES(2, 'susan', PASSWORD('susan'));
Query OK, 1 row affected (0.08 sec)

mysql> INSERT INTO user (UserID, Username, Password)
      VALUES(3, 'marco', PASSWORD('marco'));
Query OK, 1 row affected (0.08 sec)

mysql> INSERT INTO user (UserID, Username, Password)
      VALUES(4, 'donald', PASSWORD('donald'));
Query OK, 1 row affected (0.08 sec)

使用するのはもう少し後ですが、特権情報を保持するテーブルも作成します。

mysql> CREATE TABLE IF NOT EXISTS `user_access` (
    -> RecordID int(4) NOT NULL AUTO_INCREMENT,
    -> UserID int(4) NOT NULL,
    -> UserAccess varchar(255) NOT NULL,
    -> PRIMARY KEY (RecordID),
    -> ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Query OK, 0 rows affected (0.1 sec)

次に、Doctrine ORM をダウンロードし (「参考文献」のリンクを参照)、Doctrine ライブラリーを $ROOT/libs/doctrine に追加します。

$ROOT/app/config/settings.xml でアプリケーションの設定を更新することでデータベースのサポートを有効にし、さらにデータベースの構成ファイル (通常は $ROOT/app/config/databases.xml にあります) を更新して Agavi で Doctrine アダプターを使えるようにします。この構成の例を示したものがリスト 2 です。

リスト 2. Agavi Doctrine アダプターの構成
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
 xmlns="http://agavi.org/agavi/config/parts/databases/1.0">
  <ae:configuration>
    <databases default="doctrine">
      <database name="doctrine" class="AgaviDoctrineDatabase">
        <ae:parameter name="dsn">
         mysql://user:pass@localhost/example
       </ae:parameter>
        <ae:parameter name="load_models">
         %core.lib_dir%/doctrine
       </ae:parameter>
      </database>
    </databases>

  </ae:configuration>
</ae:configurations>

この時点で、これらのテーブルのモデルを Doctrine を使って生成することができます。生成されたモデル・クラスは忘れずに $ROOT/app/lib/doctrine/ ディレクトリーにコピーしてください。

shell> cp /tmp/models/User.php app/lib/doctrine/
shell> cp /tmp/models/generated/BaseUser.php app/lib/doctrine/
shell> cp /tmp/models/UserAccess.php app/lib/doctrine/
shell> cp /tmp/models/generated/BaseUserAccess.php app/lib/doctrine/

Doctrine を Agavi に統合し、それを使ってデータベース・テーブルからモデルを生成するプロセスについては、Agavi の入門記事の連載第 3 回に詳細に説明されています (「参考文献」のリンクを参照)。

ステップ 2: さまざまなログイン・ビューとログアウト・ビューを追加する

モデルが動作するようになったら、次のステップとして LoginAction と LogoutAction を追加します。プロジェクトを作成した時点のデフォルトで Agavi ビルド・スクリプトによって LoginAction が作成されているはずです。そのため、必要なものは LogoutAction のみです。

shell> agavi action-wizard
Module name: Default
Action name: Logout
Space-separated list of views to create for Save [Success]: Success
...

また、LoginInputView、LoginSuccessView、LoginErrorView のテンプレートも以下のように生成する必要があります。

shell> agavi template-create
Module name: Default
Template name: LoginSuccess
...

shell> agavi template-create
Module name: Default
Template name: LoginInput
...

shell> agavi template-create
Module name: Default
Template name: LoginError
...

これらのビューの中で、おそらく LoginInputView が最も重要です。LoginInputView は、制限付きのアクションにユーザーがアクセスしようとすると表示されるログイン・フォームを生成します。また LoginInputView は最初のリクエスト URL を保管し、ユーザーがログインに成功すると、その URL にユーザーをリダイレクトします。Agavi cookbook (「参考文献」のリンクを参照) によると、そのために最も容易な方法は、最初のリクエスト URL を実行コンテキストに保管する方法です (リスト 3)。

リスト 3. LoginInputView の定義
<?php

class Default_LoginInputView extends ExampleAppDefaultBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    // get referrer URL and save it
    if($this->getContainer()->hasAttributeNamespace(
      'org.agavi.controller.forwards.login')) {
      $this->getContext()->getUser()->setAttribute(
       'redirect', $this->getContext()->getRequest()->getUrl(),
       'org.agavi.example.login');
    } else {
      $this->getContext()->getUser()->removeAttribute(
        'redirect', 'org.agavi.example.login');
    }
    $this->setupHtml($rd);
    $this->setAttribute('_title', 'Login');
  }
}

?>

リスト 4 は、この LoginInputView に対応する LoginInput テンプレートのコードです。

リスト 4. LoginInput テンプレート
<form action="<?php echo $ro->gen('login'); ?>" method="post">
  <label for="username" class="required">Username:</label>
  <br/>
  <input id="username" type="text" name="username" style="width:150px" />
  <p/>
  <label for="password" class="required">Password:</label>
  <br/>
  <input id="password" type="password" name="password" style="width:150px" />
  <p/>
  <input type="submit" name="submit" class="submit" value="Log In" />
</form>

ご想像のとおり、このテンプレートは至って標準的なもので、ユーザー名とパスワード用のフィールドがあるフォームです。図 3 にこのフォームの外観を示します。

図 3. アプリケーションのログイン・ページ
アプリケーションのログイン・ページのスクリーン・キャプチャー。画面には Username フィールドと Password フィールドが表示されています。
アプリケーションのログイン・ページのスクリーン・キャプチャー。画面には Username フィールドと Password フィールドが表示されています。

リスト 5 には、リスト 4 のフォームに対する入力検証ルールが含まれています。

リスト 5. LoginAction バリデーター
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
  xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
  xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  parent="%core.module_dir%/Default/config/validators.xml"
>
  <ae:configuration>

    <validators method="write">
      <validator class="string">
        <arguments>
          <argument>username</argument>
        </arguments>
        <errors>
          <error for="required">ERROR: Username is missing</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
        </ae:parameters>
      </validator>

      <validator class="string">
        <arguments>
          <argument>password</argument>
        </arguments>
        <errors>
          <error for="required">ERROR: Password is missing</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">true</ae:parameter>
        </ae:parameters>
      </validator>
    </validators>

  </ae:configuration>
</ae:configurations>

ログインに成功すると、LoginSuccessView は最初の URL リクエストを取得し、クライアントをその URL にリダイレクトします (リスト 6)。

リスト 6. LoginSuccessView の定義
<?php

class Default_LoginSuccessView extends ExampleAppDefaultBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    if($this->getContext()->getUser()->hasAttribute(
      'redirect', 'org.agavi.example.login')) {
      $this->getResponse()->setRedirect($this->getContext()
        ->getUser()->removeAttribute(
        'redirect', 'org.agavi.example.login'));
      return true;
    }
    $this->setupHtml($rd);
    $this->setAttribute('_title', 'Login');
  }
}

?>

リダイレクトを実行する必要がない場合には、LoginSuccessView はログイン成功を表すメッセージを含む LoginSuccess テンプレートを単純に描画します (リスト 7)。

リスト 7. LoginSuccess テンプレート
You were successfully logged in.

あるいは、ログインに失敗した場合には LoginError テンプレートが描画されます (リスト 8)。

リスト 8. LoginError テンプレート
There was an error logging you in. Please try again.

ステップ 3: ログインとログアウトのアクションを実装する

今度は LoginAction にコードを追加します。リスト 9 に示す LoginAction では、リスト 4 の LoginInput フォームから送信されたユーザーの資格情報を読み取り、MySQL データベースに保管されている情報と比較検証します。資格情報が有効であれば、LoginAction は setAuthenticated() メソッドを使って認証フラグをセットし、LoginSuccessView が実行されます。資格情報が有効でなければ、LoginErrorView が実行されます。

リスト 9. LoginAction の定義
<?php

class Default_LoginAction extends ExampleAppDefaultBaseAction
{
  public function getDefaultViewName()
  {
    return 'Input';
  }

  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    // get input parameters
    $u = $rd->getParameter('username');
    $p = $rd->getParameter('password');

    // check user credentials
    $q = Doctrine_Query::create()
          ->from('User u')
          ->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p));
    $result = $q->fetchArray();

    // set authentication flag if valid
    if (count($result) == 1) {
      $this->getContext()->getUser()->setAuthenticated(true);
      return 'Success';
    } else {
      return 'Error';
    }
  }
}

?>

LogoutAction の場合、LoginAction とは逆の処理を行います。つまり LogoutAction は認証フラグをリセットし、ユーザー・セッションを終了します。リスト 10 に LogoutAction のコードを示します。

リスト 10. LogoutAction の定義
<?php

class Default_LogoutAction extends ExampleAppDefaultBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }

  public function executeRead()
  {
    $this->getContext()->getUser()->setAuthenticated(false);
    return 'Success';
  }
}

?>

アプリケーションのルーティング・テーブルを更新する

最後のステップでは、アプリケーションのルーティング・テーブルを更新し、ログイン・アクションのルートとログアウト・アクションのルートをルーティング・テーブルに追加します。リスト 11 には、更新されたルートが示されています。

リスト 11. サンプル・アプリケーションのルーティング・テーブルを更新する
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
 xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
  <ae:configuration>
    <routes>
      <!-- default action for "/" -->
      <route name="index" pattern="^/$" module="Default" action="Index" />

      <!-- action for login page "/login" -->
      <route name="login" pattern="^/login$" module="Default" action="Login" />

      <!-- action for admin logout pages "/logout" -->
      <route name="logout" pattern="^/logout$" module="Default" action="Logout" />

      <!-- action for book pages "/book/*" -->
      <route name="book" pattern="^/book" module="Book">
        <route name=".index" pattern="^/index$" action="Index" />
        <route name=".create" pattern="^/create$" action="Create" />
        <route name=".display" pattern="^/display/(id:\d+)$" action="Display" />
        <route name=".edit" pattern="^/edit/(id:\d+)$" action="Edit" />
        <route name=".delete" pattern="^/delete/(id:\d+)$" action="Delete" />
        <route name=".search" pattern="^/search$" action="Search" />
      </route>
    </routes>
  </ae:configuration>
</ae:configurations>

この時点で、ログインとログアウトのシステムが動作するようになりました。実際に http://example.localhost/login にアクセスし、先ほどユーザー・テーブルに設定した資格情報と一致する資格情報を入力してみてください。問題がなければ、ログインの成功を示すメッセージが表示されるはずです (図 4)。

図 4. ログインに成功した場合の表示
ログインに成功した場合に表示される画面のスクリーン・キャプチャー。「You were successfully logged in. (ログインに成功しました。)」というメッセージが表示されています。
ログインに成功した場合に表示される画面のスクリーン・キャプチャー。「You were successfully logged in. (ログインに成功しました。)」というメッセージが表示されています。

正しくないユーザー名またはパスワードを入力すると、ログイン・エラー・メッセージが表示されるはずです (図 5)。

図 5. ログインに成功しなかった場合の表示
ログインに成功しなかった場合に表示される画面のスクリーン・キャプチャー。「There was an error logging in. Please try again. (ログインに失敗しました。もう一度試してください。)」というメッセージが表示されています。
ログインに成功しなかった場合に表示される画面のスクリーン・キャプチャー。「There was an error logging in. Please try again. (ログインに失敗しました。もう一度試してください。)」というメッセージが表示されています。

パスワードを使ってアクセスを制御する

ログインとログアウトのフレームワークが動作するようになると、特定のアクションへのアクセス制限をするのは非常に容易になります。それを説明するために、Book モジュールのすべてのアクションに認証が必要だとしましょう。これを実現するためには、単純に各アクション・クラスを編集し、true を返す isSecure() メソッドを追加します。リスト 12 は CreateAction を変更した結果を示しています。

リスト 12. CreateAction の定義
<?php

class Book_CreateAction extends ExampleAppBookBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }

  public function isSecure()
  {
    return true;
  }
}

?>

すると今度は、これらのアクションのいずれか (例えば http://example.localhost/book/create の CreateAction) にアクセスしようとすると、Agavi によってまず LoginAction にリダイレクトされ、有効なユーザー名とパスワードを入力しても、表示されるのはリクエストされた URL のみです。

重要な注意点として、いずれか 1 つのアクションにアクセスするためにログインしてしまえば、他のアクションにもアクセスできてしまい、もうログインを要求されることはありません。なぜこの点が重要かと言うと、パスワードのみによる単純な方法の重大な欠陥の 1 つ (いったん認証されると、ユーザーは保護されたアクションのどれにでも、あるいはすべてにアクセスできてしまうこと) が、この点に現れているからです。つまり、この単純な方法は異なるタイプのユーザーを区別することができません。そのため、詳細なアクセス制御を必要とするアプリケーションには不向きです。そこで特権ベースの方法が登場します。

特権を使ってアクセスを制御する

特権ベースの方法の場合、各アクションには特定の特権が必要であり、認証済みのユーザーには特定の特権が与えられています。アクションを実行できるのは、そのアクションに必要な特権を持ったユーザーのみです。そのため、アクションはユーザー単位で制限され、どのユーザーがどのアクションにアクセスできるのかを詳細に制御することができます。

ステップ 1: アクションに必要な特権を設定する

この方式による動作を説明するために、各アクションに以下の制限があるとします。

  • CreateAction と DeleteAction を呼び出せるのは、book.create 特権を持つユーザーのみです。
  • EditAction を呼び出せるのは、book.edit 特権を持つユーザーのみです。
  • IndexAction を呼び出せるのは、book.index 特権を持つユーザーのみです。
  • DisplayAction を呼び出せるのは、book.display 特権を持つユーザーのみです。
  • SearchAction を呼び出せるのは、book.index 特権と book.display 特権を持つユーザーのみです。

1 つのアクションの中で、そのアクションの getCredentials() メソッド (そのアクションを使うために必要な特権を返します) を使って上記の特権が設定されます。変更された CreateAction を示すリスト 13 を考えてみてください。

リスト 13. CreateAction の定義
<?php

class Book_CreateAction extends ExampleAppBookBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }

  public function isSecure()
  {
    return true;
  }

  public function getCredentials()
  {
    return 'book.create';
  }
}

?>

同様に、リスト 14 に示すのは、book.index 特権を持つユーザーのみがアクセスできるように変更した IndexAction です。

リスト 14. IndexAction の定義
<?php

class Book_IndexAction extends ExampleAppBookBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }

  public function isSecure()
  {
    return true;
  }

  public function getCredentials()
  {
    return 'book.index';
  }
}

?>

リスト 15 には、book.index と book.display という 2 つの特権がないとアクセスできないように変更した SearchAction を示しています。そのために getCredentials() メソッドによって配列を返すようにしています。

リスト 15. SearchAction の定義
<?php

class Book_SearchAction extends ExampleAppBookBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }

  public function isSecure()
  {
    return true;
  }

  public function getCredentials()
  {
    return array('book.index', 'book.display');
  }

}

?>

ステップ 2: ユーザーの特権を設定する

アクションを構成できたら、次のステップはユーザーに特権を割り当てることです。各ユーザーに以下の特権があるとします。

  • ユーザー james は book.index 特権を持ちます。
  • ユーザー susan は book.index 特権と book.display 特権を持ちます。
  • ユーザー marco は book.edit 特権と book.display 特権を持ちます。
  • ユーザー donald は book.index 特権と book.display 特権、book.create 特権を持ちます。

上記の特権を、以下の SQL を使って MySQL データベースに追加します。

mysql> INSERT INTO user_access (UserID, UserAccess) VALUES
    -> (1, 'book.index'),
    -> (2, 'book.index'),
    -> (2, 'book.display'),
    -> (3, 'book.display'),
    -> (3, 'book.edit'),
    -> (4, 'book.index'),
    -> (4, 'book.display'),
    -> (4, 'book.create');
Query OK, 8 rows affected (0.05 sec)
Records: 8  Duplicates: 0  Warnings: 0

図 6 は MySQL データベースでのユーザーと特権との間の関係を示しています

図 6. ユーザーと特権のマップ
ユーザーと特権のマップのスクリーン・キャプチャー
ユーザーと特権のマップのスクリーン・キャプチャー
図 6 をテキストで表したもの
図 6 のユーザーと特権のマップ
Usernamegroup_concat(UserAccess)
donaldbook.index,book.display,book.create
jamesbook.index
marcobook.display,book.edit
susanbook.index,book.display

ステップ 3: 実行時にユーザーの特権を取得し、割り当てる

最後のステップでは、ログイン時にデータベースから各ユーザーの特権を取得し、それらの特権をユーザー・オブジェクトに割り当てるように LoginAction を変更します。リスト 16 に、変更されたコードを示します。

リスト 16. 変更された LoginAction の定義
<?php

class Default_LoginAction extends ExampleAppDefaultBaseAction
{
  public function getDefaultViewName()
  {
    return 'Input';
  }

  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    // get input parameters
    $u = $rd->getParameter('username');
    $p = $rd->getParameter('password');

    // check user credentials
    $q = Doctrine_Query::create()
          ->from('User u')
          ->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p));
    $result = $q->fetchArray();

    // set authentication flag if valid
    if (count($result) == 1) {
      $this->getContext()->getUser()->setAuthenticated(true);

      // get credentials and attach to user object
      $this->getContext()->getUser()->clearCredentials();
      $q = Doctrine_Query::create()
            ->from('UserAccess ua')
            ->where('ua.UserID = ?', array($result[0]['UserID']));
      $rs = $q->fetchArray();
      foreach ($rs as $r) {
        $this->getContext()->getUser()->addCredential(trim($r['UserAccess']));
      }
      return 'Success';
    } else {
      return 'Error';
    }
  }
}

?>

ここでは、あるユーザーのログイン資格情報が検証された後、追加のクエリーが実行され、そのユーザーの特権が取得されます。そして取得された特権は addCredential() メソッドによってユーザー・オブジェクトに追加され、LoginSuccessView が実行されます。また、ユーザーの資格情報をすべてクリアする clearCredentials() メソッドにも注意してください。最大限にセキュリティーを高めるためには、異なる資格情報を扱うあらゆる処理を行う前に、ユーザーがログアウトするときと同様に、ユーザーの資格情報をすべてクリアする必要があります。

上記の実際の動作を調べるために、james としてログインしてみます。ログインすると、IndexAction にアクセスできるはずです。しかし他のどのアクションにアクセスしようとしても、「Access Denied」というレスポンスが表示されます (図 7)。

図 7. 特権が設定されたアクションへのアクセスを試みたときの結果
特権が設定されたアクションへのアクセスを試みたときに、アクセスするための十分な資格がないというメッセージを表示している画面のスクリーン・キャプチャー。
特権が設定されたアクションへのアクセスを試みたときに、アクセスするための十分な資格がないというメッセージを表示している画面のスクリーン・キャプチャー。

一方、marco としてログインすると、DisplayAction と EditAction にアクセスできるはずですが、他のアクションにはアクセスできないはずです。susan としてログインすると、IndexAction、DisplayAction、SearchAction にアクセスできるはずです。そして donald としてログインすると、IndexAction、DisplayAction、CreateAction、DeleteAction、SearchAction にアクセスできるはずです。

当然ですが、この方法は先ほど説明した単純なパスワード・ベースの認証よりも詳細な制御が可能であり、複数レベルのアクセス制御を必要とするアプリケーションに推奨の方法です。しかし特権レベルとユーザーの数が増えてくると、ユーザー特権マップの維持は次第に複雑になり、時間もかかるようになります。そこでロールの登場です。

ロール・ベースのアクセス制御 (RBAC) を実装する

RBAC は、ユーザーも特権も複数のアプリケーションにおける、承認タスクを扱うためによく使われる方法です。基本的に、RBAC ではユーザーのロールを定義することと (各ロールは一連の定義済み特権を表します)、アプリケーションのユーザーにそれらのロールを割り当てることができます。ユーザーがログインすると、そのユーザーには 1 つ以上のロールが関連付けられ、そのユーザーは、その (それらの) ロールに付随するすべての特権を自動的に取得します。

ステップ 1: ロールと特権を定義する

Agavi における他のほとんどの事項と同様、ロールと特権は XML 構成ファイルで定義されます (この構成ファイルはデフォルトで $ROOT/app/config/rbac_definitions.xml にあります)。この定義ファイルは AgaviRbacSecurityUser オブジェクトによって自動的に読み取られます。リスト 17 は、この定義ファイルがどのようなものかを示しています。

リスト 17. サンプル・アプリケーションのロールの定義
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  xmlns="http://agavi.org/agavi/config/parts/rbac_definitions/1.0">
  <ae:configuration>
    <roles>
      <role name="visitor">
        <permissions>
          <permission>book.index</permission>
        </permissions>
        <roles>
          <role name="student">
            <permissions>
              <permission>book.display</permission>
            </permissions>
            <roles>
              <role name="manager">
                <permissions>
                  <permission>book.create</permission>
                </permissions>
              </role>
            </roles>
          </role>
        </roles>
      </role>
      <role name="librarian">
        <permissions>
          <permission>book.display</permission>
          <permission>book.edit</permission>
        </permissions>
      </role>
    </roles>
  </ae:configuration>
</ae:configurations>

このファイルでは、manager、librarian、student、visitor という 4 つのロールを定義しています。これらのロールはそれぞれ異なる特権に関連付けられています。この XML ファイルの構造からわかるように、ロールはネストすることができます。子ロールは親ロールの特権を継承します。従って、manager は manager 自身のカスタム特権と、student と visitor の特権を取得します。

ステップ 2: ユーザーにロールを割り当てる

次のステップでは、ユーザーにロールを割り当てます。各ユーザーのロールは以下であるとします。

  • ユーザー james は visitor です。
  • ユーザー susan は student です。
  • ユーザー marco は librarian です。
  • ユーザー donald は manager です。

既存の MySQL テーブルを再利用して各ユーザーに以下のロールを割り当てます。

mysql> TRUNCATE TABLE user_access;
Query OK, 0 rows affected (0.06 sec)

mysql> INSERT INTO user_access (UserID, UserAccess) VALUES
    -> (1, 'visitor'),
    -> (2, 'student'),
    -> (3, 'librarian'),
    -> (4, 'manager');
Query OK, 4 rows affected (0.05 sec)
Records: 4  Duplicates: 0  Warnings: 0

図 8 は MySQL データベースでのユーザーとロールの間の関係を示しています

図 8. ユーザーとロールのマップ
ユーザーとロールのマップのスクリーン・キャプチャー
図 8 をテキストで表したもの
図 8 のユーザーとロールのマップ
UsernameUserAccess
Donaldvisitor
jamesstudent
marcolibrarian
susanmanager

ステップ 3: AgaviRbacSecurityUser オブジェクトをインスタンス化する

デフォルトで、Agavi はアプリケーションのユーザーを AgaviSecurityUser オブジェクトで表現します。しかしこのオブジェクトには、実行時にロールを割り当てたり、取り消したりするために必要なメソッドがありません。従って、AgaviSecurityUser オブジェクトではなく AgaviRbacSecurityUser オブジェクトをインスタンス化するように Agavi に指示する必要があり、そのために $ROOT/app/config/factories.xml を編集してクラス・ファクトリーのリストを更新します (リスト 18)。

リスト 18. Agavi のクラス・ファクトリーの構成
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
  xmlns="http://agavi.org/agavi/config/parts/factories/1.0">

  <ae:configuration>
    ...
    <user class="AgaviRbacSecurityUser" />
    ...
  </ae:configuration>

</ae:configurations>

ステップ 4: 実行時にユーザー・ロールを取得し、割り当てる

最後のステップでは、認証が成功した場合にはデータベースから各ユーザーのロールを取得し、それらのロールをユーザー・オブジェクトに割り当てるように、LoginAction を変更します。リスト 19 は変更された LoginAction のコードを示しています。

リスト 19. 変更された LoginAction の定義
<?php

class Default_LoginAction extends ExampleAppDefaultBaseAction
{
  public function getDefaultViewName()
  {
    return 'Input';
  }

  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    // get input parameters
    $u = $rd->getParameter('username');
    $p = $rd->getParameter('password');

    // check user credentials
    $q = Doctrine_Query::create()
          ->from('User u')
          ->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p));
    $result = $q->fetchArray();

    // set authentication flag if valid
    if (count($result) == 1) {
      $this->getContext()->getUser()->setAuthenticated(true);
      // get and grant roles
      $this->getContext()->getUser()->revokeAllRoles();
      $q = Doctrine_Query::create()
            ->from('UserAccess ua')
            ->where('ua.UserID = ?', array($result[0]['UserID']));
      $rs = $q->fetchArray();
      foreach ($rs as $r) {
        $this->getContext()->getUser()->grantRole(trim($r['UserAccess']));
      }
      return 'Success';
    } else {
      return 'Error';
    }
  }
}

?>

ユーザーのログイン資格情報が検証されると、そのユーザーのロールを取得するための追加のクエリーが実行されます。取得されたロールは grantRole() メソッドによって AgaviRbacSecurityUser オブジェクトに割り当てられ、LoginSuccessView が実行されます。また、revokeAllRoles() メソッドにも注意してください。revokeAllRoles() メソッドは、新しいロールを許可する前、そしてユーザーがログアウトしたときに、そのユーザーの既存のロールをすべてクリアします。

james としてログインすると、IndexAction にしかアクセスできないはずです。一方 marco としてログインすると、DisplayAction と EditAction にはアクセスできるはずですが、他のアクションにはアクセスできないはずです。susan としてログインすると、IndexAction、DisplayAction、SearchAction にアクセスできるはずです。そして donald としてログインすると、IndexAction、DisplayAction、CreateAction、DeleteAction、SearchAction にアクセスできるはずです。

1 つのロールによって複数の特権を表現することができ、また 1 人のユーザーが複数のロールを持つことができるため、Agavi の RBAC 実装を利用すると複数レベルの特権階層構造を容易に作成することができます。また、保守も大幅に容易になります。ロールを定義する XML ファイルを編集するだけで、各ロールに適用される特権を変更することができ、そのロールを持つすべてのユーザーに変更が自動的に反映されるからです。これは、非常に複雑な Web アプリケーションや、どのユーザーがどの機能にアクセスできるかを詳細に制御する必要のある Web アプリケーションにとっては理想的です。そして当然、皆さん独自のユーザー管理要件に関するメソッドを追加することによって、ベースとなる AgaviRbacSecurityUser オブジェクトを拡張することができます (また拡張するとよいでしょう)。

まとめ

ここまでの説明から、Agavi のアクセス制御メカニズムによって多様な要求に対応できることを理解できたはずです。フラットな特権システムを求める場合であれ、あるいは階層構造の特権システムを求める場合であれ、Agavi に組み込みのオブジェクトによってセキュアで堅牢なアーキテクチャーを容易に実装することができ、またアプリケーションの機能へのアクセス制限を単純かつスマートな方法で実現することができます。また、Agavi のアクセス制御はモジュール形式で実装できるため、実装フェーズの任意の時点でアプリケーションにアクセス制御を追加することもでき、さらにはアプリケーションがデプロイされた後でアクセス制御を追加することもでき、既存のビジネス・ロジックへの影響は最小限にとどまります。

この記事で実装したコードはすべて、「ダウンロード」セクションからダウンロードすることができます。このコードを入手して、いろいろと試してみることをお勧めします。実際に新しい機能を追加してみるのも一考です。何も壊れることはありませんので、よい勉強になると思って楽しみながら試してください!


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=XML, Open source
ArticleID=604971
ArticleTitle=Agavi を使ってアクセス制御を実装する
publish-date=10272009