Ajaxでセッション(状態)管理する方法
2017/08/30
前回(低コストでできるだけセキュアなAjaxパスワード認証を考える)の続きです。Ajaxによるパスワード認証をコストをできる限りかけずに実装するという取り組み(プロジェクト)を紹介しています。
おさらいですが、まずパスワード認証を実装するにあたって、パスワードの安全な受け渡し、つまり暗号化をどうするかという問題がありました。これについては自前で通信の暗号化をはかるより、一定の信頼性の得られるSSL、それも徐々に浸透しつつある格安SSLを利用して導入するという結論になりました。格安SSLの多くは、信頼性の低いドメイン認証ですが、小規模でECサイトのような取引などの生じないサイトであれば十分と考えられます。なによりも、SSLの導入によって、中間者攻撃によるWebサイトの改ざんと成りすましを防ぐことができ、単純な暗号化通信では対策できない問題が解決されます。また、ソースが丸見えになるクライアントサイドで、暗号化スクリプトをあえて実行する必要がなくなるので、脆弱性が緩和されます。
次にCSRFやXSSなどのスクリプティング対策ですが、トークンを使った対策やユーザーから受け取った(信頼するべきでない)データの無害化するといった基本的な対策が、Ajaxを用いた認証でもそのまま使えること、また、Ajax自体にクロスサイトの攻撃を抑制する機構がそなわっていることを確認しました。 その他にパスワードの保管方法について、これは徳丸本に書かれていた方法そのものですが、ソルトとストレッチングを用いて暗号化して保存することとしました。平文での保管は言うまでもないですが、パスワードを単純にハッシュ化しただけでは脆弱であるからです。
そして、今回の焦点となるのが、Ajaxによるパスワード認証の実装です。上記の三点のセキュリティ課題を具体的にどのように実装に落とし込むかという点と、Ajaxでどのようにサーバー・クライアント間でデータのやり取りを行い、また非同期のページ遷移をどう行っていくかを解説します。
実はすでに実装例を作成済みであるので、先にリンクを張って置きます。ドメインがこのブログのものと違いますが、それはこのサイトのレンタルサーバーの共有SSLを使用しているためです。
このサンプルページでは、ユーザーIDが「test_user1」、メールアドレスが「test_user1@test.com」、パスワードが「testUSER1」のアカウントを登録しているので試しにログインしてみてください。
ログイン認証の手順
- ログイン/登録ページ「https://digick-wiz-code.ssl-lolipop.jp/dev/test/ajax-login/」へアクセスする。
- 「ログインする」ボタンを押して次の画面へ
- Eメールとパスワードのそれぞれのフォームに、test_user1@test.com、testUSER1と入力して「サインイン」をクリックする。
- するとタイトル部分が「こんにちは! test_user1さん!」というメッセージに変わり、メニューが出現する。
- 現在(11/4時点)ではログアウト機能のみ実装。本来サンプルページなので「チャット」などは未実装のまま。「設定」画面はUIのみうわべだけ実装済み。メールアドレスの変更などの実際の機能は今後実装する予定。
- ログイン状態でリロードしても、最初のログイン/登録ページは表示されずユーザーのホーム画面(メニュー画面)が表示される。
- メニュー画面で「ログアウトする」をクリックすると、ログアウトの確認ダイアログが表示される。ここで「ログアウトする」をクリックするとサーバー側でセッションデータの破棄とセッションの終了が実行され、クライアント側のクッキーも破棄され、最初の画面にリダイレクトされる。
さらに、これらのデモで使用されているソースコードを以下のページで公開しています。ログイン機能とパスワード認証まわりの実装はほぼ9割方完成しています。
このプロジェクトはサーバープログラミング言語にPHPを使用しています。そのため、ソースコードの解説はPHPコードに限定されますのでご了承ください。
Webセキュリティに配慮した実装
SSLの実装に関しては、HTTPS環境が用意できれば、サイト管理側はHTTPS用のリンクを用意するだけなので、特に気をつけることはないと思われます。しかし、ユーザーがHTTPSではなく、HTTPで接続してきた場合を想定する必要があるでしょう。この対処法はWeb上で多く取り上げられているので詳述しませんが、.htaccessを設定するかサーバー側で判別して対処します。なお、自分は.htaccessを編集して、HTTP接続できたユーザーをHTTPSにリダイレクトするようにしています。
次にCSRF対策についてですが、主要な対策は正式なサイトからのリクエストであることを判別するためトークンによる確認方法をとります。あらかじめサイトに予測困難な乱数のトークンを仕込んでおき、そのトークンを送信してきたユーザーのリクエストにしか応答しないというものです。徳丸本によると、トークンはリクエスト毎に異なるものを用意する必要はないとのことなので、このプロジェクトではCSRF対策トークンにセッションIDを使用しています。実際にソースコードを見てみますと、
$login_token = $_POST['login-token']; session_start(); /* トークンが異なっていたら強制終了 */ if (session_id() !== $login_token) { $response['message'] = 'illicit access'; echo json_safe_encode($response); die(); }
このようになっていて、$login_tokenはリクエストデータから取得し、それをセッションIDと突き合せます。照合が失敗したら不正なアクセスと見なして接続を切ります。また、jQueryやPrototype.jsなどのAjaxライブラリがXMLHttpRequestを行うとき、HTTP_X_REQUESTED_WITHリクエストヘッダにXMLHttpRequestという値を付けて送ってくるので、サーバー側でそれをチェックすることでクロスドメインのリクエストでないことを確認します。
/* Ajax以外のリクエストだったら強制終了 */ if (@$_SERVER['HTTP_X_REQUESTED_WITH'] !== 'XMLHttpRequest') { $response['message'] = 'not XHR'; echo json_safe_encode($response); die(); }
ただし、これらのリクエストヘッダを攻撃者が偽装・改ざんすることが可能だそうなので、あくまで保険的な位置づけとなりますが。
XSS対策の場合、様々な攻撃手段があるため対策は多様ですが、ここでは基本となる対策を重点的に行います。
- ブラウザへ出力するデータはhtmlspecialchars()によって無害化する。
- ただし、htmlspecialchars()はむやみに使わない。パスワードなどデータベースに保存するなら入力値はそのまま保存。このメソッドを使用するのは基本的にブラウザに出力する時のみ。
- ユーザーからの入力データはバリデーションを行う
- レスポンスヘッダで文字エンコーディング指定(UTF-8)
- AjaxレスポンスをHTMLとしてJavaScriptで流し込むときは、JavaScriptでスクリプト除去を行う。Prototype.jsならばString.stripScripts()、jQueryならばjQuery.parseHTML()に通す。
その他、セキュリティ上、攻撃の標的にされやすいセッション管理機構について対策します。
Webアプリケーションでは認証の記録だけでなく、ユーザーの識別にセッション機能を使用します。セッションが必要なのはHTTPが状態を引き継がない仕様だからです。単一ページのアプリならば、パスワード認証さえすませればセッション機能などむしろ不要です。しかし、ほぼすべてのアプリは複数のページから構成されています。ユーザーが異なるページを訪れたとき、新たなページではそのユーザーについて何の情報も持っていません。セッション管理機構はサーバー側とクライアント側に同じ識別データを保管し、それによってユーザーを識別します。そして、ユーザー(クライアント)側にデータを残すため、大抵のWebアプリケーションはCookieを使用します。URLに直接識別データを埋め込む方法がありますが、こちらはセキュリティリスクが問題になっているので、現実的な方法ではありません。ただし、Cookieは昔から脆弱性が指摘され、危険視されてきたブラウザ機能です。また、セッション機能自体もセッションハイジャックやフィクセーションといった攻撃にさらされやすく、双方に厳重な対策が求められています。 このプロジェクトでは、主に徳丸本を参考にしたセッション・セキュリティ対策を行います。ほんとうはセッションIDの生成方法の改善までやりたかったのですが、自分のサイトが設定自由度の低いレンタルサーバーのため断念しました。その他に実施するセキュリティ対策は次のようになります。
- セッションIDをURL埋め込みにしない。php.iniでsession.use_trans_sid = 0とする。
- CookieにHttpOnly属性とSecure属性を付けて送信する。
- ログインやユーザー登録後にsession_regenerate_id(TRUE)関数でセッションIDを振りなおす。
session_regenerate_id(TRUE)関数はログインとユーザー登録に関連するregister.phpとlogin.phpで使用しています。HttpOnlyとSecure属性はPHPファイルの初めの部分で、session_start関数が呼ばれる前にsession_set_cookie_params関数で指定しておきます。環境が許すならphp.iniに設定する方法もあるでしょう。
セッションの有効期限についてはブラウザを閉じるまでとするのが主流とのことです。(参照先)セッションのクッキーを設定する場合のベストプラクティス
それにならいますと、次のコードは
session_set_cookie_params(0, '/', '', TRUE, TRUE);
となります。
//HttpOnlyとSecure属性はTRUEに設定 session_set_cookie_params(60 * 60, '/', '', TRUE, TRUE);
ここでAjaxを使用するページにセッションが必要か、という疑問があります。なぜなら、Ajaxアプリケーションはページ遷移のない単一のページで動作するからです。また、セッションでユーザーを識別できる点はCookieを使っても同じです。確かにセッション機構はAjaxログイン認証を実現するのになくてもよいものです。しかし、セッションデータは基本的に外部からアクセスできず、改ざんなどの心配がないためCookieよりセキュリティの面で勝ります。その上、データベースにアクセスする負荷を軽減したり、セッションIDをCSRF対策のトークン代わりにできるなど利点が多いです。ですので、このプロジェクトはセッションを利用しています。
パスワードの保管方法は、ソルトとストレッチングを使ったハッシュ化を徳丸本の解説に沿ったやり方で実装しています。PHP5.5以上では、手軽にして強力なパスワードハッシュ化関数であるpassword_hash()とpassword_verify()が追加されているので、そちらを使用した方がより安全な対策になると思います。ただし、自分のローカル/リモート環境のPHPがバージョン5.3であるため、それらの関数が使えません。
ここまでプロジェクトのセキュリティ対策を簡単に追ってきました。次に本題に移りたいと思います。Ajaxによる完結したパスワード認証ページの作り方です。
Ajaxによるアプリケーションの実装方法
Ajaxを用いたWebアプリケーションの例にTwitterがあります。モバイルアプリではなくPCでTwitterを閲覧すると、画面遷移のときに画面が真っ白になることなく、動的にスムーズに入れ替わることに気づくかと思います。Firebugなどの開発ツールを使用すれば、この画面遷移が通常のHTTPリクエストではなく、XMLHttpRequestを使っていることがわかるはずです。ちなみにTwitterの認証はもっと厳重な方式を用いていると思いますが、ここではセキュリティの話は置いておき、実際にTwitterのようなAjaxアプリケーションを実現させる方法を考えてみたいと思います。
このプロジェクトでは、最初の画面(ログインかユーザー登録を求める画面)からパスワード認証後に会員ページへ飛ぶ工程までを実装します。Ajax通信でデータのやり取りを完結させるため、画面遷移はJavaScriptで制御しなければなりません。サーバー側で処理するならばページごとに○△□.phpというファイルを用意すればいいのですが、JavaScriptで制御する場合、ずっと同じページにいるわけですからそういうわけにいきません。
では、具体的にどうするのかというと、従来サーバー側でページ単位でファイルに分けていたところを、JavaScriptでそれぞれ「状態」として保持させます。たとえば、最初の画面(ログインかユーザー登録を求める画面)を「状態A」として、ログインのフォームが表示される部分を「状態B」とします。そして、状態ごとにJavaScriptで適切に画面構築をしていくわけです。しかし、すべてJavaScriptで動的にHTMLを生成するのは危険です。HTML部分はやはりサーバーからロードしたデータを使用します。
でも、ある画面から他の画面に遷移するたびに、Ajax通信を使ってHTML断片などのデータを引っ張ってくるのではサーバーに負荷がかかります。そこで、ロードするHTMLを状態ごとではなく、ある程度まとめたものとしてロードするようにします。たとえば、「最初の画面」に使うHTMLと、ログインフォーム画面のHTML、そしてユーザー登録フォーム画面のHTMLをセットにしたものをサーバーで用意します。それとは別に「会員メニュー画面」や「ログアウト画面」など認証後の画面をまとめたHTMLを用意します。それによって、Ajax通信でHTMLをロードする回数は2回で済むようになります。
<div id="content" class="panel-body" data-authentication-status="unauthenticated"> <h1 id="title" class="text-center">Ajaxログイン認証</h1> <div id="auth" class="text-center hidden"> <button id="register-button" type="button" class="btn btn-primary">登録する</button> <button id="login-button" type="button" class="btn btn-default">ログインする</button> </div> <div id="return-home-button" class="hidden"><button type="button" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-home"></span></button></div> <div id="validation" class="text-danger bg-danger hidden"></div> <div id="register" class="hidden"> <form class="form-horizontal"> <fieldset> <div class="form-group form-group-xs"> <label for="user-id" class="col-xs-offset-1 col-xs-4 control-label">登録ユーザー名</label> <div class="col-xs-4"> <input type="text" class="form-control user-id" name="user-id" placeholder="登録ユーザー名"> </div> </div> <div class="form-group form-group-xs"> <label for="email" class="col-xs-offset-1 col-xs-4 control-label">登録Eメール</label> <div class="col-xs-4"> <input type="email" class="form-control email" name="email" placeholder="登録Eメール"> </div> </div> <div class="form-group form-group-xs"> <label for="password" class="col-xs-offset-1 col-xs-4 control-label">登録パスワード</label> <div class="col-xs-4"> <input type="password" class="form-control password" name="password" placeholder="登録パスワード"> </div> </div> <input id="regi-token" name="regi-token" value="<?=h($token)?>" type="hidden"> <div class="form-group form-group-xs"> <div class="col-xs-offset-5 col-xs-4"> <button type="submit" class="btn btn-primary btn-sm">登録する</button> </div> </div> </fieldset> </form> </div> <div id="login" class="hidden"> <div class="row"> <div class="col-xs-offset-4 col-xs-4"> <form> <fieldset> <div class="form-group form-group-xs"> <label for="email">Eメール</label> <input type="email" class="form-control email" name="email" placeholder="Eメール"> </div> <div class="form-group form-group-xs"> <label for="password">パスワード</label> <input type="password" class="form-control password" name="password" placeholder="パスワード"> </div> <input id="login-token" name="login-token" value="<?=h($token)?>" type="hidden"> <button type="submit" class="btn btn-primary btn-sm">サインイン</button> </fieldset> </form> </div> </div> </div> <div id="credit" class="text-center"> <address>Copyright © WIZARD-CODE 2015</address> </div> </div>
上のコードは上記の「Ajax認証デモページ」で使用されているHTMLデータで、認証前に使用するすべて画面をひとつにまとめたものです。これは、「Ajax認証デモページ」を最初に訪れたとき、ロードされます。しかし、これを実際に表示すると、「最初の画面」と「ログイン画面」と「ユーザー登録画面」が縦に並んだおかしな画面になります。ただし、本当のところはコンポーネント単位で要素に「hidden」というクラス名が記述してあり、最初はすべて非表示で始まります。ですから、ページロードが完了すると、JavaScriptで「最初の画面」コンポーネントだけを表示させます。
そして、各手続きが済むごとに、表示中のコンポーネントを消して、次の(下の)コンポーネントを表示するわけです。また、前述のようにログイン認証前は、認証後に表示するHTMLデータセットがロードされないよう分けているので、セキュリティに配慮されています。
各画面をコンポーネントと呼ぶことにします。コンポーネントは前述の「状態」にあたります。この「Ajax認証デモページ」でもそうですが、これらの各状態をJavaScriptで適切に管理していくことが、Ajax認証システムの肝になってきます。
では、状態を管理するとはどういうことでしょうか。UMLモデリング理論にステートマシン図という概念があります。Webアプリケーションの設計をする上で、ひとつのモデルになる考え方です。
以前JavaScriptでステートマシンを実装するためのソースコードを紹介したことがあります。このプロジェクトでは、そのソースコードを機能追加した上でステートマシンの実行に使用しています。
⇒JavaScriptでステートマシンを実行するスクリプト FSM-0.6.js
より多機能なJavaScript ステートマシン・ライブラリ Async-FSMのソースコードをGitHubで公開しました。導入方法や使い方の説明も同ページに記載しています。
Async-FSM – Finite StateMachine JavaScript Library
このソースコードの解説までしようと長くなるので省きますが、JavaScriptで各状態を制御するシステムを作るには、こうしたライブラリなりフレームワークが必要になってくると思われます。
ステートマシン図はそれぞれ機能を持った状態(State)とそれらを結ぶ遷移(Transition)で記述され、状態から状態への遷移は、トリガ(Trigger)というイベント(事象)をきっかけとします。FSM-0.6.jsはそれらStateとTransitionをオブジェクト(実体)として扱えるようにしており、、またTriggerをメソッドとして実装し、ステートマシン図の設計通りにWebアプリケーションを動かせるよう工夫しています。基本的な書き方は次のようにします。
/* ステートマシンの作成 */ var fsm = new FSM('my-fsm'); //状態オブジェクトを作成 var firstState = new State('first-state', { entryAction: function () { console.log('Enter the first state!!'); }, doActivity: function () { console.log('Do something!'); }, exitAction: function () { console.log('Exit the first state!!'); } }); //ステートマシンに状態オブジェクトを登録 fsm.addState(firstState); //最初の遷移オブジェクトを作成 var firstTransit = new Transition('first-transit', null, 'first-state'); //ステートマシンに遷移オブジェクトを登録 fsm.addTransition(firstTransit); //ステートマシンの起動 fsm.start();
このプロジェクトのソースコードの詳細は前述のリンクにて確認できます。SyntaxHilighterを全コードに適用しているため、少し重いかもしれません。ひとまずAjaxでアプリケーションを構築するのに、ステートマシン図を念頭に置いた実装方法を説明しました。記事が長くなったので、具体的なソースコードの解説等はまたの機会にいたします。