JavaScript
CakePHP
AWS
rest
勉強会

CakePHPで実装するログイン機能 - AWS上で構築するRESTfulアプリ勉強会~Web開発ワークショップ~【第5回】マニュアル

More than 3 years have passed since last update.

:large_blue_circle: はじめに

本投稿は、2015/5/22に行われた、CakePHPで実装するログイン機能 - connpassの内容についてまとめた資料です。

:warning:今後の予定
AWS上で構築するRESTfulアプリ勉強会~Web開発ワークショップ~ - connpass

今回は、ログイン機能を追加します。
第2回でBackbone.jsを使用して画面を作りましたが、それ以来の機能追加です!


:warning: 2015/5/25追記 - 1

phpMyAdminにログインできない、という方が何名かいらっしゃいました。

/etc/httpd/conf/httpd.conf(66行目から)
User study
Group apache

であるべきなんですが、なぜか、

/etc/httpd/conf/httpd.conf
User study
Group wheel

と、Groupがwheelになっていました。
rootでログインして、そこを直してapache再起動(service httpd restart)すればOKでした。
※第1回のQiita記事作成と前後して、開発環境の元とするAMIを直前に修正したりしていたので、そのへんのタイミングでまちがった設定のAMIを一時公開してしまっていた可能性があります。
申し訳ございません!

:warning: 2015/5/25追記 - 2

ユーザ情報は、パスワードをハッシュ化してから保存しますので、phpMyAdminでデータ追加してもログイン時にエラーになります。ご注意下さい。

:large_blue_circle: ログインとは?

由来はこういうことらしいです。
それはさておき、ログイン機能を実装する際にやるべきことは次の2つです。

  • 認証 - Authentication
  • 承認(認可) - Authorization

認証 - Authentication とは?

認証とは、「本人確認」と言っていいでしょう。
今回作成するログイン機能では、シンプルなフォーム認証を使用します。
つまり、「ユーザID」と「パスワード」を使用し、正しければ本人であるとみなします。
「ログイン」といった場合通常はこの「認証」を指しますが、「承認」処理とセットで実装することになります。

承認(認可) - Authorization とは?

「認証」されたユーザに対し、特定のリソースやサービスへのアクセスを許可することを言います。
今回は、認証されたユーザには全リソースへのアクセスを許可することとします。

:large_blue_circle: 今回のログイン処理実装の特殊なところ

この勉強会の大前提は「REST」、「SinglePageApplication(SPA)」です。
SPAでもRESTでもない、通常のCakePHPのアプリケーション(サーバサイドでページ生成する普通のWebアプリ)だと、未認証状態で認証が必要なページにアクセスした場合、ログインページにリダイレクトされます。
ですが、SPAの場合はサーバへのアクセスは全てRestAPIを通じて行われるので、基本的にリダイレクトは使用しません。単にHTTPステータスコード401(Unauthorized)をクライアントに返し、クライアント側プログラムが自身でログイン画面を表示します。
未認証の状態での全てのAPIリクエストは、HTTPステータスコード401を返す可能性があるので、クライアント側はそれに対応する必要があります。

:large_blue_circle: 今回の内容

今回は、以下の機能を実現します。けっこう多いので頑張りましょう!

機能リスト

  • ユーザ登録
    • ユーザ名と氏名、パスワードを入力し、新規ユーザを登録します
      • 成功すればメッセージ表示
  • ログイン
    • ユーザ名、パスワードを入力し、ログインボタンをクリック
      • 正しければTODO一覧画面に遷移
      • 間違っていればエラーを表示
      • 既に他のユーザがログイン済みの場合その旨表示
      • 自分自身が既にログイン済みであればTODO一覧画面に遷移
  • ログアウト
    • ログインした状態でログアウトボタンをクリック
      • ログイン画面に遷移
  • ヘッダ表示
    • ログイン中は画面上部(ヘッダ)にユーザ名とログアウトボタンを常に表示する。
  • 認証済みチェック
    • 未認証ユーザによる、ログイン画面以外へのアクセスは全てログイン画面に遷移させる

画面はこんな感じです

  • ログインとユーザ登録
    • 画面上半分がログイン画面、下半分がユーザ登録画面です。
    • ユーザ登録では、ユーザ名と氏名、パスワードを登録します。
    • :warning: 登録には「パスワードの再入力」欄を用意するのがセオリーですが、今回はシンプルにする為あえて用意していません。
      • 余力があれば付けてみましょう!

login.png

  • TODO一覧画面
    • 画面上部にヘッダが表示されます。ユーザ名と、カッコ内に氏名、ログアウトボタンが表示されます。

todo-lists.png

  • TODO詳細画面
    • 同じくヘッダが表示されます。

todo-detail.png

:large_blue_circle: 事前準備

事前準備は毎回同じなので、別エントリにまとめました。
全12回の勉強会でやっているGitの使い方 - AWS上で構築するRESTfulアプリ勉強会~Web開発ワークショップ~ - Qiitaを参照してください。
やることは、

  • gitのブランチを整えて今回用ブランチvol/05を作成する

です。まずこれをやりましょう。今回はさらに、

  • ユーザ登録用のテーブル作成

