目次


PHP と Passport サービスを利用して IBM Cloud アプリケーション内で容易にユーザーを管理、認証する, 第 2 回

ロール・ベースのアクセス制御機能とパスワード回復機能を PHP アプリケーションに追加する

Passport API を利用して、PHP アプリ用のユーザー管理機能を拡張する

Comments

コンテンツシリーズ

このコンテンツは全#シリーズのパート#です: PHP と Passport サービスを利用して IBM Cloud アプリケーション内で容易にユーザーを管理、認証する, 第 2 回

このシリーズの続きに乞うご期待。

このコンテンツはシリーズの一部分です:PHP と Passport サービスを利用して IBM Cloud アプリケーション内で容易にユーザーを管理、認証する, 第 2 回

このシリーズの続きに乞うご期待。

第 1 回では、Passport API の基本について説明し、IBM Cloud 上で稼働する PHP アプリケーションに Passport サービスを統合するプロセスを手順に沿って案内しました。アプリケーションのユーザー管理および認証を Passport に任せることで、迅速かつ効率的にユーザー登録、ログイン/ログアウト、アクティブ化/非アクティブ化のワークフローを PHP アプリケーションに追加できます。

けれども第 1 回で述べたように、これらの機能は氷山の一角に過ぎません。Passport API を利用すれば、ユーザー・アカウントの変更と削除、ユーザー・プロファイルのカスタム属性の保管、ユーザー・ロールに基づくアクセス制限、パスワードを忘れた場合の回復システムなどの機能を追加して、PHP アプリケーション内のユーザー管理を強化することができます。第 1 回の終わりで約束したように、今回は、これらすべての機能を取り上げます。早速、始めましょう!

IBM Cloud の Passport サービスを利用すれば、フル機能を備えたユーザー管理、認証、そしてロール・ベースのアクセスを迅速かつ簡単にアプリケーションに追加することができます

必要になるもの

このチュートリアルの手順に従うために必要なすべてのものについては、第 1 回の「必要になるもの」を参照してください。また、必ず Inversoft License Agreement および Bluemix の利用規約に関する要件に留意してください。

デモを試すGitHub からコードを入手する

ステップ 1: ユーザー・レコード内でカスタム属性をサポートする

第 1 回で扱ったユーザー登録フォームは、かなり基本的なものです。このフォームで入力を求めているのは、ユーザーの名、姓、e-メール・アドレス、パスワードだけに過ぎません。実際には、ユーザーが登録する際には他の情報もユーザーから集めることになるでしょう。例えば、ユーザーの住所、電話番号、さらには支払いなどの情報です。

幸い、Passport はユーザー・アカウントのカスタム属性をサポートしているので、ユーザー・レコードを完成するために必要だと思われるあらゆる情報の入力をユーザーに求めること (そして保存すること) ができます。追加で収集する情報は、他の必須のユーザー情報と一緒に Passport サービス内に保管されるため、Passport API を使用してそれらの情報にアクセスできます。

この仕組みを説明するために、前回の $APP_ROOT/views/users-save.phtml ファイルに戻って、登録フォームを更新します。このフォームに、以下のように市区町村、職業、携帯電話番号を入力するための 3 つのフィールドを追加してください。

...
<form method="post" 
  action="<?php echo $data['router']->pathFor('admin-users-save'); ?>">
  <div class="form-group">
    <label for="fname">First name</label>
    <input type="text" class="form-control" id="fname" name="fname">
  </div>
  <div class="form-group">
    <label for="lname">Last name</label>
    <input type="text" class="form-control" id="lname" name="lname">
  </div>
  <div class="form-group">
    <label for="email">Email address</label>
    <input type="text" class="form-control" id="email" name="email">
  </div>
  <div class="form-group">
    <label for="password">Password</label>
    <input type="password" class="form-control" id="password" name="password">
  </div> 
  <div class="form-group">
    <label for="city">City</label>
    <input type="text" class="form-control" id="city" name="city">
  </div>
  <div class="form-group">
    <label for="occupation">Occupation</label>
    <input type="text" class="form-control" id="occupation" name="occupation">
  </div>
  <div class="form-group">
    <label for="mobilePhone">Mobile phone (with country code)</label>
    <input type="text" class="form-control" id="mobilePhone" name="mobilePhone">
  </div>
  <div class="form-group">
    <button type="submit" name="submit" class="btn btn-default">Save</button>
  </div>
</form>  
...

更新後のユーザー登録フォームは、以下のように表示されます。

図 1. ユーザー登録フォーム
ユーザー登録フォームのスクリーンショット
ユーザー登録フォームのスクリーンショット

次に、対応する Slim コールバックを更新し、コールバックが新しく追加された入力を検証して、それらの値を /api/user/registration API メソッドに送信される JSON ドキュメントに追加するようにします。

<?php
// Slim application initialization - snipped

