dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

[Node.js]ExpressとPassport.jsでOAuth2 (3)認証済みユーザー情報をMongoDBに保存する

はじめに

Passport.js を使ってみる回その3です。
以前の記事はこちら:

前回は express-session を使い、認証したユーザーの情報をセッションに保存するところまでやりました。
そのときの TODO として、セッションの保存先がデフォルトではサーバー側のメモリ(MemoryStore)になっていたのですが、これは production 環境では推奨されないようです(参考(Warning のところ))。

そこで今回はセッションの保存先として MongoDB を使うように設定します。


リポジトリ


MongoDB のインストール

詳しくは
dackdive.hateblo.jp
を見ていただきたいのですが、Mac の場合 Homebrew からインストールできます。

$ brew install mongodb

# 以後、PC の起動時にプロセスを自動起動
$ brew services start mongodb

# 起動確認
$ mongo
MongoDB shell version v3.4.7
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.7
...
> 


各種ライブラリのインストール

MongoDB を Node.js および Express で使うために必要なライブラリをインストールします。

mongoose

Node.js から MongoDB を扱うためのライブラリには node-mongodb-native という MongoDB 公式のドライバもありますが、今回は mongoose というライブラリを使います。
mongoose は「object data modeling(ODM)tool」あるいは「O/R マッパーのように使えるライブラリ」などと説明されています。

$ yarn add mongoose
connect-mongo

こちらは Express のセッションストアとして MongoDB を使うために必要なライブラリです。
express-session の compatible session stores にも記載されています。

$ yarn add connect-mongo


コード


User モデルの作成

はじめに、データベースに保存する User データのモデルを定義します。

// models/user.js
import mongoose from 'mongoose';

const userSchema = mongoose.Schema({
  _id: String,
  displayName: String,
  image: String,
});

export default mongoose.model('User', userSchema);

mongoose.Schema() に渡すオブジェクトでデータの型を定義します。
今回、データを一意に識別するための id には Google のユーザー Id を使用するため、 _id: String を指定します。
(何も指定しないと MongoDB の ObjectId 型になるみたいです)

mongoose.model() の第一引数はコレクション名を決めるもので、今回のように User とした場合、コレクション名は自動的に先頭小文字+複数形の users になります。

参考:Compiling your first model

The first argument is the singular name of the collection your model is for. Mongoose automatically looks for the plural version of your model name. Thus, for the example above, the model Tank is for the tanks collection in the database.


MongoDB および connect-mongo の設定

サーバー側のエントリーポイントである app.js に、以下のように追記します。

 import express from 'express';
 import session from 'express-session';
+import mongoose from 'mongoose';
+import connectMongo from 'connect-mongo';
 import path from 'path';
 import passport from 'passport';

 (略)
+
+const DATABASE_URI =
+  process.env.MONGOLAB_URI ||
+  process.env.MONGOHQ_URL ||
+  'mongodb://localhost/myapp';
+
+mongoose.connect(DATABASE_URI, {
+  useMongoClient: true,
+});



+const MongoStore = connectMongo(session);
 app.use(session({
   secret: 'keyboard cat',
   resave: false,
   saveUninitialized: true,
+  store: new MongoStore({ mongooseConnection: mongoose.connection }),
   cookie: {
     maxAge: 1000 * 60 * 60 * 24 * 30,
   },
}));

前半では mongoose.connect() メソッドを使い MongoDB へ接続します。
DATABASE_URI環境変数を使っているのは、将来的に Heroku などの PaaS でも動かすことを想定しています。
Heroku の公式ドキュメント を参考にしています。
localhost/myappmyapp の部分は任意の文字列で、これが DB 名になります。

また、 useMongoClient: true というオプションについては http://mongoosejs.com/docs/connections.html#use-mongo-client
理由はちゃんと理解していませんが、バージョン 4.11.0 以降はこれを指定しないと起動時に warning が出るようです。

(node:47913) DeprecationWarning: `open()` is deprecated in mongoose >= 4.11.0, use `openUri()` instead, or set the `useMongoClient` option if using `connect()` or `createConnection()`. See http://mongoosejs.com/docs/connections.html#use-mongo-client


後半では express-session 用に MongoStore を作成し、 store オプションに指定することでセッションの保存先を MongoDB に変更しています。

なお、mongoose のセットアップを行った際のコネクションを再利用するため、 mongooseConnection オプションを指定しています。
これは connect-mongo の README に記載がありました。
https://github.com/jdesboeufs/connect-mongo#re-use-a-mongoose-connection


認証完了時に User をデータベースに保存する