も行います。

ユーザ登録用のテーブル作成

作成するのは、usersテーブルです。

まず、phpMyAdminにログインしましょう。

URLは、http://(PublicIP)/phpMyAdmin/です。
変更していなければ、ユーザ名study、パスワードstudyでログインできます。

下図の通り、左側ペインの[study]-[New]をクリックし、テーブルを作成して下さい。

create_table.png

下記の通り設定して下さい。

テーブル名

users

各列の設定
名前 データ型 長さ/値 デフォルト値 照合順序 属性 NULL インデックス A_I(AutoIncrement) コメント
id INT 10 なし   UNSIGNED   PRIMARY id
username VARCHAR 20 なし utf8_general_ci     UNIQUE   ユーザ名
password VARCHAR 100 なし utf8_general_ci         パスワード
name VARCHAR 50 なし utf8_general_ci         氏名
その他の設定
  • テーブルのコメント:ユーザ
  • ストレージエンジン:InnoDB
  • 照合順序:utf8_general_ci
  • パーティションの定義:空白

「保存する」クリックでテーブル作成!

準備

では、準備を始めましょう!

  • :white_check_mark: Gitのブランチを整えて、vol/05ブランチを作成。
  • :white_check_mark: ユーザ登録用テーブルを作成。

準備ができたら、Lesson1です!

:large_blue_circle: Lesson1 サーバサイド

Lesson1では、サーバサイドのプログラムを実装します。
プログラムのテストには、第1回でインストールした「POSTMAN」を使用してAPIの動作確認を行います。

ポイント

SPAでないアプリケーションと、WebAPIを使用したSPAアプリケーションの実装の仕方の違いを抑えておきましょう。

未ログイン状態でTODO一覧を表示しようとした場合

SPAでない通常のWebアプリケーション

こういうシーケンスです。

login_seq_normal.png

  1. TODO一覧画面をサーバに要求
  2. サーバは、未ログインのユーザからの要求なので、ステータスコード302を返し、ログイン画面にリダイレクトさせる。
  3. 302を受けたブラウザが、Locationヘッダで指定されたURLを要求
  4. サーバはログイン画面のHTMLを返す
SPAの場合

こうなります。

login_seq_spa.png

  1. TODO一覧画面をサーバに要求
  2. サーバは、未ログインのユーザからの要求なので、ステータスコード401を返し、要求が許可されないことを応答する。
  3. 401を受けるとJavascriptが自分でログイン画面を表示します。

この違いを抑えておきましょう!

作成するAPI

URL Http Method 処理 Controller Action
/rest-study/users/login.json POST ログインする UsersController login
/rest-study/users/logout.json POST ログアウトする UsersController logout
/rest-study/users/loggedin.json GET ログインユーザ情報を取得(ログイン中かどうかのチェック) UsersController loggedIn
/rest-study/users/signup.json POST ユーザを登録する UsersController signUp

編集するファイル一覧

編集 file 編集概要
修正 app/Config/routes.php 追加するAPIへのルート追加
修正 app/Controller/AppController.php Authコンポーネントの設定
新規 app/Controller/UsersController.php API実装
新規 app/Model/User.php ユーザテーブルへのアクセス

routes.php

追加したAPIへのルートを追加します。

routes.php
〜略〜
 /*
  * API
  */
+
+// ログイン
+Router::connect('/users/login', array (
+   'controller' => 'users',
+   'action' => 'login',
+   'method' => array (
+       'POST'
+   )
+));
+
+// ログアウト
+Router::connect('/users/logout', array (
+   'controller' => 'users',
+   'action' => 'logout',
+   'method' => 'POST'
+));
+
+// ログインチェック(ログイン情報取得)
+Router::connect('/users/loggedin', array (
+   'controller' => 'users',
+   'action' => 'loggedIn',
+   'method' => 'GET'
+));
+
+// サインアップ
+Router::connect('/users/signup', array (
+   'controller' => 'users',
+   'action' => 'signUp',
+   'method' => array (
+       'POST'
+   )
+));
+
+
 Router::mapResources(array (
    'todo_lists',
 ));
 Router::parseExtensions('json');
〜略〜

単純なCRUDを実行するAPIであれば、
Router::mapResources()関数でControllerを追加してあげれば
index, view, add, edit, deleteのアクションへのルートが設定されますが、ログインのURLはこうではないので、個別に追加しています。
Router::connect()関数を使用して下記項目を全て設定しています。

  • URL
  • 実行するcontroller
  • 実行するaction
  • 対象とするhttpメソッド

ログインの場合、

  • URL -> /users/login
  • 実行するcontroller -> users
  • 実行するaction -> login
  • 対象とするhttpメソッド -> POST

なので、上記コードのように設定します。

AppController.php

コンポーネント、承認処理を追加。