// user form processor
$app->post('/admin/users/save', function (Request $request, Response $response) {
  // get configuration
  $config = $this->get('settings');

  // get input values
  $params = $request->getParams();
  
  // validate input
  if (!($fname = filter_var($params['fname'], FILTER_SANITIZE_STRING))) {
    throw new Exception('ERROR: First name is not a valid string');
  }
  
  if (!($lname = filter_var($params['lname'], FILTER_SANITIZE_STRING))) {
    throw new Exception('ERROR: Last name is not a valid string');
  }
  
  $password = trim(strip_tags($params['password']));
  if (strlen($password) < 8) {
    throw new Exception('ERROR: Password should be at least 8 characters long');      
  }
      
  $email = filter_var($params['email'], FILTER_SANITIZE_EMAIL);
  if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
    throw new Exception('ERROR: Email address should be in a valid format');
  }
  
  if (!($city = filter_var($params['city'], FILTER_SANITIZE_STRING))) {
    throw new Exception('ERROR: City is not a valid string');
  }
  
  if (!($occupation = filter_var($params['occupation'], FILTER_SANITIZE_STRING))) {
    throw new Exception('ERROR: Occupation is not a valid string');
  }

  $mobilePhone = filter_var($params['mobilePhone'], FILTER_SANITIZE_NUMBER_INT);
  if (filter_var($mobilePhone, FILTER_VALIDATE_FLOAT) === false) {
    throw new Exception('ERROR: Mobile phone is not a valid number');
  }

  // generate array of user data
  $user = [
    'registration' => [
      'applicationId' => $config['passport_app_id'],
    ],
    'skipVerification' => true,
    'user'  => [
      'email' => $email,
      'firstName' => $fname,
      'lastName' => $lname,
      'password' => $password,
      'mobilePhone' => $mobilePhone,
      'data' => [
        'attributes' => [
          'city' => $city,
          'occupation' => $occupation,
        ]
      ]
    ]
  ];
  
  // encode user data as JSON
  // POST to Passport API for user registration and creation
  $apiResponse = $this->passport->post('/api/user/registration', [
    'body' => json_encode($user),
    'headers' => ['Content-Type' => 'application/json'],
  ]);

  // if successful, display success message
  // with user id
  if ($apiResponse->getStatusCode() == 200) {
    $json = (string)$apiResponse->getBody();
    $body = json_decode($json);
    $response = $this->view->render($response, 'users-save.phtml', [
      'router' => $this->router, 'user' => $body->user
    ]);
    return $response;
 }
});

// other callbacks

Passport API に送信される JSON ドキュメント内には user.data.attributes キーが追加されることに注意してください。このキーは、アプリケーション・ユーザーに関して保管するカスタム属性のすべてを格納するように設計されています。この例では、このキーにユーザーの市区町村と職業が格納されるようになっていますが、要件に応じて他の属性を追加することもできます。ユーザーの携帯電話番号は、他の属性とは別に、user.mobilePhone キーに格納されます。このキーは、Passport API があらかじめサポートしている事前定義されたキーです。

フィールドが追加された登録リクエストの動作を確認するには、登録フォームを使用して新規ユーザー・アカウントを作成します。忘れずに、追加されたフィールドにも情報を入力してください。ユーザー・アカウントを作成した後、Passport フロントエンド URL にアクセスし、管理資格情報を使用してサインインします。その後、Passport サービス・ダッシュボードに新しいユーザー・レコードを表示し、情報が正常に保存されていることを確認します。以下のような表示内容になっているはずです。

図 2. カスタム属性が追加されたユーザー・レコード
カスタム属性が追加されたユーザー・レコードを示す画面のスクリーンショット
カスタム属性が追加されたユーザー・レコードを示す画面のスクリーンショット

ステップ 2: ユーザー・プロファイルを変更できるようにする

アプリケーションでは一般に、登録済みユーザーが自分のユーザー・プロファイルに保管されている情報を変更できるようにもなっています。この例の場合、ユーザー情報は Passport を使用して保管されるのであって、アプリケーション・データベース内には保管されませんが、それでも Passport API を使用すれば、ごく簡単にユーザー情報を取得して変更することができます。

そのための最も単純な方法として、既存のユーザー登録フォームを再利用し、このフォームがユーザー・プロファイル変更フォームとしても機能するように更新します。まず、/admin/users/save ルートに対するコールバック・ハンドラーから取り掛かります。このルートは、登録フォームを表示するためのルートなので、オプションのユーザー ID をルート・パラメーターとして受け入れるように更新する必要があります (以下を参照)。

<?php
// Slim application initialization - snipped

// user form handler
$app->get('/admin/users/save[/{id}]', function (Request $request, 
  Response $response, $args) {
  $user = [];
  
  if (isset($args['id'])) {
    // sanitize input
    if (!($id = filter_var($args['id'], FILTER_SANITIZE_STRING))) {
      throw new Exception('ERROR: User identifier is not a valid string');
    }
    
    $apiResponse = $this->passport->get('/api/user/' . $id);
    
    if ($apiResponse->getStatusCode() == 200) {
      $json = (string)$apiResponse->getBody();
      $body = json_decode($json);
      $user = $body->user;
    } 
  }

  $response = $this->view->render($response, 'users-save.phtml', [
    'router' => $this->router, 'user' => $user
  ]);
  return $response;
})->setName('admin-users-save');

// other callbacks

オプションのユーザー ID を追加してこのルートをリクエストすると、コールバックが /api/user/USER_ID エンドポイントに対するリクエストを実行します。このリクエストに応答して、エンドポイントは (カスタム属性が追加された) ユーザー・レコードを含む JSON ドキュメントを返します。このユーザー・レコード内の情報が、配列としてビュー・スクリプトに渡されます。

次のステップでは、$APP_ROOT/views/users-save.phtml にある登録フォームを更新して、追加された情報を把握するとともに、渡された配列に格納されている、ユーザーの既存のデータをフォームのフィールドに事前に取り込むようにします。それには、以下の変更をフォームに加える必要があります。

