はじめに
Passport.js を使ってみる回その3です。
以前の記事はこちら:
- [Node.js]ExpressとPassport.jsでOAuth2 (1)インストールと認証までのフローを作る - dackdive's blog
- [Node.js]ExpressとPassport.jsでOAuth2 (2)認証済みユーザー情報をセッションに保存する - dackdive's blog
前回は 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
になります。
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/myapp
の myapp
の部分は任意の文字列で、これが 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 を導入しました。
エラーハンドリングなど詰めが甘いところは多々ありますが、基本的な認証のしくみとしては整ったかと思います。
この後実際に Google の API を利用しようとすると、アクセストークンやリフレッシュトークンを使う必要があります。
そのため、おそらくこれらもユーザー情報にひもづけて保存しておく必要がありますね。。。
と、ここまでやってみて気づいたんですが、 Google の API を利用するのであれば専用の Node.js Client がライブラリとして提供されていました。
最初からこっちを使っていればよかったかも。。。
まあ、 Google 以外のサービスでも使える Passport.js の基本的な使い方がわかったから良しとするか。
(追記)
続編書きました。
(追記ここまで)
わからないこと
期限切れになったセッションの削除方法。
セッションの有効期限が切れると再度認証画面にとばされますが、そこで認証を行って戻ってくると sessions
コレクションに新しいセッション情報がセットされます。
古くなったセッションは削除してもよさそうですが、方法がわからず。。。