routes.php
 class AppController extends Controller {
-    public $components = array(
-        'RequestHandler'
-    );
+   public $components = array (
+       'RequestHandler',
+       'Auth' => array (
+           'authenticate' => array (
+               'Form' => array (
+                   'passwordHasher' => 'Blowfish'
+               )
+           )
+       )
+   );
+
+   public function beforeFilter() {
+       $user = $this->Auth->user();
+       if ($user === null
+               && $this->request->params['controller'] !== 'users'
+               && $this->request->params['action'] !== 'login'
+               && $this->request->params['action'] !== 'logout'
+               && $this->request->params['action'] !== 'loggedin'
+               && $this->request->params['action'] !== 'signup') {
+                   throw new UnauthorizedException();
+       }
+       //全アクションを許可
+       $this->Auth->allow();
+   }
+
 }
$components変数

認証処理は、CakePHPが用意してくれているAuthコンポーネントを使用します。
フォーム認証を使用し、パスワードのハッシュアルゴリズムにはBlowfishを使用することを指定しています。

beforeFilter関数

全てのアクションの前に実行される関数がこのbeforeFilter関数です。
ここで、アクセスしてきたユーザが認証済みかどうかチェックしています。
認証されていない場合、ログイン(login)、ユーザ登録(signup)、ログイン済みチェック(loggedin)以外は実行出来ないようにしています。

  • $this->Auth->user()でログインユーザ情報を取得
  • ログインユーザ情報が取得できなかった場合、usersコントローラの下記アクションを除き、UnauthorizedExceptionをthrowして401エラーを発生させます。
    • login
    • loggedin
    • signup

UsersController.php

ログイン、ログイン済みチェック、ログアウト、ユーザ登録の実装です。

UsersController.php
<?php

App::uses('AppController', 'Controller');
App::uses('BlowfishPasswordHasher', 'Controller/Component/Auth');

class UsersController extends AppController {

    public function login() {
        $user = $this->Auth->user();
        $res = array();
        if ($user) {
            if ($user['username'] === $this->request->data['username']) {
                $res['User'] = $user;
                $res['message'] = "ログイン済みです";
            } else {
                $res['message'] = "別のユーザがログイン済みです";
            }
        } else {
            $this->request->data['User']['username'] = $this->request->data['username'];
            $this->request->data['User']['password'] = $this->request->data['password'];
            if ($this->Auth->login()) {
                $user = $this->Auth->user();
                $res['User'] = $user;
                $res['message'] = "ログイン成功";
            } else {
                $res['message'] = "ユーザ名またはパスワードが違います";
            }
        }
        $this->set(compact('res'));
        $this->set('_serialize', 'res');
    }

    public function loggedIn(){
        $user = $this->Auth->user();
        if ($user) {
            $res['User'] = $user;
            $res['message'] = "ログイン済みです";
            $this->set(compact('res'));
            $this->set('_serialize', 'res');
        }else{
            throw new UnauthorizedException();
        }
    }

    public function logout() {
        $user = $this->Auth->user();
        $res = array();
        if ($user !== null) {
            $this->Auth->logout();
            $res['message'] = "ログアウトしました。";
        } else {
            $res['message'] = "ログインしていません。";
        }
        $this->set(compact('res'));
        $this->set('_serialize', 'res');
    }

    public function signUp() {
        $data = $this->request->data;
        if (isset($data['password'])) {
            $passwordHasher = new BlowfishPasswordHasher();
            $data['password'] = $passwordHasher->hash($data['password']);
        }
        $res = $this->User->save($data);
        if($res){
            unset($res['User']['password']);
            $res['message'] = "登録しました。ログインできます!";
        }else{
            $res['message'] = "登録に失敗しました。";
        }
        $this->set(compact('res'));
        $this->set('_serialize', 'res');
    }

}

login関数

ログイン処理を実行します。結果として、下記の4つのいずれかになります。

  • ログイン成功
  • ログイン失敗(ユーザ名orパスワードが違う)
  • 自分が既にログイン済み
  • 他人がログイン済み

ログイン処理は、

  • $this->request->data['User']['username']にユーザ名、
  • $this->request->data['User']['password']にパスワードを設定し、
  • $this->Auth->login()を実行します。

クライアントに返すJSONの項目として、下記を設定しています。

  • User -> ユーザ情報(id, username, name)
  • message -> 処理結果を表すメッセージ
例(ログイン成功時)
{
    "User": {
        "id": "1",
        "username": "test",
        "name": "テスト"
    },
    "message": "ログイン成功"
}

loggedIn関数

ユーザが既にログイン済みかどうか調べます。

  • $this->Auth->user()でログインユーザ情報を取得し、nullでなければログイン済みと判断します。
  • 未ログインの場合、UnauthorizedExceptionをthrowして401エラーを発生させます。
  • クライアントに返すJSONはlogin関数と同じく、usermessageを設定します。

logout関数

ログアウト処理を実行します。

  • $this->Auth->user()でログイン済みか判断し、ログイン済みなら$this->Auth->logout()でログアウト処理を実行します。
  • 未ログインの場合はログアウト処理は実行せず、JSONにmessageをセットします。

signUp関数

Userモデルを使用してユーザ情報をデータベースに登録します。

  • パスワードは、BlowfishPasswordHasherクラスを使用してハッシュ化した文字列を保存します。
  • 保存が成功した場合JSONにユーザ情報を設定しますが、パスワードは除いておきます(unset($res['User']['password']);)。