...              
<?php if (!isset($_POST['submit'])): ?>
  <form method="post" 
    action="<?php echo $data['router']->pathFor('admin-users-save'); ?>">
    <input name="id" type="hidden" 
      value="<?php echo (isset($data['user']->id)) ? 
      $data['user']->id : ''; ?>" />
    <div class="form-group">
      <label for="fname">First name</label>
      <input type="text" class="form-control" id="fname" 
        name="fname" value="<?php echo (isset($data['user']->firstName)) ? 
        $data['user']->firstName : ''; ?>">
    </div>
    <div class="form-group">
      <label for="lname">Last name</label>
      <input type="text" class="form-control" id="lname" 
        name="lname" value="<?php echo (isset($data['user']->lastName)) ? 
        $data['user']->lastName : ''; ?>">
    </div>
    <div class="form-group">
      <label for="email">Email address</label>
      <input type="text" class="form-control" id="email" 
        name="email" value="<?php echo (isset($data['user']->email)) ? 
        $data['user']->email : ''; ?>">
    </div>
    <div class="form-group">
      <label for="password">Password</label>
      <input type="password" class="form-control" id="password" name="password">
    </div>
    <div class="form-group">
      <label for="city">City</label>
      <input type="text" class="form-control" id="city" name="city" 
        value="<?php echo (isset($data['user']->data->attributes->city)) ? 
        $data['user']->data->attributes->city : ''; ?>">
    </div>
    <div class="form-group">
      <label for="occupation">Occupation</label>
      <input type="text" class="form-control" id="occupation" name="occupation" 
        value="<?php echo (isset($data['user']->data->attributes->occupation)) ? 
        $data['user']->data->attributes->occupation : ''; ?>">
    </div>
    <div class="form-group">
      <label for="mobilePhone">Mobile phone (with country code)</label>
      <input type="text" class="form-control" id="mobilePhone" name="mobilePhone" 
        value="<?php echo (isset($data['user']->mobilePhone)) ? 
        $data['user']->mobilePhone : ''; ?>">
    </div>
    <div class="form-group">
      <button type="submit" name="submit" class="btn btn-default">Save
      </button>
    </div>
  </form>  
<?php else: ?>
  <div class="alert alert-success">
    <strong>Success!</strong> The user with identifier 
      <strong><?php echo $data['user']->id; ?></strong> 
      was successfully created or updated. <a role="button" 
      class="btn btn-primary" 
      href="<?php echo $data['router']->pathFor('admin-users-save'); ?>">
      Add another?</a>
  </div>
<?php endif; ?>
...

更新後のフォームの各フィールドには、value 属性が含まれていることに注目してください。この属性はフィールド値を、ユーザー・レコード内の対応する値 (存在する場合) に自動的に設定します。したがって、フォームにはユーザーの既存のデータが事前に取り込まれるというわけです。

次は、ユーザーが既存の情報の一部またはすべてを変更して送信すると、フォーム・プロセッサーが送信内容を検証して、Password サービス内の既存のユーザー・レコードを更新する必要があります。それには、リクエストにエンドポイント・シグニチャーの一部としてユーザー ID を含めて、そのリクエストを PUT リクエストとして /api/user/USER_ID エンドポイントに送信することで対処できます。

変更リクエストに対して実行される入力検証は、新規登録リクエストの場合とほとんど同じなので、既存のコールバック・ハンドラーを再利用するのが理にかなった方法となります。既存のコールバック・ハンドラーを更新して、リクエストされている URL にユーザー ID が含まれているかどうかに基づき、作成処理と変更処理を区別するようにすればよいだけです。それには、$APP_ROOT/index.php 内のコールバックを、以下に記載するように更新してください。

<?php
// Slim application initialization - snipped

// user form processor
$app->post('/admin/users/save', function (Request $request, Response $response) {
  // get configuration
  $config = $this->get('settings');

  // get input values
  $params = $request->getParams();
  
  // check for user id
  // if present, this is update modification
  // if absent, this is user creation
  if ($params['id']) {
    if (!($id = filter_var($params['id'], FILTER_SANITIZE_STRING))) {
      throw new Exception('ERROR: User identifier is not a valid string');
    }
  }    
  
  if (!($fname = filter_var($params['fname'], FILTER_SANITIZE_STRING))) {
    throw new Exception('ERROR: First name is not a valid string');
  }
  
  if (!($lname = filter_var($params['lname'], FILTER_SANITIZE_STRING))) {
    throw new Exception('ERROR: Last name is not a valid string');
  }
  
  $password = trim(strip_tags($params['password']));
  if (empty($id)) {
    if (strlen($password) < 8) {
      throw new Exception('ERROR: Password should be at least 8 characters long');      
    }
  } else {
    if (!empty($password) && (strlen($password) < 8)) {
      throw new Exception('ERROR: Password should be at least 8 characters long');      
    }
  }
      
  $email = filter_var($params['email'], FILTER_SANITIZE_EMAIL);
  if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
    throw new Exception('ERROR: Email address should be in a valid format');
  }
  
  if (!($city = filter_var($params['city'], FILTER_SANITIZE_STRING))) {
    throw new Exception('ERROR: City is not a valid string');
  }
  
  if (!($occupation = filter_var($params['occupation'], FILTER_SANITIZE_STRING))) {
    throw new Exception('ERROR: Occupation is not a valid string');
  }

  $mobilePhone = filter_var($params['mobilePhone'], FILTER_SANITIZE_NUMBER_INT);
  if (filter_var($mobilePhone, FILTER_VALIDATE_FLOAT) === false) {
    throw new Exception('ERROR: Mobile phone is not a valid number');
  }
  
  // generate array of user data
  $user = [
    'registration' => [
      'applicationId' => $config['passport_app_id'],
    ],
    'skipVerification' => true,
    'user'  => [
      'email' => $email,
      'firstName' => $fname,
      'lastName' => $lname,
      'mobilePhone' => $mobilePhone,
      'data' => [
        'attributes' => [
          'city' => $city,
          'occupation' => $occupation,
        ]
      ]
    ]
  ];
  
  // add password if exists
  // can be empty for user modification operations
  if (!empty($password)) {
    $user['user']['password'] = $password;
  }
  
  if (empty($id)) {
    // encode user data as JSON
    // POST to Passport API for user registration and creation
    $apiResponse = $this->passport->post('/api/user/registration', [
      'body' => json_encode($user),
      'headers' => ['Content-Type' => 'application/json'],
    ]);
  } else {
    // encode user data as JSON
    // PUT to Passport API for user modification
    $apiResponse = $this->passport->put('/api/user/' . $id, [
      'body' => json_encode($user),
      'headers' => ['Content-Type' => 'application/json'],
    ]);      
  }

  // if successful, display success message
  // with user id
  if ($apiResponse->getStatusCode() == 200) {
    $json = (string)$apiResponse->getBody();
    $body = json_decode($json);
    $response = $this->view->render($response, 'users-save.phtml', [
      'router' => $this->router, 'user' => $body->user
    ]);
    return $response;
 }
});

