自習室

こもります

Node.js+Express+MongoDBでSessionを利用する、をちょっと整理して理解を試みた

まえおき

前回までの記事で、Node.jsのアプリ上で4sqAPIを叩いて色々出来る下地が整ったのですが、ユーザアカウントの切り分けなどは全く考慮していないので、このままだとユーザAさんがOAuthした後にユーザBさんがサイトにアクセスするとBさんは何もしなくても自動的にAさんのチェックイン履歴が見られる、というファンタスティックな事態に陥ります。

今回はいったん前回までのFourSquareAPIの話は忘れて、上記問題に対してsessionの作法をExpressアプリ上に実装することで対応しようと勉強をした内容をまとめます。

参考にさせていただいた記事様

今回は、こちらの記事様をベースとして勉強しました。

Node.js+Express+MongoDBでSessionを利用してログイン機能を実装 - QiitaNode.js+Express+MongoDBでSessionを利用してログイン機能を実装 - Qiita

その中でわかりにくかった部分などをピックアップしてまとめておきます。

GitHubにあげました

私個人の勉強のためにあちこちコメントが散らかっています。ご勘弁ください。

izmhr/connect-mongo-test · GitHubizmhr/connect-mongo-test · GitHub

ここから先は参考記事様の内容を実装した物になります。細かいところは本記事の方をご覧下さい。

参考記事様から改変した内容

セッションmaxAge指定を修正

参考記事様ではセッションストアの生成時にcookies: { maxAge: new Date(Date.now + 60 * 60 * 1000), ...} の様に maxAge を現在時刻に期間を足して指定していますが、これはおそらく間違いです。試しにこのままやってみると、expireしたあとのsessionに、originalMaxAge としてマイナスの値が残り続け、新たなsessionが生成されなくなりました。

expire 指定と maxAge 指定の関係については公式に説明があります。下のリンクの "Session#maxAge" のあたり。

Connect - High quality middleware for node.js

修正したコードは下記のようになります。

// app.js[f:id:AMANE:20140518175155p:plain][f:id:AMANE:20140518175155p:plain]
app.use(express.cookieParser());  // cookieのパースを行う。次に決めるsecretを使って行われる
app.use(express.session({         // cookieに書き込むsessionの仕様を定める
  secret: 'secret',               // 符号化。改ざんを防ぐ
  store: new MongoStore({
    db: 'session',
    host: 'localhost',
    clear_interval: 60 * 60     // mongodbに登録されたsession一覧を見て、expireしている物を消す、ということをする周期。こちらはs
  }),
  cookie: { //cookieのデフォルト内容
    httpOnly: false,
    maxAge: 60 * 60 * 1000// ★★修正箇所:1 hour. ここを指定しないと、ブラウザデフォルト(ブラウザを終了したらクッキーが消滅する)になる こちらはms
  } 
}));

connect-mongo-test/app.js at master · izmhr/connect-mongo-test · GitHub

重複IDの確認

ユーザーアカウントのデータベースを定義するスキーマにおいて、email キーがユニークになるよう設定しています。

// model.js
var UserSchema = new mongoose.Schema({
  email    : {type:String, unique: true}, // ★★ emailキーはunique
  password  : String
},{collection: 'info'});  // collection名を指定する

connect-mongo-test/model.js at master · izmhr/connect-mongo-test · GitHub

これにより、既に存在する email キーと同じ物を指定してドキュメントを作り、save(コレクションに加える)しようとすると、エラーがはかれるようになります。確認のコードは下の節にまとめて掲載します。

アカウント作成後自動ログイン

アカウント作成時に一度ID/PWを入れてページが遷移するのに、また同じ画面に来て今度はログインの方にID/PWを入力するのに違和感があったので、自動でログインするようにしましたlogin時にやっていることと同じ処理をadd時にもやるだけです。