User.php

usersテーブルにデータを登録するためのmodelです。

User.php
<?php

App::uses('AppModel', 'Model');

class User extends AppModel {
}

CakePHPのmodelの標準機能以外何も実装していません。
:warning: ちゃんとしたアプリケーションなら、ここに入力値のバリデーション等を実装しなければなりませんが、今回のテーマでは無いので実装しないことにしました。

 動作確認

POSTMANで各APIをテストします。
各APIの実行例を載せておきます。参考にしてやってみましょう!
:warning: ユーザ登録時のパスワードは、siginin実行時にハッシュ化してから保存します。
なので、phpMyAdminでレコード追加したデータではログインに失敗しますのでご注意下さい。
下記、siginupAPIからテストするといいと思います。

URL リクエストJSON レスポンスコード レスポンスJSON
/rest-study/users/signup.json 200 {
 "username": "test2",
 "password": "test2",
 "name": "テスト2"
}
{
 "User": {
  "username": "test2",
  "name": "テスト2",
  "id": "65"
 },
 "message": "登録しました。ログインできます!"
}
/rest-study/users/login.json {
 "username":"test",
 "password":"test"
}
200 {
 "User": {
  "id": "1",
  "username": "test",
  "name": "テスト"
 },
 "message": "ログイン成功"
}
/rest-study/users/login.json {
 "username":"test",
 "password":"hoge"
}
200 {
"message": "ユーザ名またはパスワードが違います"
}
/rest-study/users/logout.json - 200 {
 "message": "ログアウトしました。"
}
/rest-study/users/loggedin.json - 200 {
 "User": {
  "id": "1",
  "username": "test",
  "name": "テスト"
 },
 "message": "ログイン済みです"
}
  • エラー時の例1(未ログイン状態でログインチェック - 401が返却)
URL リクエストJSON レスポンスコード レスポンスJSON
/rest-study/users/loggedin.json - 401 {
 "name": "Unauthorized",
 "message": "Unauthorized",
 "url": "/rest-study/users/loggedIn.json"
}
  • エラー時の例2(未ログイン状態でTODO一覧を取得 - 401が返却)
URL リクエストJSON レスポンスコード レスポンスJSON
/rest-study/users/todo_lists.json - 401 {
 "name": "Unauthorized",
 "message": "Unauthorized",
 "url": "/rest-study/users/todo_lists.json"
}

実装

では、実際に修正してAPIの動作確認をしてみましょう。

  • :white_check_mark: app/Config/routes.php を上記の通り修正。
  • :white_check_mark: app/Controller/AppController.php を上記の通り修正。
  • :white_check_mark: app/Controller/UsersController.php を上記の通り新規作成。
  • :white_check_mark: app/Model/User.php を上記の通り新規作成。
  • :white_check_mark: 動作確認!
  • :white_check_mark: Gitにコミット

GitHubでのdiff表示へのリンク

第5回 Lesson1 サーバーサイド · suzukishouten-study/rest-study@59c29b0

Lesson2へ!

:large_blue_circle: Lesson2 クライアントサイド

Lesson2では、クライアントサイドのプログラムを実装します。

ポイント

ちょっとハマりポイントがあるのでチェックしておきましょう。
サーバとの通信はAjaxであり、非同期です。そのことを抑えておけば大丈夫です!

ブラウザでTODO一覧画面のURLを叩いた後の動作シーケンスについて次に説明します。
起動時に、まずサーバにユーザ情報を取りに行って、取れたらログイン済みと判断します。
(ヘッダに表示するユーザ情報の取得を兼ねています)

正しい動作

うまく動くシーケンスは下図のようになります。

client_flow_good.png

  1. ログイン中かどうかを確認する処理を開始
  2. XHR(XmlHttpRequest)を使用し、サーバと通信します。この時、サーバからのレスポンス受信時に実行するコールバック関数をXHRに教えておきます。
  3. サーバからレスポンスを受信後、XHRは2で指定されたコールバック関数を実行します。
  4. コールバック関数では、サーバからの応答をみて、ユーザ情報(ログイン済み or 未ログインの判断に利用)を記憶します。
  5. ルーティングを有効にします(Backbone.history.start()関数)。
  6. コントローラが実行され、TODO一覧画面表示関数が実行を開始
  7. 4で記憶したユーザ情報を確認
    • ユーザ情報が取得済み(ログイン済み)なら、TODO一覧表示画面を表示
    • ユーザ情報未取得(未ログイン)なら、ログイン画面を表示

うまく動かないパターン

前回までのプログラムでは、上記の5でやっているルーティングの有効化(Backbone.history.start()関数)を起動時にすぐやっています。
このままだと、下記のようなシーケンスになってしまいます。