// other callbacks

上記の更新後のハンドラーを、第 1 回に記載した単純なバージョンと比較すると、以下の重要な違いがあることに気付くはずです。

  • 上記のコールバックは、最初に、リクエスト・パラメーターにユーザー ID が含まれるかどうかをチェックします。ユーザー ID が含まれている場合、コールバックはこれが新規ユーザーの作成処理ではなく、更新処理であると見なします。この情報は、以降の入力検証の一部 (特に、パスワード検証) をどのように実行するかを左右します。
  • 以前は、このコールバックはフォームに入力して送信されたパスワードをテストして、パスワードの長さが少なくとも 8 文字であることを確認し、8 文字未満である場合はエラーを起動しました。けれども更新処理の場合、ユーザーが既存のパスワードを更新しないことを選択する場合もあります。したがって、更新処理の際にパスワードが提供されないとしてもエラーを起動しないよう、ハンドラー内のパスワード検証コードが更新されています。同様に、入力検証の後には JSON ドキュメントの user キーが構成されるため、新規ユーザーの作成処理、あるいはフォームに入力して新しいパスワードが提供された場合の更新処理にのみ、password キーを組み込むようになっています。
  • 以前は、クライアントが JSON にエンコードされたドキュメントを POST リクエストによって Passport API の /api/user/registration エンドポイントに送信することになっていました。コード内のその処理に対応するセグメントが更新されて、POST リクエストは新規ユーザーの作成処理の場合にだけ送信されるようにっています。更新処理の場合は、PUT リクエストが生成されて /api/user/USER_ID エンドポイントに送信されます。

以上のすべての変更を追加した結果、新規ユーザー登録を処理する際にも、既存のユーザーが自分のプロファイルを変更できるようにする際も、同じフォームを使用できるようになっています。後は、ユーザー・リスト・ページに表示される各レコードの横に「Edit (編集)」コマンド・ボタンを追加して、そのボタンを上記のルートにリンクすれば完了です (そのためのコードは、ソース・コード・リポジトリー内にあります)。

最終的に、このフォームは以下のように表示されます。

図 3. 編集処理用に入力されたユーザー登録フォーム
編集処理用に入力されたユーザー登録フォームのスクリーンショット
編集処理用に入力されたユーザー登録フォームのスクリーンショット

ステップ 3: ユーザー・アカウントを削除できるようにする

Passport API を統合すると、アプリケーション・ユーザーが自分のプロファイルを編集するためのインターフェースを提供できるのと同様に、アプリケーション管理者がユーザーをシステムから削除できるようにすることもできます。それには、Passport API の /api/user/USER_ID エンドポイントに DELETE リクエストを送信して、そのリクエスト内にクエリー・パラメーターとして hardDelete 引数を追加すればよいだけです。以下に、この機能をアプリケーションに追加するために必要なコードを記載します。

<?php
// Slim application initialization – snipped

// user deletion handler
$app->get('/admin/users/delete/{id}', function (Request $request, 
  Response $response, $args) {
  // sanitize and validate input
  if (!($id = filter_var($args['id'], FILTER_SANITIZE_STRING))) {
    throw new Exception('ERROR: User identifier is not a valid string');
  }

  $apiResponse = $this->passport->delete('/api/user/' . $id , [
    'query' => ['hardDelete' => 'true']
  ]);

  return $response->withHeader('Location', 
    $this->router->pathFor('admin-users-index'));
})->setName('admin-users-delete');

// other callbacks

前と同じように、ユーザー・リスト・ページに表示される各レコードの横に「Delete (削除)」コマンド・ボタンを追加して、そのボタンを上記のルートにリンクする必要があります。最終的には、ユーザー・ダッシュボードは以下のように表示されます。

図 4. 編集/削除ボタンが追加されたユーザー・ダッシュボード
編集/削除ボタンが追加されたユーザー・ダッシュボードのスクリーンショット
編集/削除ボタンが追加されたユーザー・ダッシュボードのスクリーンショット

ステップ 4:ロール・ベースのアクセスを実装する

