既存の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ハンドラを使用すると、例えば以下のような形式にシリアライズされる。

$_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というモジュールを使用すると、上記形式のセッションをオブジェクトとして扱うことができるようになる。

例えばこんな感じ。

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クライアントの作成とか一部省略。

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ミドルウェアが前段に必要。

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

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

ログイン済みかどうか確認する

Socket.ioであれば例えば以下のようにミドルウェアを設定する。

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という便利なモジュールのおかげで結構簡単に実装できた。

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

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