// ユーザー登録機能
exports.add = function(req, res) {  // add は postで行われる。
  var newUser = new User(req.body); // postの内容{email: "fuga@fuga", password: "fugafuga"}を利用して、新しいドキュメントを作成
  newUser.save(function(err){       // 追加する
    if(err){
      console.log(err);
      // ★★model.jsの方で email に対し unique option をつけたので、同じemailを指定するとエラー11000が返ってくる
      if( err.code === 11000 ) console.log("the email is already used");
      res.redirect('back');
    } else {
      console.log("add success and redirect to '/'");
      req.session.user = req.body.email;  // ★★ここでsessionに記録して、ログインする
      res.redirect('/');  // 新しいアカウントが作られたので、次のloginCheckは成功する
    }
  });
};

connect-mongo-test/routes/index.js at master · izmhr/connect-mongo-test · GitHub

これを利用して、同じIDのアカウント作成を防ぎます。

sessionミドルウェア(とconnect-mongo)が暗黙的にやっていることのまとめ

sessionミドルウェアは、リクエストが来る度に 以下の事を暗黙的に行っているようです。

  1. req 中にsession情報とおぼしきcookieが含まれて居るかチェック
  2. cookie中の符号化された coonect.sid をsession middlewareが復号化して中身を確認し、それを req オブジェクトに加える
  3. req の中身のreq.session と一致するsession情報がデータベース('session')にあるかを確認
  4. データベースにsession情報が無いかそもそも req 中にcookieが含まれていなかった場合は、新たに固有のsessionを生成してreq に付与する。同時にデータベース('session')にsession情報を書き込む。
  5. データベースにsession情報が存在した場合は、そのsession情報をそのまま利用する
  6. あったとしてもそれが maxAge を超えていないか確認して、超えていたら既存の session にかわって新たに sessionIDmaxAge を設定したsessionを生成し、req オブジェクトの内容を書き換える。またデータベースに保存する。
  7. sessionの付与されたreqオブジェクトは最終的にresオブジェクトにくるまれてブラウザに返される (これがブラウザの側でcookieとして保有される)

とくに何もしなくともセッションが作られ、cookieをブラウザに返していることの確認

今回のプロジェクトで新しいアカウントを作る前に、/login にアクセスした時点で、デベロッパーコンソールを見ると connect.sid というフィールドを持つcookieを受け取っていることが分かります。

f:id:AMANE:20140528233426p:plain

またこのときsession情報を管理しているデータベースを見てみると、以下の様にユーザID等を持たず自動生成されたセッションが保存されていることも確認出来ます。上に示したようにブラウザに渡ってきたcookieの内容と、connect-mongoがsession情報としてmongoDBに記録した内容が一致していることが確認出来ます。

f:id:AMANE:20140528233249p:plain