第 1 回で、ログイン時にユーザーに認証を行うよう求めることによって、アプリケーションの特定のページへのアクセスを保護する方法を説明しました。この仕組みを Slim ミドルウェアとして実装し、このミドルウェアを特定のルート・ハンドラーに追加することで、対応するアプリケーション機能へのアクセスを保護できるようにしました。

けれども、アクセスを制限するには、認証だけでは十分でないこともよくあります。一般的な例としては、アプリケーション・ユーザーのそれぞれに 1 つ以上のロールを割り当て、ユーザーが利用できるフィーチャーや機能をそのロール (または複数のロールの組み合わせ) に定義することがあります。この場合、例えば「従業員」ロールが割り当てられたユーザーは限られた数の機能にアクセスできる一方、「管理者」ロールが割り当てられたユーザーはすべての機能にアクセスできるといった仕組みになります。

このようなロール・ベースの認証を PHP アプリケーションに実装することも、Passport API では完全にサポートされています。各ユーザー・レコードには roles キーが含まれていて、そこに、対応するユーザーに割り当てられているすべてのロールが格納されます。したがって、アプリケーションではユーザーに対して特定の機能へのアクセスを許可または拒否するかを決定する際に、このキーを使用できます。

単純な例で、この仕組みを見ていきましょう。例えば、アプリケーションでサポートするユーザー・ロールに、「gold」メンバーシップと「silver」メンバーシップの 2 つがあるとします。さらに、この 2 つのロールはそれぞれに異なる、重複しないアプリケーション機能へのアクセス権が定義するとします。この場合、ロール・ベースのアクセスを実装するには、以下の手順に従います。

  1. まず始めに、サポート対象のロールについて Passport API に通知します。Passport フロントエンド URL にアクセスし、管理資格情報を使用してサインインします。その後、アプリケーションの「Manage Roles (ロールの管理)」メニュー項目にナビゲートして、「member-silver」と「member-gold」という 2 つのロールを追加します。ロールを追加した後は、以下のような設定になります。
    図 5. ユーザー・ロール
    ユーザー・ロールを示す画面のスクリーンショット
    ユーザー・ロールを示す画面のスクリーンショット
  2. 次は、この 2 つのロールをサポートするよう、ユーザー登録フォームとハンドラーを更新します。つまり、登録プロセスで、ユーザーに適切なロールを割り当て可能にするということです。それには、以下に記載するコードを $APP_ROOT/views/users-save.phtml にあるユーザー登録フォームに追加してください。
    ...
    <div class="form-group">
      <label for="tier">Membership tier</label>
      <select class="form-control" id="tier" name="tier">
        <option value="1" <?php echo !empty($data['user']) &&
          !empty($data['user']->registrations[0]->roles) &&    
          ($data['user']->registrations[0]->roles[0] == 'member-gold') ? 
          'selected="selected"' : ''; ?>>Gold</option>
        <option value="2" <?php echo !empty($data['user']) && 
          !empty($data['user']->registrations[0]->roles) &&    
          ($data['user']->registrations[0]->roles[0] == 'member-silver') ? 
          'selected="selected"' : ''; ?>>Silver</option>
      </select>
    </div>
    ...

    上記のコードによって、ロールを選択するためのリストがユーザー登録フォームに追加されます。フォームは以下のように表示されます。
    図 6. ロール・セレクターが追加されたユーザー・プロファイル・フォーム
    ロール・セレクターが追加されたユーザー・プロファイル・フォームを示す画面のスクリーンショット
    ロール・セレクターが追加されたユーザー・プロファイル・フォームを示す画面のスクリーンショット
  3. 同時にフォーム・プロセッサーを更新して、フォーム・プロセッサーが選択されたロールを検証し、新規ユーザーを作成する際に Passport に送信される JSON ドキュメントにそのロールを追加するようにします。選択されたロールは registration.roles キーに追加します。
    <?php
    // Slim application initialization – snipped
    
    // user form processor
    $app->post('/admin/users/save', function (Request $request, Response $response) {
    
      // ...
      
      if (!($tier = filter_var($params['tier'], FILTER_SANITIZE_NUMBER_INT))) {
        throw new Exception('ERROR: Membership tier is not valid');
      }
      if ($tier == 1) {
        $role = 'member-gold';
      } else if ($tier == 2) {
        $role = 'member-silver';
      }
      
      // generate array of user data
      $user = [
        'registration' => [
          'applicationId' => $config['passport_app_id'],
          'roles' => [
            $role
          ]
        ],
        'skipVerification' => true,
        'user'  => [
          'email' => $email,
          'firstName' => $fname,
          'lastName' => $lname,
          'mobilePhone' => $mobilePhone,
          'data' => [
            'attributes' => [
              'city' => $city,
              'occupation' => $occupation,
            ]
          ]
        ]
      ];
      
      // ...
      
    });
    
    // other callbacks
  4. 次のステップでは、ロールに基づいてアクセスを許可するためのコードを追加します (Slim ミドルウェアとして実装します)。そのためのコードでは、現在ログインしているユーザーのロールをチェックし、そのロールをアクセス対象のルートのロール要件と照合します。要件に一致しなければ、ルートへのアクセスを拒否して、ユーザーをログイン・ページにリダイレクトします。そのための以下のコードは $APP_ROOT/public/index.php にある他のいずれのコールバック関数よりも前に追加してください。
    <?php
    // Slim application initialization – snipped
    
    // simple authorization middleware
    $authorize = function ($role) {
      return function($request, $response, $next) use ($role) {
        if ($_SESSION['user']->registrations[0]->roles[0] != $role) {
          return $response->withHeader('Location', 
            $this->router->pathFor('login'));
        } 
        return $next($request, $response);  
      };
    };
    
    // other callbacks
  5. 最後のステップとして、ロールによってアクセスを制限するすべてのルートに、上記のアクセス許可ミドルウェアを追加します。ロール・ベースのアクセス制限をデモするために、「gold」メンバー専用のルートと「siver」メンバー専用のルート、およびそれらのルートに対応するコールバック・ハンドラーを $APP_ROOT/public/index.php 内に作成します。
    <?php
    // Slim application initialization – snipped
    
    // role-limited page handler
    $app->get('/members/gold', function (Request $request, Response $response) {
      return $this->view->render($response, 'members-gold.phtml', [
        'router' => $this->router, 'user' => $_SESSION['user']
      ]);
    })->setName('members-gold')->add($authenticate)->add($authorize('member-gold'));
    
    // role-limited page handler
    $app->get('/members/silver', function (Request $request, Response $response) {
      return $this->view->render($response, 'members-silver.phtml', [
        'router' => $this->router, 'user' => $_SESSION['user']
      ]);
    })->setName('members-silver')->add($authenticate)->add($authorize('member-silver'));
    
    // other callbacks

    /members/gold ルートには、2 つのミドルウェア関数が関連付けられていることに注目してください。一方の $authenticate ミドルウェアは、このルートをログイン済みユーザーだけが使用できるようにします。さらにもう一方の $authorize ミドルウェアが、ログイン済みユーザーのうち、「member-gold」ロールが割り当てられているユーザーだけにルートへのアクセスを許可します。/members/silver ルートについても同様の手法に従って、「member-silver」ロールが割り当てられているユーザーだけにアクセスを制限します。これらのルートのビュー・スクリプトはアプリケーション・ソース・コード・リポジトリーから取得できます。

    このロール割り当ての動作を確認するには、アプリケーション内に 2 つのユーザーを作成し、それぞれのユーザーに「gold」ロールまたは「silver」ロールを割り当てます。いずれかのユーザーとしてログインした後、上記の 2 つのルートにアクセスしてみてください。「gold」ユーザーは /members/gold ルートにアクセスできても、/members/silver ルートへのアクセスは拒否されるはずです。「silver」ユーザーにはその逆が当てはまります。以下に、ロールによって制限されるページの例を示します。
    図 7. ロールによって制限されるページ
    ロールによって制限されるページのスクリーンショット
    ロールによって制限されるページのスクリーンショット

