Express.jsとBookshelf.jsでユーザ認証

Node.jsにおいて[Express](http://expressjs.com/)はWebアプリケーション作成時のほぼデファクトスタンダードですが、こんかいはさらにORMに[Bookshelf](http://bookshelfjs.org/)をもちいた場合のユーザ認証です。BookshelfはBackboneを下敷きにプロミスインタフェースを提供するRDB向けORMです。MySQL、PostgreSQL、Sqlite3をサポートしています。バリデーションロジック等をもっておらずシンプルなことが特徴です。

ユーザ認証は多くのアプリケーションにとって必須の機能です。今回は単純なユーザ認証付きのブログアプリケーションを作成してみます。作成したアプリケーションは以下リポジトリにおいて有ります：

https://bitbucket.org/p_baleine/express-bookshelf-user-auth

ルーティングは以下の通り:

 * / 投稿一覧
 * /posts 投稿一覧
 * /admin/sessions/new ログイン画面
 * /admin/posts 管理者向け投稿一覧(ログインしていないとみれない)
 
## アプリケーションの作成

`express`コマンドでアプリケーションを作成します、

```bash
$ npm install -g express # まだ`express`をインストールしていない場合のみ
$ express -s express-bookshelf-user-auth
$ cd express-bookshelf-user-auth && npm install
```

ついでに[Underscore](http://underscorejs.org/)もインストールしておきます、

```bash
$ npm install underscore --save
```

## ルーティング

RESTfulなルーティングの定義をするために[express-resource](https://github.com/visionmedia/express-resource)を導入します、

```bash
$ npm install express-resource --save
```

`app.js`を以下の通り変更します:

```js

/**
 * Module dependencies.
 */

var express = require('express')
  , _ = require('underscore') // ← 追加
  , posts = require('./routes/posts') // ← 追加
  , admin = require('./admin') // ← 追加
  , http = require('http')
  , path = require('path')
  , Resource = require('express-resource'); // ← 追加

var app = module.exports = express(); // ← 変更

app.configure(function(){
  app.set('port', process.env.PORT || 3000);
  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');
  app.use(express.favicon());
  app.use(express.logger('dev'));
  app.use(express.bodyParser());
  app.use(express.methodOverride());
  app.use(express.cookieParser('your secret here'));
  app.use(express.session());
  app.use(app.router);
  app.use(express.static(path.join(__dirname, 'public')));
});

app.configure('development', function(){
  app.use(express.errorHandler());
});

// ↓変更

app.get('/', posts.index);

app.resource('posts', _.pick(posts, 'index', 'show')); // 未ログインだと`index`と`show`のみアクセス可

// adminサブアプリをマウント
app.use('/admin', admin);

if (!module.parent) {
  app.listen(app.get('port'));
  console.log("Express server listening on port " + app.get('port'));
}
```

ユーザ認証は`admin`サブアプリに切り出します、

```bash
$ mkdir admin
$ touch admin/index.js
```

`admin/index.js`に以下を記載します、

```js

/**
 * Module dependencies.
 */

var express = require('express')
  , posts = require('../routes/posts')
  , sessions = require('../routes/sessions')
  , http = require('http')
  , path = require('path')
  , Resource = require('express-resource')
  , User = require('../models/user');

var app = module.exports = express();

app.configure(function(){
  app.set('port', process.env.PORT || 3000);
  app.set('views', __dirname + '/../views');
  app.set('view engine', 'jade');
  app.use(express.favicon());
  app.use(express.logger('dev'));
  app.use(express.bodyParser());
  app.use(express.methodOverride());
  app.use(express.cookieParser('your secret here'));
  app.use(express.session());
  app.use(app.router);
  app.use(express.static(path.join(__dirname, 'public')));
});

app.configure('development', function(){
  app.use(express.errorHandler());
});

app.resource('posts', posts);
app.resource('sessions', sessions);
```

`routes/posts.js`に以下のダミーアクションを記述しておきます

```js
exports.index = function(req, res) {
  res.send('投稿一覧、' + (req.user ? 'ログイン済' : '未ログイン'));
};

exports.show = function(req, res) {
  res.send('投稿 ' + req.params.post + 'の詳細');
};

exports.new = function(req, res) {
  res.send('新規投稿作成画面');
};

exports.cretate = function(req, res) {
  res.send('新規投稿作成');
};

exports.edit = function(req, res) {
  res.send('投稿 ' + req.params.post + 'の編集画面');
};

exports.update = function(req, res) {
  res.send('投稿 ' + req.params.post + 'の更新');
};

exports.del = function(req, res) {
  res.send('投稿 ' + req.params.post + 'の削除');
};
```

### ログイン画面の実装

`routes/sessions.js`にログイン画面のルーティングを記述します、

```js
exports.new = function(req, res) {
  res.render('session/new');
};
```

`views/session/new.jade`を作成します、

```bash
$ mkdir views/session
$ touch views/session/new.jade
```

```js
extends ../layout

block content
  h1 ログイン
  if error
    #error= e
  form(action="/admin/sessions" method="post")
    .form-group
      label(for="user[email]")
      input(type="email" name="user[email]" plaeholder="Enter your email")
    .form-group
      label(for="user[password]")
      input(type="password" name="user[password]")
    input(type="submit" value="ログイン")

```

ためしに以下コマンドでサーバを立ち上げて`http://localhost:3000/admin/sessions/new`にアクセスできることを確認します。

```bash
$ npm start
```

### ログインの実装

`routes/sessions.js`にログインのルーティングを記述します、`User`モデルの`authenticate`メソッドで認証できた場合のみ`/admin/posts`にリダイレクトします、

```js
var User = require('../models/user');

exports.create = function(req, res) {
  var user = req.body.user;

  User.authenticate(user.email, user.password).then(function(user) {
    req.session.uid = user.id;
    res.redirect('/posts');
  }, function(e) {
    req.session.uid = null;
    res.statusCode = 404;
    res.render('session/new', { error: e.message });
  });
};
```

### Userモデルの実装

必要になるモジュールをインストールしておきます、

```bash
$ npm install bookshelf sqlite3 knex bluebird bcrypt --save
```

データベースの設定を`config.js`に記述します、これはアプリケーションでデータベースに接続する時とマイグレーション実行時に利用します、

```js
module.exports = {
  "directory": "./db/migrations",
  "database": {
    "client": "sqlite3",
    "connection": {
      "filename": "./db/blog-dev.db"
    },
    "debug": true
  }
};
```

以下コマンドでマイグレーションのファイルを作成します、

```bash
$ mkdir db
$ ./node_modules/.bin/knex migrate:make create_user -c ./config.js
```

作成されたマイグレーションファイル`db/migrations/<タイムスタンプ>_create_user.js`を編集します、

```js
exports.up = function(knex, Promise) {
  return knex.schema.createTable('users', function(t) {
    t.increments().primary();
    t.string('email').notNull();
    t.string('password').notNull();
    t.string('salt').notNull();
    t.dateTime('created_at').notNull();
    t.dateTime('updated_at').nullable();
  });  
};

exports.down = function(knex, Promise) {
  return knex.schema.dropTable('users');  
};
```

マイグレーションを実行します、

```bash
$ ./node_modules/.bin/knex migrate:latest -c ./config.js
```

`Bookshelf`はプロミスインタフェースを提供しているので、自前のメソッドもやはりプリミスベースで記述すると利用しやすいです。`models/user.js`を以下の通り作成します。ユーザの新規作成時に`hashPassword`メソッドでパスワードを暗号化します。`authenticate`スタティックメソッドでは与えられた`email`からユーザをselectして、パスワードの比較をします。


```js
var Promise = require('bluebird'),
    bcrypt = Promise.promisifyAll(require('bcrypt')),
    Bookshelf = require('bookshelf'),

    // database setting
    config = require('../config'),

    // initialize database
    blogBookshelf = Bookshelf.blogBookshelf = Bookshelf.initialize(config.database);

/**
 * User model
 */

var User = module.exports = blogBookshelf.Model.extend({

  tableName: 'users',

  hasTimestamps: true,

  initialize: function() {
    blogBookshelf.Model.prototype.initialize.apply(this, arguments);
    this.on('saving', this.hashPassword, this);
  },

  /**
   * Set salted password.
   */

  hashPassword: function() {
    var _this = this;

    return bcrypt.genSaltAsync(10)
      .then(function(salt) {
        _this.set('salt', salt);
        return bcrypt.hashAsync(_this.get('password'), salt);
      })
      .then(function(hash) {
        return _this.set('password', hash);
      });
  }

}, {

  /**
   * Authenticate user.
   *
   * @param {String} email
   * @param {String} password
   * @return {Promise}
   */

  authenticate: function(email, password) {
    return new this({ email: email }).fetch({ require: true })
      .then(function(user) {
        return [bcrypt.hashAsync(password, user.get('salt')), user];
      })
      .spread(function(hash, user) {
        if (hash === user.get('password')) { return user; }
        throw new Error('password or email is incorrect.');
      });
  }

});
```

テスト用のユーザをreplから作成します、

```bash
$ node
> var User = require('./models/user');
> new User({ email: 'p.baleine@gmail.com', password: 'mypassword' }).save().then(function(user) {
  console.log(user);
  });
```

サーバを立ち上げて`/admin/sessions/new`にアクセスし先程作成したユーザのメールアドレスとパスワードを入力すると投稿一覧に遷移できます。

### アクセス制限

最後に管理者権限のない人のアクセスを制限します、`admin/index.js`に以下を記述します、

```js
var User = require('../models/user');

app.all(/^\/posts/, restrict, loadUser);

app.resource('posts', posts);
app.resource('sessions', sessions);

function restrict(req, res, next) {
  if (!req.session.uid) { return res.redirect('/'); }
  next();
}

function loadUser(req, res, next) {
  if (!req.session.uid) { return next(); }
  new User({ id: req.session.uid }).fetch().then(function(user) {
    req.user = user;
    next();
  });
}
```

ログインしていない人が`/admin/posts`で始まるURLにアクセスするとトップにリダイレクトされます。