mongooseを使った認証について考える

# はじめに
これはただの実装例です。

## 使うもの
* mongoose
* nodeのcrypto （bcryptを使った実装はネット上にあります）

## 実装例
単純な「ユーザー名」と「パスワード」による認証を実装します。
パスワード生成には crypto.pbkdf2 を使い、そのほかのハッシュアルゴリズムやストレッチ回数などを下記のようなフォーマットで保存することとします。

`password: $pbkdf2-digest$iterations$salt$hash`

digest: ハッシュアルゴリズム
iterations: ストレッチ回数
salt: ソルト
hash: ハッシュの計算結果

まず、モデルの定義から。

```js:user.js
'use strict';

const crypto = require('crypto');
const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const userSchema = new Schema({
  username: {type: String, required: true, index: {unique: true}},
  password: {type: String, required: true}
});

userSchema.pre('save', function (next) {
  const salt_length = 22;
  const iterations = 100000;
  const key_length = 64;
  const digest = 'sha512';

  // salt生成
  crypto.randomBytes(salt_length, (error, buffer) => {
    if (error) return next(error); // TODO 適切なエラー処理を入れてください

    crypto.pbkdf2(this.password, buffer, iterations, key_length, digest, (error, key) => {
      if (error) return next(error); // TODO 適切なエラー処理を入れてください

      const salt = buffer.toString('hex');
      const hash = key.toString('hex');

      this.password = `$pbkdf2-${digest}$${iterations}$${salt}$${hash}`;
      next();
    });
  });
});

function authenticate(candidate) {
  return new Promise((resolve, reject) => {
    this.findOne({username: candidate.username}, (error, user) => {
      if (error) return reject(error); // TODO 適切なエラー処理を入れてください

      // 解説
      // ここで、userが見つからなかった場合も、処理を続けます。
      // 見つからない場合にすぐに応答を返すと、攻撃者のヒントになりかねないのではないでしょうか。

      const password = user ? user.password : '';
      const re = /^\$(\w+)-(\w+)\$(\d+)\$(\w+)\$(\w+)$/;
      const match = re.exec(password) || [];

      const func = match[1] || 'pbkdf2';
      const digest = match[2] || 'sha512';
      const iterations = match[3] || 100000;
      const salt = match[4] || crypto.randomBytes(22).toString('hex');
      const hash = match[5] || crypto.randomBytes(64).toString('hex');
      const key_length = hash.length / 2;

      crypto[func](candidate.password, new Buffer(salt, 'hex'), parseInt(iterations, 10), key_length, digest, (error, key) => {
        if (error) return reject(error); // TODO 適切なエラー処理を入れてください

        if (key.toString('hex').localeCompare(hash) === 0) {
          resolve(user); // パスワードの一致が確認できた
        } else {
          reject(new Error('ユーザー名、またはパスワードが違います'));
        }
      });
    });
  });
}

// スキーマのstaticなmethodとして定義します。
userSchema.statics.authenticate = authenticate;

mongoose.model('User', userSchema);
```

実際に使うところのサンプルは、下記のような感じです。

```js
'use strict';

const mongoose = require('mongoose');
require('./user');

mongoose.connect('mongodb://localhost:27017/test', (error) => {
  if (error) throw error; // TODO 適切なエラー処理を入れてください
});

const User = mongoose.model('User');

// ためしにテストユーザーを作ってみます
const testUser = new User({
  username: 'momunchu',
  password: 'password'
});

// saveすることでスキーマに登録していたpre'save'が働き、パスワードがハッシュ化されます
testUser.save(error => {
  if (error) throw error; // TODO 適切なエラー処理を入れてください

  // save後、正しいパスワードでuser情報が得られるか確認してみましょう
  User.authenticate({
    username: 'momunchu',
    password: 'password'
  })
  .then(user => {
    console.log('test1: path');
    console.log(user);
  })
  .catch(error => {
    console.log('test1: NG');
    console.log(error);
  });

  // 間違ったパスワードを入力した場合、エラーとなることを確認しましょう
  User.authenticate({
    username: 'momunchu',
    password: 'パスワード'
  })
  .then(user => {
    console.log('test2: NG');
    console.log(user);
  })
  .catch(error => {
    console.log('test2: path');
    console.log(error);
  });

  // ユーザー名もパスワードもでたらめな場合も、処理時間はなるべく変わらないようにしています
  User.authenticate({
    username: 'モム人',
    password: 'パスワード'
  })
  .then(user => {
    console.log('test3: NG');
    console.log(user);
  })
  .catch(error => {
    console.log('test3: path');
    console.log(error);
  });
});
```

一度saveすると、二回目以降の実行結果はusernameのduplicateエラーになります。

## その他
utf-8でコードを書けば、普通にuft-8のusernameやpasswordでも動きます。