既存のPHPサイトにNode.js+Socket.ioチャットボットを追加しセッションを共有する

既存のPHPサイトにチャットボットを追加しようという話があるのだが、そのサイトにはユーザ認証機能があり、チャットボットに関しても認証済みのユーザしかアクセスさせたくないという要件がある。

PHPサイトはCMSを利用しているため、こちらにはほとんど手を入れずに実装したいと考えているので、チャットボットとの会話インターフェースはPHPサイトにiframeを埋め込むこととし、iframeでロードするページはNode.jsから配信、Node.jsで配信されたページ内でSocket.ioを使ったチャットを行うという構成を考えている。

ここで問題になるのが、PHPとNode.js+Socket.io間でのセッションの共有をどうするかということ。それぞれ個別に認証機能を持つというのはあまりに利便性が悪いため、PHPで認証したユーザをNode.js側も認識できるようにしたい。

## セッション共有の戦略
上記要件は一旦置いておいて、PHPとNode.jpでセッション共有する戦略を調べてみた。

### セッションストアを共有する
参考: https://simplapi.wordpress.com/2012/04/11/php-nodejs-session-share-memcache/

PHPがセッションストア（ここではmemcache）に保存したセッション情報を、Node.jsが参照するという方法。PHPがユーザを認証してセッション情報をmemcacheに保存し、Node.jsはユーザから送信されたCookieからセッションIDを取得し、memcacheからセッション情報を取得するというもの。

一つ問題となるのは、PHPがセッション情報を独自の形式でシリアライズし、セッションストアに保存すること。例えば以下のような形。

```
user_id|s:1:"1";password|s:0:"";firstname|s:7:"Charles";
```

Node.jsでこれをそのまま扱うのは不便なので、上記サイトではPHPのセッションハンドラを改良し、セッションをJSON形式で保存することで、Node.jsから扱いやすいようにしている。

### PHPにNode.jsからアクセスするためのAPIを用意する
参考: http://www.slideshare.net/takyam1213/php-meets-nodejs

セッションストアを共有するのではなく、PHPにAPIを用意して、Node.jsがAPIからセッション情報やユーザ情報を取得するという方法。Node.jsはセッションIDをWebsocketハンドシェイク時にCookieから取得し、そのCookieをHTTPヘッダにつけてAPIにアクセスすることで、PHPから見ると認証済みユーザからのアクセスとなる。

上記以外に、こちらの議論も参考になった。
https://groups.google.com/forum/#!topic/nodejs_jp/gU2347-33PQ

## セッション共有を実装してみる
PHPサイトはCMSを利用しており、APIの追加開発等はしたくなかったので、セッションストアを共有する戦略でいこうと思う。

### PHPの設定
セッションストアとしてRedisを使用するようにしたいので、例えば`php.ini`に以下の設定をする。

```php.ini
extension = redis.so
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379"
```

上記参考記事で紹介したが、PHPはセッション情報を独自の形式でシリアライズするので、Node.jsからは扱いづらい。上記記事ではPHPのセッションハンドラを改良してJSON形式で保存するようにしていたが、もっと簡単な方法があった。

まず、PHPのシリアライズ形式を変更する。

```php.ini
;session.serialize_handler = php  # コメントアウト
session.serialize_handler = php_serialize
```

`php_serialize`ハンドラを使用すると、例えば以下のような形式にシリアライズされる。

```php
$_SESSION["hoge"] = "foo";
$_SESSION["bar"] = "baz";
```

これがこうなる。

```
a:2:{s:4:"hoge";s:3:"foo";s:3:"bar";s:3:"baz";}
```

### シリアライズされたセッション文字列をNode.jsで扱う
Node.jsの[php-serialize](https://github.com/steelbrain/php-serialize)というモジュールを使用すると、上記形式のセッションをオブジェクトとして扱うことができるようになる。

例えばこんな感じ。

```js
const phpSerializer = require('php-serialize');

const sessionStr = 'a:2:{s:4:"hoge";s:3:"foo";s:3:"bar";s:3:"baz";}';

const session = phpSerializer.unserialize(sessionStr);

console.log(`hoge: ${session.hoge}`);  // hoge: foo
console.log(`bar: ${session.bar}`);    // bar: baz
```

### セッションミドルウェアを作成する
クライアントアクセス時にRedisからセッションを取得するミドルウェアを実装する。Node.jsのフレームワークはExpressを使っているとして、例えば以下のような感じ。モジュールのインポート文とかRedisクライアントの作成とか一部省略。

```js
function session(req, res, next) {
  if (!req.cookies) {
    console.error('must use cookie-parser middleware');
    return next();
  }

  const sessionId = req.cookies['PHPSESSID'];
  if (!sessionId) {
    return next();
  }

  const sessionKey = 'PHPREDIS_SESSION:' + sessionId;
  redis.getAsync(sessionKey)
    .then((sessionStr) => {
      if (sessionStr) {
        req.session = new Session(sessionKey, sessionStr);
      }
      return next();
    })
    .catch((err) => {
      return next(new Error('Server error'));
    });
  };
}

function Session(sessionId, sessionStr) {
  this.id = sessionId;

  let data;
  try {
    data = phpSerializer.unserialize(sessionStr);
  } catch(e) {
    return;
  }

  for (const prop in data) {
    if (!(prop in this)) {
      this[prop] = data[prop];
    }
  }
}
```

`req.cookies`にはクライアントが送信してきたCookieが入っていて、ここにPHPサイトで発行されたセッションIDが入っているのでそれを取得する。なお、`req.cookies`が存在するためには[cookie-parser](https://github.com/expressjs/cookie-parser)ミドルウェアが前段に必要。

次に、Redisからセッション文字列を取得する。デフォルトだと`PHPREDIS_SESSION:`というプレフィックスがついたキーで保存されている。例えば`PHPREDIS_SESSION:30bn9bgoii4ndkii9grr128vf4`のような感じ。

セッション文字列が取得できたら、php-serializeでデシリアライズしてオブジェクトとして`req.session`に格納する。

### ログイン済みかどうか確認する
Socket.ioであれば例えば以下のようにミドルウェアを設定する。

```js
const cookieParser = require('cookie-parser');
const io = require('socket.io')();

io.use((socket, next) => {
  cookieParser()(socket.request, socket.request.res, next);
});

io.use((socket, next) => {
  session(socket.request, socket.request.res, next);
});

io.use((socket, next) => {
  if (socket.request.session.user_id) {
    next();
  } else {
    next(new Error('authentication required');
  }
});

io.on('connection', (socket) => {
  ...
});
```

ログイン済みかどうかを判定する部分はサイトによって異なるが、ここではセッションにユーザIDがあるかどうかを判定条件としている。ユーザIDが存在すればログイン済みとして先に進み、存在しなければWebsocketハンドシェイクに失敗するようにしている。

## まとめ
PHPとNode.jsでセッションを共有したいという要件はまあまああるようで、`php nodejs session`などでググるといくつか記事がヒットした。情報の多くは海外ブログやStackOverflowで、ガチャガチャとPHPをいじる感じの内容が多いが、PHPの設定を少し変えるのと、[php-serialize](https://github.com/steelbrain/php-serialize)という便利なモジュールのおかげで結構簡単に実装できた。

日本ではあまり事例がないのか、あるけどブログに上がっていないのかよくわからないが、同じようなことをしようとしてる人に少しでも参考になればと。

なお、今回はセッションを取得するだけだが、Node.js側で情報を追加して保存したいということもあるので、そのあたりをまた別の機会に。