Passportで外部認証を実現する

電脳麻将 ver.2.3.0ネット対戦 の機能を追加した。ルーム(対局待ちの状態)や対局画面にプレーヤー名(できればアイコンも)を表示しようとするとプレーヤーの登録が必要になる。電脳麻将では「ゲスト登録」と「外部認証」の2つの方法で、電脳麻将自身ではプレーヤー情報を管理せずプレーヤー名を取得しているので、この方法を説明する。

ネット対戦ゲームでプレーヤーを識別しようと思ったらユーザ登録機能を用意するのが一般的だが、電脳麻将にそんな大げさな機能は追加したくなかった*1。そこで、プレーヤー名のみを登録する「ゲスト登録」と、はてなやGoogleに認証とユーザ情報の管理を任せる「外部認証」で実現することにした。これならユーザ情報をセッションに持たせることができ、メモリのみでも運用可能だ。

電脳麻将の 麻雀サーバーNode.jsExpress で実装しているが、その場合、外部認証には Passport を使うのが定番となっている。Passport は Express の ミドルウェア*2であり、共通の枠組みで多数のサイトの認証を扱うことができる。

パッケージのインストール

まず使用するパッケージをインストールする*3。Passort には外部認証先ごとに Strategy と呼ばれるパッケージがあり、その一覧は こちら で見ることができる*4。あるいは npmのサイトで検索 してもいいだろう。同じサイトに複数のStrategyの実装があったりするので、選定の際にはダウンロード数なども参考に信頼できそうなものを選ぶ必要がある。

電脳麻将では以下のパッケージをインストールした*5

ユーザ情報の形式と管理方法の決定

複数のサイトを使って認証する場合でも、最終的に得られるユーザ情報は共通の形式にすべきである*6。電脳麻将では、どのような外部サイトからも一般的に得られると思われる以下の情報を使うことにした。

uid
電脳麻将上でユーザを一意に識別するID。ゲスト認証の場合はセッションIDをそのまま使う。外部認証の場合は、外部サイトでのユーザID + @ + 外部サイト名 とする。
name
電脳麻将上でのプレーヤー名。ゲスト認証の場合は、登録時に指定された名前、外部認証の場合は、外部サイトでの当該ユーザの表示用のニックネームとする。
icon
ルームでユーザを識別するアイコン。ゲスト認証の場合は null、外部認証の場合は、外部サイトで当該ユーザを識別するために使用するアイコンのURLとする。

次にユーザ情報をセッションに保存・セッションから回復するときの処理を登録する。ユーザ情報をすべてセッションに保存するなら以下とすればよい。

const passport = require('passport');

passport.serializeUser((user, done)=> done(null, user));
passport.deserializeUser((userstr, done)=> done(null, userstr));

ここでの処理は外部サイト個別ではないので、user は先に定義したユーザ情報となる。もし、ユーザ情報の保存・復元にDBを使用するなら、その際の処理を関数化し、登録する必要がある*7

外部認証情報のユーザ情報へのマッピング

外部認証で得られた情報をアプリケーションのユーザ情報にマッピングするには、各認証パッケージのクラスStrategyを使って以下のように記述する。

はてなの場合:

const hatena = require('passport-hatena-oauth');

passport.use(new hatena.Strategy(
    require(path.join(auth, 'hatena.json')),
    (token, tokenSecret, profile, done)=>{
        let user = {
            uid:  profile.id + '@hatena',
            name: profile.displayName,
            icon: profile.photos[0].value
        };
        done(null, user);
    }
));

Googleの場合:

const google = require('passport-google-oauth20');

passport.use(new google.Strategy(
    require(path.join(auth, 'google.json')),
    (accessToken, refreshToken, profile, cb)=>{
        let user = {
            uid:  profile.id + '@google',
            name: profile.displayName,
            icon: profile.photos[0].value
        };
        cb(null, user);
    }
));

