passport-local のエラーメッセージをなんとかする

## はじめに

[Node](https://nodejs.org) でのサーバアプリ開発では、APサーバとして [Express](http://expressjs.com) を使い、認証にはExpressミドルウェアの [Passport](http://passportjs.org) を使うのが一般的と思われる。しかし、Passportは「様々な認証方式を『戦略(Storategy)』という形に抽象化する」というコンセプトで設計・実装されているため小回りが利かない。ID/passwd の組による認証方式用のStrategyは [passport-local](https://www.npmjs.com/package/passport-local)として実装されているが、デフォルトではエラーメッセージすら出せない。これでは実業務に使えないのでなんとかならないか弄ってみた。

## 要求仕様

 1. 未認証でのアクセスを /login にリダイレクトする
 2. /login では ID/passwd を入力するフォームを表示する
 3. フォームを submit すると ID/passwd を /login にPOSTする
 4. 認証に成功したときは / にリダイレクトする
 5. 認証に失敗したときは /login にリダイレクトし、フォームにエラー理由を表示する。すでに入力済みのIDは保持し、passwd はクリアする。エラーメッセージは以下のいずれかとする

   - ID 未入力
   - passwd 未入力
   - 認証エラー (ID/passwdが一致しない場合)

5.の仕様がむやみに複雑だが、これが日本のSI案件標準。一方 passport-local はこの部分がおおらか。

## エラーメッセージのない実装

まずは、エラーメッセージ以外の箇所を実装してみる。

### 0. 前準備
```.js
const passport = require('passport');
const local    = require('passport-local');

app.use(passport.initialize());
app.use(passport.session());
```
``passport.initialize`` と ``passport.session`` は、``express-session``によるセッション復元処理より後に use する必要がある。[^1]

### 1. 未認証でのアクセスを /login にリダイレクトする
```.js
app.use((req, res, next)=>{
    if (! req.user) res.redirect('/login');
    else            next();
});
```
この処理は、ログインフォーム表示・認証処理より後、APの処理より前に行う必要がある。
``else next();`` が何ともダサい。

### 2. /login では ID/passwd を入力するフォームを表示する
```.js
app.get('/login', (req, res)=>res.render('login'));
```
HTMLはテンプレートエンジンで作成するのが「いまどき」の模様。今はまだないが、エラーメッセージはパラメータで渡すことになる。

### 3. フォームを submit すると ID/passwd を /login にPOSTする
テンプレートはこんな感じ(Pug形式)。

```login.pug
doctype html
html(lang="ja")
  head
    title ログイン
  body
    form.local(method="POST" action="/login")
      div Username:
        input(name="login" value=login)
      div Password:
        input(name="passwd" type="password")
      div
        input(type="submit" value="Login")
      div.error #{error}
```
``login`` と ``error`` が外部から渡される想定の変数。

### 4. 認証に成功したときは / にリダイレクト<br>5. 認証に失敗したときは /login にリダイレクト
```.js
app.post('/login',
    passport.authenticate('local', { successRedirect: '/',
                                     failureRedirect: '/login' })
);
```
``'local'`` に対応するStorategyは事前に以下のように登録しておく。
ID/passwd の正しい組み合わせは ``Passwd`` という配列にある想定。

```.js
passport.use(new local.Strategy(
    (login, passwd, done)=>{
        let user = Passwd.find(u=>u.id == login && u.passwd == passwd);
        if (! user)  return done(null, false);
        user = { id: `${user.id}@local`, name: user.name, icon: user.icon };
        return done(null, user);
    }
));
```
``done()`` というコールバック関数で結果を通知する。第1引数は処理中に捕捉した例外、第2引数は失敗のときは ``null``、成功のときはリクエストごとに参照するユーザ情報のオブジェクト。

## エラーメッセージの追加

passport-localでは認証用のURLにPOSTした後、その結果によって別のURLにリダイレクトする。これは極めてまともな実装なのだが、エラーが発生したリクエストと、エラーを表示するリクエストが別なのでセッションをまたがってエラー内容を引き継ぐ必要が生じる。[^2]

これを実現するExpressミドルウェアに[connect-flash](https://www.npmjs.com/package/connect-flash)がある[^3]。connect-flash を use するとリクエストオブジェクトに ``req.flash()`` というメソッドが生えるので、これを通してエラーメッセージをやり取りすることができる。

```.js
const flash    = require('connect-flash');

app.use(flash());
```

connect-flash が有効になると passport-local では ``passport.authenticate()`` で ``failureFlash: true`` の設定ができるようになる。

```.js
    passport.authenticate('local', { successRedirect: '/',
                                     failureRedirect: '/login',
                                     failureFlash:    true      })
```
``failureFlash: true`` を設定すると、コールバック関数 ``done()`` の第3引数でエラーメッセージが設定できるようになる。

```.js
passport.use(new local.Strategy(
    (login, passwd, done)=>{
        let user = Passwd.find(u=>u.id == login && u.passwd == passwd);
        if (! user)  return done(null, false, { message: '認証エラー'});
        user = { id: `${user.id}@local`, name: user.name, icon: user.icon };
        return done(null, user);
    }
));
```

こうしておいて、ログインフォーム表示の際に ``req.flash()`` で、エラーメッセージをパラメータとして渡せばよい。

```.js
app.get('/login', (req, res)=>res.render('login', req.flash()));
```

ただし、この方法には問題がある。``passport.authenticate()`` はフォームに空欄があったときに Storategy で登録した認証関数を呼び出さず、自前の **``Missing credentials``** というメッセージを勝手に設定してしまう。

これを避けるために、以下のように ``passport.authenticate()`` でエラーメッセージを指定することもできる。

```.js
    passport.authenticate('local', { successRedirect: '/',
                                     failureRedirect: '/login',
                                     failureFlash:    '認証エラー'})
```

しかし、このやり方もうまくいかない。``failureFlash`` にエラーメッセージを指定すると、それが認証プロセス全体の共通メッセージとなってしまい、コールバック関数 ``done()`` の第3引数が再び無効になってしまうからだ。

どうやら passport-local の作者と日本のSIerのエラーメッセージに対する考え方はすり合わせられないようである。

## 解決策

では、どうするか。要求仕様にあった、認証に失敗したときに「すでに入力済みのIDは保持」という要件もまだ満たせていないではないか。

もはや自前で ``req.flash()`` を弄るしかない。/login にPOSTがあったとき、passport-local より先にフォームの入力値を **横取り** し、``req.flash()`` で保存するしかない。

```.js
app.post('/login',
    (req, res, next)=>{                         /* 横取り開始 */
        req.flash('login', req.body.login);         /* ID を 'login' に保存 */
        if (! req.body.login) {                     /* ID未入力の場合 */
            req.flash('error', 'Usernameが未入力');
            res.redirect('/login');                 /* 認証処理は呼ばない */
        }
        else if (! req.body.passwd) {               /* ID未入力の場合 */
            req.flash('error', 'Passwordが未入力');
            res.redirect('/login');                 /* 認証処理は呼ばない */
        }
        else next();                      /* フォームに入力があれば認証処理を呼ぶ */
    },
    passport.authenticate('local', { successRedirect: '/',
                                     failureRedirect: '/login',
                                     failureFlash:    true      })
);
```

ログインフォーム表示も若干の手直しが必要。

```.js
app.get('/login', (req, res, next)=>{
    let {login, error} = req.flash();
    res.render('login', {login: login && login[0], error: error});
});
```

これでなんとか要件は満たせたが、ここまで手間がかかると passport-local を使うこと自体の有用性も疑わしくなる。日本人のエラーメッセージに対するこだわりが細か過ぎるのかもしれないなあ、という気はしました。

全体のプログラムはこちらにあります。
- https://github.com/kobalab/test-node/blob/passport-local/server.js

[^1]: が、passport-local のドキュメントにはその記載がない。
[^2]: 多くの業務APではこの問題を回避するために、エラーがあった場合はPOSTの延長で画面を表示し、リダイレクトしないという実装をされる場合が多い。この実装は「戻るボタンで再登録」の問題を発生させる。
[^3]: express-flash という同種の競合モジュールがあるようだが、connect-flash の方が使われている模様。