この実装では、ロールが割り当てられていないユーザーは、ロールによって制限されるページのいずれにもアクセスできない点に注目してください。ロールが割り当てられていないユーザーにアクセスを許可するには、特殊な例外を実装に組み込む必要があります。そのような特殊な例外をコーディングしなければならなくなることを避けるために、プロジェクトを開始する時点で、必ずロール・ベースのアクセス制御を定義して実装するのが賢明です。

ステップ 5: パスワード回復を実装する (リクエスト段階)

それなりの価値を提供するアプリケーションというものには例外なく、ユーザーがパスワードを忘れた場合に対処する方法が必要です。Passport API にはパスワードを忘れた場合の回復システムを迅速に実装するための手段が組み込まれているため、大量のコードを作成したり、多くの時間を費やしたりする必要はありません。このパスワード回復システムは以下のように機能します。

  1. ユーザーがアプリケーション内のリンクをクリックして、パスワード回復ワークフローを開始します。
  2. アプリケーションがユーザーに e-メール・アドレスの入力を求めます。e-メール・アドレスの入力を受信すると、アプリケーションが Passport API エンドポイント /api/user/forgot-password にリクエストを送信し、ユーザーの e-メール・アドレスを渡します。
  3. Passport API が、検証のためのリンクと固有の検証 ID を記載した e-メールをユーザーの e-メール・アドレスに送信します。このリンクは、ユーザーをアプリケーション内のルートにリダイレクトするためのものです。
  4. ユーザーが e-メールを受信し、検証リンクをクリックします。
  5. アプリケーションがユーザーに新しいパスワードの入力を求めます。パスワードの入力を受信すると、アプリケーションは Passport API エンドポイント /api/user/change-password/VERIFICATION_ID にリクエストを送信し、検証 ID と新しいパスワードを渡します。
  6. Passport API が検証 ID を使用してリクエストを検証します。ID が一致していれば、Passport API はユーザーのパスワードを、渡された新規パスワードにリセットします。

このプロセスを実装するには、まず、ユーザーがパスワードのリセットをリクエストするためのフォームを $APP_ROOT/views/password-request.phtml に作成します。

...
<?php if (!isset($_POST['submit'])): ?>
<div>
  <form method="post" 
    action="<?php echo $data['router']->pathFor('password-request'); ?>">
    <div class="form-group">
      <label for="email">Email address</label>
      <input type="text" class="form-control" id="email" name="email">
    </div>
    <div class="form-group">
      <button type="submit" name="submit" 
        class="btn btn-default">Submit</button>
    </div>
  </form>  
</div>
<?php else: ?>
<div>
  <?php if ($data['status'] == 200): ?>
  <div class="alert alert-success">
    <strong>Success!</strong> A verification email has been sent 
      to your email address. Click the link in the email to proceed.
  </div>
  <?php elseif ($data['status'] == 404): ?>
  <div class="alert alert-danger">
    <strong>Failure!</strong> No user matching that identifier 
      could be found.
  </div>
  <?php else: ?>
  <div class="alert alert-danger">
    <strong>Failure!</strong> Something unexpected happened.
  </div>
  <?php endif; ?>