client_flow_bad.png

  1. ルーティングを有効にします(Backbone.history.start()関数)。
  2. ログイン中かどうかを確認する処理を開始
  3. XHR(XmlHttpRequest)を使用し、サーバと通信します。この時、サーバからのレスポンス受信時に実行するコールバック関数をXHRに教えておきます。
  4. 1でルーティングは既に有効なので、サーバからの応答を受ける前にコントローラが実行され、TODO一覧画面表示関数が実行を開始
  5. サーバからの応答後記録される予定のユーザ情報を確認。
    • しかしまだ応答がないのでここではユーザ情報が未取得。よってログイン画面を表示します。
  6. サーバからレスポンスを受信後、XHRは2で指定されたコールバック関数を実行します。
  7. コールバック関数では、サーバからの応答をみて、ユーザ情報を記憶します。が、既にログイン画面を表示した後です。

上記から、ログイン中であったとしてもTODO一覧画面は表示されません。
こういった、「サーバからの応答が返る前に他の処理が実行されている」という動きを抑えておきましょう。

編集するファイル一覧

編集 file 編集概要
修正 app/View/Layouts/default.ctp テンプレートの追加、修正
修正 app/webroot/js/main.js application.start()のタイミング変更
修正 app/webroot/js/app.js ログインチェック等
修正 app/webroot/js/routers/router.js ルート追加
修正 app/webroot/js/routers/controller.js ログイン関連の画面遷移制御
新規 app/webroot/js/models/user-model.js APIの実行
新規 app/webroot/js/views/login-layout-view.js ログイン画面表示用ビュー
新規 app/webroot/js/views/header-view.js ヘッダ表示用ビュー

default.ctp

テンプレートを追加。

default.ctp
 <title>シンプルTODOアプリ</title>
 </head>
 <body>
+   <!-- ヘッダ -->
+   <div id="header"></div>
    <!-- コンテンツ -->
    <div id="main"></div>
+   
+   <!-- ヘッダのテンプレート -->
+   <script type="text/template" id="header-template">
+   <p>ユーザ:<%- username %>(<%- name %>) <input type="button" id="logout" value="ログアウト"></input></p>
+   <hr>
+   </script>

    <!-- TODO一覧表示のレイアウトテンプレート -->
  〜略〜
    </script>

    <!-- 詳細画面のレイアウトテンプレート -->
    <script type="text/template" id="todo-detail-layout-template">
  〜略〜
    </script>

    <!-- 詳細画面の表示内容テンプレート -->
    <script type="text/template" id="todo-detail-item-template">
  〜略〜
    </script>

+   <!-- ログイン画面テンプレート -->
+   <script type="text/template" id="login-layout-template">
+   <h2>ログイン</h2>
+   <div>
+   <p>ユーザ名 :<input type="text" id="username" placeholder="username" autofocus></input></p>
+   <p>パスワード:<input type="password" id="password" placeholder="password"></input></p>
+   <input type="button" id="login" value="ログイン"></input>
+   </div>
+   <hr>
+   <h2>ユーザ登録</h2>
+   <div>
+   <p>ユーザ名 :<input type="text" id="signup-username" placeholder="username"></input></p>
+   <p>氏名   :<input type="text" id="signup-name" placeholder="name"></input></p>
+   <p>パスワード:<input type="password" id="signup-password" placeholder="password"></input></p>
+   <input type="button" id="signup" value="登録"></input>
+   </div>
+   </script>
+
    <!-- require -->
    <script type="text/javascript" src="js/require-config.js"></script>
    <script type="text/javascript" src="js/lib/require.js" data-main="main.js"></script>

 </body>
 </html>

下記を追加。

  • ヘッダ情報を表示するRegionをするdivタグ
  • ヘッダ情報表示ビュー用テンプレート
    • ユーザ名、氏名、ログアウトボタンを配置
  • ログイン画面用テンプレート
    • ログイン用要素を配置
      • ユーザ名
      • パスワード
      • ログインボタン
    • ユーザ登録用要素を配置
      • ユーザ名
      • 氏名
      • パスワード
      • 登録ボタン

main.js

main.js
    require(['app'], function(Application){
        console.log('run main2');
        window.application = new Application();
-       window.application.start();
-       console.log('app start');
    });
 });

application.start()を削除。
これは、先程述べたハマりポイントの対応です。
後述するapplication(app.js)内でのログインチェック(ログイン中かどうか確認)の処理が完了してからapplication.start()を実行するためです。application.start()を削除した場合(期待する動作)と、削除しなかった場合の動作の違いを、今度はコードをベースに説明します。