ユーザーアカウントの管理などをしているか否かにかかわらず、app.user(express.session{... した時点で、セッションが有効になっているわけです。ただしこれだけだと「同じブラウザからアクセスされている」と言うことが分かるのみで、ユーザの登録情報をつかってアカウントごとにページを作る、等は出来ません。

従ってログイン機能は別途作成して、ログイン状態をページ間で引き継いだり、データベースにアクセスする際のクエリとしてsession中にユーザIDなどを持たせることをします。

「2.セッション情報の復号化」のちょっと詳細

下に示すコードで、sessionミドルウェアがsession符号化の鍵を指定しています。

// app.js
app.use(express.session({         // cookieに書き込むsessionの仕様を定める
  secret: 'secret',               // ★★符号化。改ざんを防ぐ
  store: new MongoStore({
    db: 'session',
// ...以下略

connect-mongo-test/app.js at master · izmhr/connect-mongo-test · GitHub

自サービスで secret を使って符号化したcookieなので復号化が出来ます。また復号化出来たことを持って、自サービスが発行したsession情報であることや、そこに含まれているユーザ情報が改ざんされていないことが確認されています。

sessionミドルウェアのお仕事の流れ

基本的には参考記事様の通りで完了なのですが、具体的に何が行われているのか考えるとちょっと難しいぞ?と感じた部分に着目して、ブラウザからのリクエスト、express上での処理、dbへのアクセスなどをまとめたシーケンス図を作成してみました。

初アクセス時

f:id:AMANE:20140528011455p:plain

シーケンス図中でやっている「暗黙の内の確認」は、上記リストでいうと3と4にあたり、ここではsession情報がそもそもreq中に含まれていない場合のシナリオになります。

アカウント作成時

exports.add = function(req, res) {} 

の内容です

f:id:AMANE:20140528011500p:plain

シーケンス図中で黄色く塗っているところを今回私が独自に加えたので、アカウント作成から自動でログインするようになっています。また、デベロッパが特に何も書かなくても、このときの req.session の内容は、自動的にデータベース sessions に上書きされます。

ログイン時

exports.login = function(req, res) {}

の内容です

f:id:AMANE:20140528011506p:plain

ここでは一般的なデータベースの使い方のように、検索クエリを作ってアカウントのデータベースで検索して、見つけられたらsession情報にそのユーザ情報を追記してトップページに移動させる、ということを行っています。

セッションが続いている間にページを表示したとき

f:id:AMANE:20140528011512p:plain

ここも、今回自分で一切記述をしていない内容が良い感じに処理されてしまっているで理解しにくい部分だと感じました。 '/' にアクセスした直後表示するページが振り分けられる前に、ミドルウェアreq オブジェクト内の cookie をパースし、有効かどうかの判断を行った上で req オブジェクト内に展開しています。

このことは、以下のコードがはき出しているコンソールログを見ると分かります。

// app.js
// ルーティングが始まった直後のreqオブジェクトの中身を確認する
var loginCheck = function(req, res, next){
  //---- debug用 -----
  console.log("loginCheck" + new Date().toLocaleString());
  console.log("#### req.session ####");
  console.log(req.session);   // sessionミドルウェアを通してパースされたsessionオブジェクトを確認する
  console.log("#### req.cookies ####");
  console.log(req.cookies);   // 送られてきたcookieの実体を確認する

connect-mongo-test/app.js at master · izmhr/connect-mongo-test · GitHub

ルーティングの一番始めに回ってくるこの loginCheck() の時点で、下記のように req.sessionreq.cookies に値が収められていることが確認出来ます。sessionミドルウェア(とcookieParser)が暗黙の内に復号化、パースを行ってくれているので、後はreqオブジェクトのプロパティを見て処理をしていくことが可能になります。

loginCheckWed May 28 2014 23:10:49 GMT+0900 (JST)
#### req.session ####
{ cookie: 
   { path: '/',
     _expires: Thu May 29 2014 00:10:49 GMT+0900 (JST),
     originalMaxAge: 3600000,
     httpOnly: false },
  user: 'hoge@hoge' }
#### req.cookies ####
{ 'connect.sid': 's:0Oe45d51dLfc2bkabtW0QbPG.XxeBDxXPPLGAz+wZvtZs3EqowaJs7kMpOEBD5QDDSFI' }
correctly loggined

最後に

ログインらしきことが出来たのですが、今回の例だとその恩恵は「あなたのユーザIDは確かに●●ですね」と分かってくれただけです。実際は、データベース users と合わせてユーザーごとの個人情報などを保存しておくことで、たとえばSNSのような個人ページが表示できるようになります。

前回の 4sqAPI の活用の流れで言うと、4sqAPI の OAuth の結果得られたIDなどを自サービスでもIDとして利用するようアカウントデータに加えて保存し、合わせてAccessTokenも保存しておくことで、一度ログインしてそのセッションがキープできている間は、ページを開くだけでそのユーザのaccessTokenを使ってユーザの4sq情報を提示できるようになります。

そもそもNode.jsでこういった外向きのサービスを構築しようとすることが適切なのかどうかは定かではないですが、ようやく足回りがそろってきた感じです。