</div>        
<?php endif; ?>
...

次に、このフォームをレンダリングして送信された e-メール・アドレスを処理するためのコールバック・ハンドラーを作成します。

<?php
// Slim application initialization – snipped

// password reset handlers (request step)
$app->get('/password-request', function (Request $request, Response $response) {
  return $this->view->render($response, 'password-request.phtml', [
    'router' => $this->router
  ]);
})->setName('password-request');

$app->post('/password-request', function (Request $request, Response $response) {
  try {
    // validate input 
    $params = $request->getParams();
    $email = filter_var($params['email'], FILTER_SANITIZE_EMAIL);
    if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
      throw new Exception('ERROR: Email address should be in a valid format');
    }

    // generate array of data
    $data = [
      'loginId' => $email,
      'sendForgotPasswordEmail' => true
    ];

    $apiResponse = $this->passport->post('/api/user/forgot-password', [
      'body' => json_encode($data),
      'headers' => ['Content-Type' => 'application/json'],
    ]);

  } catch (ClientException $e) {
    // in case of a Guzzle exception
    // if 404, user not found error 
    // bypass exception handler and show failure message
    // for other errors, transfer to exception handler as normal
    if ($e->getResponse()->getStatusCode() != 404) {
      throw new Exception($e->getResponse());
    } else {
      $apiResponse = $e->getResponse();
    }
  } 

  return $this->view->render($response, 'password-request.phtml', [
    'router' => $this->router, 
    'status' => $apiResponse->getStatusCode()
  ]);
});

// other callbacks

アプリケーション URL /password-request にアクセスすると、以下のようなフォームが表示されます。

図 8. パスワード・リセットのリクエストとレスポンス
パスワード・リセットのリクエストとレスポンスを示す画面のスクリーンショット
パスワード・リセットのリクエストとレスポンスを示す画面のスクリーンショット

URL へのリンクは、アプリケーションのログイン・ページに追加できます (以下を参照)。

            <a href="<?php echo $data['router']->pathFor('password-request'); ?>" 
              class="btn btn-default">Forgot password?</a>

フォームが送信されると、フォームに入力された e-メール・アドレスが検証され、有効なアドレスであれば、ハンドラーが /api/user/forgot-password エンドポイントに POST リクエストを送信します。リクエストの本文には、送信された e-メール・アドレスと、指定のアドレスに検証 e-メールを送信するよう Passport API に指示するフラグが含まれます。

ユーザーの e-メール・アドレスと一致するアドレスが Passport データベース内で見つからない場合、リクエストに対するレスポンスは 404 エラーになります。ハンドラーはこのエラーをキャッチして、該当するエラー・メッセージを生成するために使用します。一致する e-メール・アドレスが見つかった場合、Passport サービスは検証リンクが含まれる e-メールをユーザーに送信します。

このリンクのターゲットは、Passport サービスのダッシュボード内で構成できます。アプリケーションによって制御される URL を指すようにターゲットを構成してください。URL の末尾には、Passport サービスによって自動的に固有の検証 ID が付加されます。リンクを定義するには、Passport フロントエンド URL にアクセスし、管理資格情報を使用してサインインします。その後、「Settings (設定)」 > 「Email Templates (e-メール・テンプレート)」 > 「Forgot Password (パスワードを忘れた)」テンプレートを表示します。以下に示すようにテンプレート内のリンクを更新して、アプリケーション・ホストを反映するようにドメインを調整します。リンク URL に含まれる ${user.verificationId} プレースホルダーに注目してください。このプレースホルダーは、新規パスワードをインストールする前のセキュリティー・チェックとして使用される検証 ID です。

図 9. パスワード・リセット用の e-メール・テンプレート
パスワード・リセット用の e-メール・テンプレートを示す画面のスクリーンショット
パスワード・リセット用の e-メール・テンプレートを示す画面のスクリーンショット

ステップ 6: パスワード回復を実装する (検証およびリセット段階)

ユーザーが e メールを受信して、e メールに記載されている検証リンクをクリックすると、新しいパスワードを入力するためのフォームが表示されます。

以下に、アプリケーション URL /password-reset に対するコールバック・ハンドラーを記載します。このハンドラーは、ユーザーが e-メールに記載されている検証リンクをクリックした時点で呼び出されます。

<?php
// Slim application initialization – snipped

// password reset handlers (reset step)
$app->get('/password-reset[/{id}]', function (Request $request, 
  Response $response, $args) {
  // sanitize and validate input
  if (!($id = filter_var($args['id'], FILTER_SANITIZE_STRING))) {
    throw new Exception('ERROR: Verification string is invalid');
  }
  return $this->view->render($response, 'password-reset.phtml', [
    'router' => $this->router, 'id' => $args['id']
  ]);
})->setName('password-reset');

// other callbacks

上記のコールバック・ハンドラーは、URL と併せてルート・パラメーターとして渡された検証 ID を見つけて、その検証 ID をサニタイズしてから、検証 ID を隠れフィールドとして含め、ユーザーが新しいパスワードを入力するためのフィールドを表示するフォームをレンダリングします。このフォームのコードを以下に記載します。このコードは $APP_ROOT/views/password-reset.phtml に作成します。