application.start()を削除した場合(期待する動作)

  • /rest-study/にアクセス
  • main.js実行。 applicaionオブジェクトは生成するのみ
  • app.jsのinitialize関数内でログインチェックを実行(loggedinAPIのAjax通信開始)
  • loggedinAPIからレスポンスを受取り、
    • ログイン中ならユーザ情報保持
    • 未ログインならユーザ情報クリア
    • application.start()を実行
      • onStart()が実行され、Backbone.history.start()が実行、ルーティングが開始される。
      • controllerが実行され、
        • ユーザ情報を保持している場合、TODO一覧画面に遷移
        • ユーザ情報がクリアされている場合、Backbone.history.navigate()関数を実行してログイン画面(/#login)にルーティング

application.start()を削除しなかった場合

  • /rest-study/にアクセス
  • main.js実行。 applicaion.start()を実行
  • app.jsのinitialize関数内でログインチェックを実行(loggedinAPIのAjax通信開始)
  • loggedinAPIからレスポンスを受取る前に、app.jsのonStart()が実行され、Backbone.history.start()が実行、ルーティングが開始される。
    • controllerが実行され、
      • loggedinAPIのAjax通信のレスポンスをまだ受け取っていないため、ユーザ情報がクリアされている状態(初期値)なので、Backbone.history.navigate()関数を実行してログイン画面(/#login)にルーティング(実際にログインされているか否かにかかわらず)。
  • loggedinAPIからレスポンスを受取り、下記のように設定する
    • ログイン中ならユーザ情報保持
    • 未ログインならユーザ情報クリア
    • が、既にcontrollerの処理は終わっているので、ログイン画面が表示される...

app.js

ログイン関連の各処理を追加。

app.js
 define(function(require){
    console.log('run app');
    var Router = require('routers/router');
+   var UserModel = require('models/user-model');
    var Application = Marionette.Application.extend({
        initialize : function(){
            console.log('app.initialize');
+           // Ajaxのエラーの共通ハンドラを設定
+           $(document).ajaxError(function ( e, xhr, options , message ) {
+               window.application.ajaxErrorHandler( e, xhr, options , message );
+           });
            new Router();
+           //ログインユーザ情報をサーバから取得
+           this.loginUser = new UserModel();
+           this.getLoginUser();
        },

        onStart : function(){
            Backbone.history.start();
        },

        regions : {
+           headerRegion : '#header',
            mainRegion : '#main'
-       }
+       },
+       
+       //ログインユーザ情報格納用
+       loginUser : null,
+
+       //ログインユーザ情報取得
+       getLoginUser : function(){
+           this.loginUser.getLoginUser(
+               this.onLoggedIn,
+               this.onNotLoggedIn
+           );
+       },
+       
+       //ログインユーザ情報取得:ログイン済みの場合
+       onLoggedIn : function(){
+           window.application.start(); // applicaiton.start()はログインチェックの後
+       },
+       
+       //ログインユーザ情報取得:未ログインの場合
+       onNotLoggedIn : function(){
+           window.application.clearLoginUser();    //ログイン情報をクリアしておく
+           window.application.start(); // applicaiton.start()はログインチェックの後
+       },
+       
+       //ログイン済みか判定
+       isLoggedIn : function(){
+           return this.loginUser.isLoggedIn();
+       },
+       
+       //ログインユーザ情報のクリア(ログアウト時)
+       clearLoginUser : function(){
+           this.loginUser.clear();
+       },
+
+       // ajaxのエラーを全てここでハンドリング
+       ajaxErrorHandler : function(e, xhr, options , message){
+           if( xhr.status === 401 ){
+               this.clearLoginUser();
+               // 未認証の場合ログイン画面に飛ばす
+               Backbone.history.navigate('#login', {trigger : true, replace : true});
+           }else if(xhr.status >= 400 && xhr.status < 500){
+               //ClientErrorの場合はメッセージ表示
+               alert(message);
+           }else if(xhr.status >= 500 && xhr.status < 600){
+               //ServerErrorの場合はメッセージ表示
+               alert(message);
+           }
+       },

    });
    return Application;

主な修正点。

追加require

  • ログイン関連のAPIを実行する為、models/user-model.jsをrequire。

initialize関数

  • Ajaxのエラーの共通ハンドラ(ajaxErrorHandler関数)を設定
  • requireしたuser-modelを生成し、ログインチェック(loggedinAPI)を実行。
    • APIはgetLoginUser関数内で実行
      • ログイン済みの場合、onLoggedIn関数が実行される。
      • 未ログインの場合、onNotLoggedIn関数が実行される。 #### ajaxErrorHandler

全てのAjax通信のエラー時に実行されるハンドラ関数。

  • http statusが401(Unauthorized)(未ログイン)の場合、Backbone.history.navigate()関数を実行してログイン画面(/#login)にルーティング
  • それ以外のhttp statusの場合はalert表示。

router.js

router.js
〜略〜
        //ルーティング設定
        appRoutes : {
+           'login'             : 'login',
            ''                  : 'todoLists',
            'todo-lists'        : 'todoLists',
            'todo-lists/:id'    : 'todoDetail'
        },
〜略〜

ログイン画面へのルートを追加。

controller.js

controller.js
 //controller
 console.log('load controller');
 define(function() {
    console.log('run controller');
    var TodoController = Marionette.Controller.extend({
+       login : function(){
+           //ログイン画面
+           this.nextView('views/login-layout-view', null, true);
+       },

        todoLists : function() {
            //Todoレイアウト用ビューにルーティング
            this.nextView('views/todo-layout-view');
        },

        todoDetail : function(id) {
            this.nextView('views/todo-detail-layout-view', {modelId : id});
        },

-       nextView : function(viewPath, option) {
+       nextView : function(viewPath, option, tryShowLoginScreen) {
+           if(window.application.isLoggedIn()){
+               //ログイン済み
+               if(tryShowLoginScreen){
+                   //ログイン済みなのにログイン画面に遷移しようとしている場合は
+                   //TODOリスト画面にルーティング
+                   Backbone.history.navigate('#todo-lists', {trigger: true, replace: true});
+                   return;
+               }
+           }else{
+               //未ログイン
+               if(!tryShowLoginScreen){
+                   //未ログイン状態でログイン画面以外に遷移しようとしている場合は
+                   //ログイン画面にルーティング
+                   Backbone.history.navigate('#login', {trigger: true, replace: true});
+                   return;
+               }
+           }
+           //ヘッダ表示
+           this.showHeaderRegion(tryShowLoginScreen);
+           //コンテンツ表示
            require([viewPath], function(View){
                window.application.mainRegion.show(new View(option));
            });
        },
-
+       
+       showHeaderRegion : function(tryShowLoginScreen){
+           if(tryShowLoginScreen){
+               //ログイン画面遷移時はヘッダ非表示
+               window.application.headerRegion.empty();
+           }else if(!window.application.headerRegion.hasView()){
+               //ログイン画面以外遷移時、かつヘッダ未表示の場合ヘッダ表示
+               require(['views/header-view'], function(View){
+                   window.application.headerRegion.show(new View({
+                       model : window.application.loginUser
+                   }));
+               });
+           }
+       }
+       
    });
    return TodoController;
 });

login関数追加

  • nextView関数に追加したtryShowLoginScreenパラメータ(後述)をtrueで実行

nextView関数

  • nextView関数で、ログイン時とみログイン時の判断ロジック追加
    • ロジックはソース中のコメント通り。下記の2パターンの動作を制御している(ログイン画面に遷移しようとしているかどうかはtryShowLoginScreenパラメータで判定)。
      • 既にログインしているのにログイン画面に遷移しようとした場合はTODO一覧画面にルーティング
      • 未ログイン状態でTODO一覧やTODO詳細画面(ログイン画面以外)に遷移しようとした場合はログイン画面にルーティング
  • コンテンツ(TODO一覧や詳細画面)のviewを表示する前に、ヘッダを表示。

showHeaderRegion関数

  • ログイン画面表示時はヘッダ用viewを非表示にする。
  • コンテンツ画面表示時は、ヘッダ用viewを表示する。

user-model.js

追加したAPIを呼び出す。

user-model.js
//ログイン用モデル
define(function() {
    var LoginModel = Backbone.Model.extend({

        parse : function(response) {
            if(response.message){
                this.loginMessage = response.message;
            }
            return response.User;
        },

        loginMessage : null,

        //ログイン済みかどうか判定
        isLoggedIn : function(){
            return this.get('id') ? true : false;
        },

        //ログイン中のユーザ情報取得
        getLoginUser : function(onLoggedIn, onNotLoggedIn){
            this.urlRoot = '/rest-study/users/loggedin';
            this.fetch(
                {
                    wait : true,
                    success : function(){
                        onLoggedIn();
                    },
                    error : function(){
                        onNotLoggedIn();
                    },
                }
            );
        },

        //ログインする
        login : function(username, password, onLoginSuccess, onLoginError){
            this.urlRoot = '/rest-study/users/login';
            this.save(
                {
                    username : username, 
                    password : password
                }, {
                    success : function(model){
                        if(model.get('id')){
                            onLoginSuccess(model.loginMessage);
                        }else{
                            onLoginError(model.loginMessage);
                        }
                    },
                }
            );
        },

        //ログアウトする
        logout : function(onLogoutSuccess){
            this.urlRoot = '/rest-study/users/logout';
            this.save(
                {}, 
                {
                    success : function(model){
                        onLogoutSuccess(model.loginMessage);
                    },
                }
            );
        },

        //登録(サインアップ)する
        signup : function(username, password, name, onSignUpSuccess, onSignUpError){
            this.urlRoot = '/rest-study/users/signup';
            this.save(
                {
                    username : username, 
                    password : password,
                    name     : name
                }, {
                    success : function(model){
                        if(model.get('id')){
                            onSignUpSuccess(model.loginMessage);
                        }else{
                            onSignUpError(model.loginMessage);
                        }
                    },
                }
            );
        },
    });
    return LoginModel;
});

parse関数

サーバからのレスポンスを下記の通り処理しています。

  • messageloginMessage変数に保持。
  • User(ユーザ情報)を取り出してreturn。

各APIの実行方法

  • GETメソッドで実行するもの(loggedinAPI)はfetch関数を実行。
  • POSTメソッドで実行するもの(loginlogoutsignupの各APIはsave関数を実行。
  • 各APIのURLは、各関数実行時にurlRoot変数を変更することで指定。
  • APIからのレスポンス受取り後は、view側の関数をコールバックすることでviewの処理を実行。
  • エラー時は、app.jsで設定したajaxErrorHandler関数が実行されるので、ここでは何もしない。

login-layout-view.js

Marionette.LayoutViewを継承して実装。

login-layout-view.js
//ログイン画面用レイアウトビュー
define(function(require) {
    var LoginModel = require('models/user-model');

    var LoginLayoutView = Marionette.LayoutView.extend({
        //テンプレート
        template : '#login-layout-template',

        //UIパーツ
        ui : {
            username       : '#username',           // ログイン時のユーザ名
            password       : '#password',           // ログイン時のパスワード
            loginButton    : '#login',              // ログインボタン
            signupUsername : '#signup-username',    // 登録時のユーザ名
            signupPassword : '#signup-password',    // 登録時のパスワード
            name           : '#signup-name',        // 登録時の氏名
            signupButton   : '#signup'              // 登録ボタン
        },

        //イベント
        events : {
            //ログインボタンクリック時
            'click @ui.loginButton' : 'onLoginClick',
            //登録ボタンクリック時
            'click @ui.signupButton' : 'onsignupClick',
        },

        //ログインボタンクリック時
        onLoginClick : function(){
            //テキストボックスから文字を取得
            var username = this.ui.username.val();    //ユーザ名
            var password = this.ui.password.val();    //パスワード
            window.application.loginUser.login(
                username,
                password,
                this.onLoginSuccess,
                this.onLoginError);
        },

        //ログイン処理成功時のコールバック
        onLoginSuccess : function(message){
            Backbone.history.navigate('todo-lists', {trigger: true, replace: true});
            console.log(message);
        },

        //ログイン処理失敗時のコールバック
        onLoginError : function(message){
            alert(message);
        },

        //登録ボタンクリック時
        onsignupClick : function(){
            //テキストボックスから文字を取得
            var username = this.ui.signupUsername.val();    //ユーザ名
            var password = this.ui.signupPassword.val();    //パスワード
            var name = this.ui.name.val();                  //氏名
            var userModel = new LoginModel();
            userModel.signup(
                    username,
                    password,
                    name,
                    this.onsignupSuccess,
                    this.onsignupError);
        },

        //登録成功時のコールバック
        onsignupSuccess : function(message){
            alert(message);
        },

        //登録失敗時のコールバック
        onsignupError : function(message){
            alert(message);
        },

    });
    return LoginLayoutView;
});

オーソドックスなビューの実装です。

  • ui変数で各入力項目やボタンのhtmlエレメントを設定
  • event変数でボタンのClickイベントとハンドラ関数を紐付け
  • 各Clickイベントハンドラ関数ではuser-modelを使用してAPIを実行
    • ログイン時は、app.js内に保持しているuser-modelを使用(返ってきたユーザ情報をそのままapp.js内に保持するため)。
    • ユーザ登録時は、user-modelを新しく生成して使用(返ってきたユーザ情報は使用しないため)
  • APIからのレスポンス時にコールバック実行、画面遷移やメッセージ表示を行う
    • ログイン成功時のコールバック関数内で、TODO一覧画面に遷移させている

header-view.js

Marionette.ItemViewを継承して実装。

header-view.js
//ヘッダ用ビュー
define(function(require){
    var UserModel = require('models/user-model');

    var HeaderView = Marionette.ItemView.extend({

        //テンプレート
        template : '#header-template',

        ui : {
            logoutButton : '#logout',
        },

        //DOMイベントハンドラ設定
        events : {
            //ログアウトボタンクリック時
            'click @ui.logoutButton' : 'onLogoutClick',
        },

        onLogoutClick : function(){
            var userModel = new UserModel();
            userModel.logout(this.onLogoutSuccess);
        },

        onLogoutSuccess : function(message){
            window.application.clearLoginUser();
            Backbone.history.navigate('#login', {trigger : true, replace : true});
            console.log(message);
        },

    });
    return HeaderView;
});

これもオーソドックスなビューの実装です。

  • ui変数でログアウトボタンを設定
  • event変数でonLogoutClick関数を紐付け
  • onLogoutClick関数ではuser-modelを使用してAPIを実行
  • APIからのレスポンス時にonLogoutSuccess関数実行、ログイン画面に遷移

実装

では、実際に修正して画面の動作確認をしてみましょう。

  • :white_check_mark: app/View/Layouts/default.ctpを上記の通り修正。
  • :white_check_mark: app/webroot/js/main.jsを上記の通り修正。
  • :white_check_mark: app/webroot/js/app.jsを上記の通り修正。
  • :white_check_mark: app/webroot/js/routers/router.jsを上記の通り修正。
  • :white_check_mark: app/webroot/js/routers/controller.jsを上記の通り修正。
  • :white_check_mark: app/webroot/js/models/user-model.jsを上記の通り新規作成。
  • :white_check_mark: app/webroot/js/views/login-layout-view.jsを上記の通り新規作成。
  • :white_check_mark: app/webroot/js/views/header-view.jsを上記の通り新規作成。
  • :white_check_mark: 動作確認!
  • :white_check_mark: Gitにコミット

GitHubでのdiff表示へのリンク

第5回 Lesson2 クライアントサイド · suzukishouten-study/rest-study@d4ae8a4

以上です!

次回予告

次回テーマは、CakePHPでデータ操作〜担当者アサイン機能の実装です。
今回でユーザが識別できるように成ったので、各ユーザにTODOをアサインする機能などをつけていきます。
またぜひご参加ください!

コメント/フィードバックお待ちしております。

参加者の方も、そうでない方もお気づきの点があればお願い致します。