use やら Strategy やらが意味不明だが、Strategy を new するときの 第1パラメータ はアプリケーションキーなどの設定情報、第2パラメータ は外部認証情報からユーザ情報へのマッピングを行う関数のようだ。第2パラメータで渡す関数の仕様については各Strategyのドキュメントを参照すること。おおむね第3パラメータが認証情報と思われるが例外もある。

以上はOAuth認証の場合だが、ローカル認証ではフォームから送られたユーザ名/パスワードを使う。

const local = require('passport-local');

passport.use(new local.Strategy(
    { usernameField: 'name',
      passwordField: 'passwd' },
    (name, passwd, done)=> done(null, { name: name })
));

Strategy を new するときの第1パラメータでフォームのフィールド名を指定し、第2パラメータの関数でフォームの情報を取得する。ゲスト認証はパスワードを問わないので、指定されたユーザ名(画面上はプレーヤー名となっている)をそのまま name に設定する*8uid にはセッションIDを使いたいのだが、このスコープでは見ることができないので、別のタイミングで設定している。icon は未定義である。

認証シーケンスとその実装

OAuth 1.0 のシーケンスは以下の通り。

      User    User-Agent           Consumer         Service Provider
 (resource owner)  |                (client)             (server)
         |         |                    |                    |
      (A)|-------->|                    |                    |
         |         |------------------->* (1)                |
         |         |                    | get_request_token  |
         |         |                    |------------------->| /initiate
         |         |                    |<-------------------|
         |         |<-------------------|                    |
         |         |---------------------------------------->| /authorize
         |         |<----------------------------------------|
      (B)|<--------|                    |                    |
         |         |                    |                    |
      (C)|-------->|                    |                    |
         |         |---------------------------------------->|
         |         |<----------------------------------------|
         |         |------------------->* (2)                |
         |         |                    | get_access_token   |
         |         |                    |------------------->| /token
         |         |                    |<-------------------|
         |         |                    |                    |
         |         |                    |------------------->| (API)
         |         |                    |<-------------------|
         |         |<-------------------|                    |
         |         |------------------->* (3)                |
         |         |<-------------------|                    |
      (D)|<--------|                    |                    |
         |         |                    |                    |

ここに登場するエンティティの意味は以下。

User
ブラウザを操作している人
User-Agent
ブラウザ
Consumer
Webアプリ(電脳麻将(麻雀サーバ))
Service Provider
外部認証先(はてな や Google)

シーケンスは以下の流れになる。

  1. 画面上の「外部認証」ボタンを押下する (A)
  2. 麻雀サーバで (1) の処理が起動し、外部認証先の /initiate にリクエスト・トークン発行を要求する
  3. (1) の処理は リクエスト・トークン を受取り、それをパラメータに含めて*9外部認証先の /authorize にリダイレクトさせる
  4. 外部認証先のOAuth認証確認ページ を表示する (B)
  5. 画面上の「許可」ボタンを押下する (C)
  6. 外部認証先はアクセス・トークン要求のキーをパラメータに含めて麻雀サーバ上のURL (2) にリダイレクトする
  7. 麻雀サーバで (2) の処理が起動し、外部認証先の /token にアクセス・トークン発行を要求する
  8. (2) の処理は アクセス・トークン を受取り、それを使用して外部認証先のユーザ情報取得のAPIを呼び出す
  9. (2) の処理は ユーザ情報 を受取り、先に定義したメソッドでセッションに保存した後、麻雀サーバ上のURL (3) にリダイレクトする
  10. 麻雀サーバーが認証完了画面を表示する (D)

/initiate、/authorize、/token の具体的なURLは外部認証先がそれぞれ定義しているが、Passport の Strategy が隠蔽しているので気にする必要はない*10

これらの処理を実現するプログラムは以下の通り。(1)、(2) のURLに各 Strategy の authenticate() メソッドが提供する関数を設定するだけだ。

はてなの場合:

app.post(`${base}/auth/hatena`, passport.authenticate('hatena',
                                        { scope: ['read_public'] }));
app.get(`${base}/auth/hatena`, passport.authenticate('hatena',
                                        { successRedirect: back }));

Googleの場合:

app.post(`${base}/auth/google`, passport.authenticate('google',
                                        { scope: ['profile'] }));
app.get(`${base}/auth/google`,  passport.authenticate('google',
                                        { successRedirect: back }));

変数 back には (3) のURLを設定する。

ローカル認証は

app.post(`${base}/auth/`, passport.authenticate('local',
                                    { successRedirect: back,
                                      failureRedirect: back }));

とした。

Express に Passport による前処理を追加

これまでに実装した処理を有効にするためには、Express に Passport による前処理を追加する必要がある。

const app = express();
app.use(session);
app.use(passport.initialize());
app.use(passport.session());

これでHTTPリクエストを表現するオブジェクト req のプロパティ user にユーザ情報が設定されるようになる。

Socket.io へのユーザ情報引き継ぎ

HTTPリクエストに関してはユーザ情報が取得できるようになったが、麻雀サーバーはWebSocketで動作しており、 その実現に Socket.io を使用している。ところが、Socket.io の提供するリクエストオブジェクト socket.request にはプロパティ user が存在しない。

Socket.io では Passport が提供する前処理が実行されないことが原因なので、以下のように前処理の実行を指定すればよい。

const http = require('http').createServer(app);
const io   = require('socket.io')(http, { path: `${base}/socket.io/` });

const wrap = (middle_wear)=>
                    (socket, next)=> middle_wear(socket.request, {}, next);

io.use(wrap(session));
io.use(wrap(passport.initialize()));
io.use(wrap(passport.session()));

WebSocketではHTTP接続を永続化させ、その上で双方向通信を行うのだが、ブラウザからは cookie も送信されているため、これを使用すればユーザ情報を復元できるという寸法である。

Scket.io のミドルウェア関数のパラメータは (socket, next) なので、Express の (req, res, next) に変換する必要があり、これを関数 wrap() で実現している。

コンフィグレーション情報の設定

実際に外部認証を行うためには、外部認証機関に対して「アプリケーション登録」が必要である。アプリケーション登録の申請*11が受理されると、アプリケーションの ID (あるいはキー)と シークレット*12 が払い出されるので、これをコンフィグレーションとして保存*13し、先に説明したクラス Strategy を new する際の第1パラメータとして指定すればよい。

はてなの場合:

{
  "consumerKey": CONSUMER_KEY,
  "consumerSecret": CONSUMER_SECRET,
  "callbackURL": "https://kobalab.net/majiang/server/auth/hatena"
}

Googleの場合:

{
  "clientID": CLIENT_ID,
  "clientSecret": CLIENT_SECRET,
  "callbackURL": "https://kobalab.net/majiang/server/auth/google"
}

callbackURL にはシーケンスで説明した (2) のURLを指定する。

これで外部認証が可能になるはずだ。詳しくは以下のソースプログラムも参照して欲しい。

*1:アイコン画像でディスクが浪費されるリスクなど考えたくもないでしょ

*2:Apacheでいうなら mod_XXX 相当の前処理を行う仕組みの総称

*3:Expressなどのインストールは省略

*4:表示が乱れていますが、検索欄にフォーカスすると画面が現れます

*5:はてな認証、Google認証とも複数の実装があったが、ダウンロード数などから使用するパッケージを決定した

*6:そうしないとアプリの作りが無闇に複雑になってしまう

*7:かつて Passport のサイトではハッシュにユーザ情報を保存する実装を例示していたので、それをコピペしてるマヌケを何度も見た

*8:ただしパスワードを省略すると passport-local がエラー扱いするので、ダミーの値を入れる必要がある

*9:リクエスト・トークン自体ではなかったかも

*10:自分で (1)、(2) の処理を実装するならこれらの情報が必要になる

*11:はてな は無審査だが、Googleには審査があり、プライバシーポリシーと利用規約の掲示を確認される。電脳麻将では こんな感じ にしたのでご参照あれ

*12:こいつの日本語訳がない。秘密鍵ほどの長さはないので パスワードのごときものかな

*13:このIDとシークレットをGitHub等にアップしてしまうという事故が散見されるので注意されたし