...
<?php if (!isset($_POST['submit'])): ?>
<div>
  <form method="post" 
    action="<?php echo $data['router']->pathFor('password-reset'); ?>">
    <input name="id" type="hidden" 
      value="<?php echo htmlentities($data['id']); ?>" />
    <div class="form-group">
      <label for="password">Password</label>
      <input type="password" class="form-control" id="password" 
        name="password">
    </div>
    <div class="form-group">
      <label for="password-confirm">Password (again)</label>
      <input type="password" class="form-control" id="password-confirm" 
        name="password-confirm">
    </div>
    <div class="form-group">
      <button type="submit" name="submit" 
        class="btn btn-default">Submit</button>
    </div>
  </form>  
</div>
<?php else: ?>
<div>
  <?php if ($data['status'] == 200): ?>
  <div class="alert alert-success">
    <strong>Success!</strong> Your password was changed.
  </div>
  <?php elseif ($data['status'] == 404): ?>
  <div class="alert alert-danger">
    <strong>Failure!</strong> Your password could not be changed.
  </div>
  <?php else: ?>
  <div class="alert alert-danger">
    <strong>Failure!</strong> Something unexpected happened.
  </div>
  <?php endif; ?>
</div>        
<?php endif; ?>
...

このフォームに含まれる検証 ID の隠れフィールドは、コールバック・ハンドラーがテンプレート変数としてフォームに渡すことに注意してください。フォームは以下のように表示されます。

図 10. パスワード・リセット・フォーム
パスワード・リセット・フォームを示す画面のスクリーンショット
パスワード・リセット・フォームを示す画面のスクリーンショット

最後のステップは、このフォームを処理して、Passport データベース内のユーザーのパスワードを更新することです。このフォーム・プロセッサーは、以下のような内容になります。

<?php
// Slim application initialization – snipped

// password reset handler
$app->post('/password-reset', function (Request $request, Response $response) {
  try {
    // validate input 
    $params = $request->getParams();

    // sanitize and validate input
    if (!($id = filter_var($params['id'], FILTER_SANITIZE_STRING))) {
      throw new Exception('ERROR: Verification string is invalid');
    }

    
    $password = trim(strip_tags($params['password']));
    if (strlen($password) < 8) {
      throw new Exception('ERROR: Password should be at least 8 characters long');      
    }
    $passwordConfirm = trim(strip_tags($params['password-confirm']));
    if ($password != $passwordConfirm) {
      throw new Exception('ERROR: Passwords do not match');      
    }
    
    // generate array of data
    $data = [
      'password' => $password,
    ];

    $apiResponse = $this->passport->post('/api/user/change-password/' . $id, [
      'body' => json_encode($data),
      'headers' => ['Content-Type' => 'application/json'],
    ]);

  } catch (ClientException $e) {
    // in case of a Guzzle exception
    // if 404, user not found error 
    // bypass exception handler and show failure message
    // for other errors, transfer to exception handler as normal
    if ($e->getResponse()->getStatusCode() != 404) {
      throw new Exception($e->getResponse());
    } else {
      $apiResponse = $e->getResponse();
    }
  } 

  return $this->view->render($response, 'password-reset.phtml', [
    'router' => $this->router, 
    'status' => $apiResponse->getStatusCode()
  ]);
});

// other callbacks

ユーザーが新しいパスワードを入力してフォームを送信すると、上記のフォーム・プロセッサーは、パスワードが要件を満たしていることを検証してから、/api/user/change-password/VERIFICATION_ID エンドポイントに対する POSTリクエストを開始します。この POST リクエストの本文に、ユーザーの新規パスワードが含まれます。

Passport 側では、Passport API が検証 ID の有効性をチェックします。有効であれば、API はユーザーのパスワードを新しい値にリセットします。リセット処理が成功したかどうかによって、API はレスポンス・コード 200 を返すか、エラー・コード 4xx を返します。いずれのコードも、コールバックがインターセプトして、成功または失敗メッセージを表示するために使用できます。成功した場合、ユーザーは新しいパスワードを使用してアプリケーションにログインできます。

まとめ

このチュートリアルで紹介した例から明らかなように、IBM Cloud の Passport サービスを利用すれば、API クライアントを用意し、Passport API をある程度理解するだけで、フル機能を備えたユーザー管理、認証、そしてロール・ベースのアクセスを迅速かつ簡単にアプリケーションに追加することができます。このチュートリアルで使用したサンプル・アプリケーションは PHP アプリケーションですが、ここで概説した API と原則は、他のどのプログラミング言語で作成されたアプリケーションにも同じく有効です。このチュートリアルの手法に従うことで、最近のセキュリティー要件と SSO 要件を満たすとともに、新しい要件に対応できるだけの柔軟性もある、スケーラブルでセキュアなアプリケーションが完成します。

チュートリアルで説明した Passport サービスを試してみようと思ったら、まずは、このデモ・アプリケーションをいろいろと操作してみてください。デモを試した後は、GitHub リポジトリーからコードをダウンロードして、すべての構成要素がどのように連動しているのかを詳しく調べてください。また、以下の「関連トピック」セクションに記載されているリンク先を参照して、このチュートリアルで利用したさまざまなサービスとツールの詳細を学ぶこともできます。どうぞ、お楽しみください!


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


関連トピック


コメント

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Cloud computing, セキュリティ
ArticleID=1055080
ArticleTitle=PHP と Passport サービスを利用して IBM Cloud アプリケーション内で容易にユーザーを管理、認証する, 第 2 回: ロール・ベースのアクセス制御機能とパスワード回復機能を PHP アプリケーションに追加する
publish-date=12142017