電脳麻将 ver.2.3.0 では ネット対戦 の機能を追加した。 ルーム(対局待ちの状態)や対局画面にプレーヤー名(できればアイコンも)を表示しようとするとプレーヤーの登録が必要になる。 電脳麻将では「ゲスト登録」と「外部認証」の2つの方法で、電脳麻将自身ではプレーヤー情報を管理せずプレーヤー名を取得しているので、この方法を説明する。
ネット対戦ゲームでプレーヤーを識別しようと思ったらユーザ登録機能を用意するのが一般的だが、電脳麻将にそんな大げさな機能は追加したくなかった*1。 そこで、プレーヤー名のみを登録する「ゲスト登録」と、はてなやGoogleに認証とユーザ情報の管理を任せる「外部認証」で実現することにした。 これならユーザ情報をセッションに持たせることができ、メモリのみでも運用可能だ。
電脳麻将の 麻雀サーバー は Node.js の Express で実装しているが、その場合、外部認証には Passport を使うのが定番となっている。 Passport は Express の ミドルウェア*2であり、共通の枠組みで多数のサイトの認証を扱うことができる。
まず使用するパッケージをインストールする*3。 Passort では外部認証先ごとに Strategy と呼ばれるパッケージがあり、その一覧は こちら*4で見ることができる。 あるいは npmのサイトで検索してもいいだろう。 同じサイトに複数のStrategyの実装があったりするので、選定の際にはダウンロード数なども参考に信頼できそうなものを選ぶ必要がある。
電脳麻将では以下のパッケージをインストールした*5。
複数のサイトを使って認証する場合でも、最終的に得られるユーザ情報は共通の形式にすべきである*6。 電脳麻将では、どのような外部サイトからも一般的に得られると思われる以下の情報を使うことにした。
次にユーザ情報をセッションに保存・セッションから回復するときの処理を登録する。 ユーザ情報をすべてセッションに保存するなら以下とすればよい。
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 に設定する*8。 uid にはセッション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)|<--------| | |
| | | |
ここに登場するエンティティの意味は以下。
シーケンスは以下の流れになる。
/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 による前処理を追加する必要がある。
const app = express();
app.use(session);
app.use(passport.initialize());
app.use(passport.session());
これでHTTPリクエストを表現するオブジェクト req のプロパティ user にユーザ情報が設定されるようになる。
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を指定する。
これで外部認証が可能になるはずだ。 詳しくはソースプログラムも参照して欲しい。