routes/auth.js を以下のように更新します。

 passport.use(new GoogleStrategy({
   clientID: process.env.CLIENT_ID,
   clientSecret: process.env.CLIENT_SECRET,
   // FIXME: Enable to switch local & production environment.
   callbackURL: 'http://localhost:8080/auth/google/callback',
   accessType: 'offline',
-}, (accessToken, refreshToken, profile, cb) => {
+}, (accessToken, refreshToken, profile, done) => {
   // Extract the minimal profile information we need from the profile object
   // provided by Google
-  cb(null, extractProfile(profile));
+  User.findByIdAndUpdate(profile.id, extractProfile(profile), {
+    upsert: true,
+    new: true,
+  }, (err, user) => {
+    console.log(err, user);
+    return done(err, user);
+  });
 }));

ここでは mongoose の findByIdAndUpdate() メソッドを使っています。
upsert: true を指定すると、文字通り DB にデータが存在しなかったときに insert してくれます。
また new: true を指定していますが、これは true の場合更新後のオブジェクトを、false の場合更新前のオブジェクトを返すようにするというオプションです。


セッションには Id のみ保存し、デシリアライズ時に User 情報を復元する

最後に、routes/auth.js の serializeUser()deserializeUser() メソッドを以下のように更新します。

 passport.serializeUser((user, done) => {
   console.log('serializeUser', user);
-  done(null, user);
+  done(null, user.id);
 });
-passport.deserializeUser((obj, done) => {
-  console.log('deserializeUser', obj);
-  done(null, obj);
+passport.deserializeUser((id, done) => {
+  console.log('deserializeUser', id);
+  User.findById(id, (err, user) => {
+    if (err || !user) {
+      console.log('Cannot find user', id);
+      return done(err);
+    }
+    console.log('Found user', user);
+    done(null, user);
+  });
 });

今までは User 情報を丸ごとセッションに保存していたのですが、Id だけを保存するように serializeUser() を修正しました。
そうすると deserializeUser() 側の引数も Id になるので、今度はこの Id を使って users コレクションから User オブジェクトを検索します。


リダイレクト処理を入れる

これはおまけですが、 / にアクセスしたときに req.user が存在するかどうか、つまり認証済みかどうかをチェックし
認証が済んでいない場合は Google の認証画面にリダイレクトするようにしておきます。

 app.get('/', (req, res) => {
-  console.log('user', req.user);
+  console.log('/', req.user);
+  if (!req.user) {
+    return res.redirect('/auth/login');
+  }
   res.sendFile(path.join(__dirname, 'public', 'index.html'));
 });


動作確認

http://localhost:8080 にアクセスすると Google の認証画面にリダイレクトされ、許可するとトップに戻ってきます。
このとき、MongoDB の中身はどうなっているかというと

$ mongo
MongoDB shell version v3.4.7
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.7
Server has startup warnings:
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten]
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten] ** WARNING: Access control is not enabled for the database.
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten] **          Read and write access to data and configuration is unrestricted.
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten]
> show dbs
admin  0.000GB
local  0.000GB
myapp  0.000GB
> use myapp # myapp に切り替え
switched to db myapp

# sessions と users コレクションが作成されている
> show collections
sessions
users

# セッションの中身
> db.sessions.find()
{ "_id" : "kdBCv3ceCC_ufClCCuekOoeHtU-X_vMV", "session" : "{\"cookie\":{\"originalMaxAge\":2592000000,\"expires\":\"2017-10-02T02:50:00.016Z\",\"httpOnly\":true,\"path\":\"/\"},\"passport\":{\"user\":\"112492058384636445846\"}}", "expires" : ISODate("2017-10-02T02:50:00.038Z") }

# ユーザー情報
> db.users.find()
{ "_id" : "112492058384636445846", "__v" : 0, "displayName" : "Shingo Yamazaki", "image" : "https://lh6.googleusercontent.com/-jwkJLbJL4wE/AAAAAAAAAAI/AAAAAAAACI/1MbHlZlCL5w/photo.jpg?sz=50" }
>

というわけで、無事に保存されました。


まとめと TODO

認証済みのユーザー情報をデータベースに保存するため、MongoDB および mongoose を導入しました。
エラーハンドリングなど詰めが甘いところは多々ありますが、基本的な認証のしくみとしては整ったかと思います。

この後実際に GoogleAPI を利用しようとすると、アクセストークンやリフレッシュトークンを使う必要があります。
そのため、おそらくこれらもユーザー情報にひもづけて保存しておく必要がありますね。。。

と、ここまでやってみて気づいたんですが、 GoogleAPI を利用するのであれば専用の Node.js Client がライブラリとして提供されていました。

最初からこっちを使っていればよかったかも。。。
まあ、 Google 以外のサービスでも使える Passport.js の基本的な使い方がわかったから良しとするか。

(追記)

続編書きました。

(追記ここまで)

わからないこと

期限切れになったセッションの削除方法。
セッションの有効期限が切れると再度認証画面にとばされますが、そこで認証を行って戻ってくると sessions コレクションに新しいセッション情報がセットされます。
古くなったセッションは削除してもよさそうですが、方法がわからず。。。


リファレンス