Edited at

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

More than 5 years have passed since last update.

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

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

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

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


  • / 投稿一覧

  • /posts 投稿一覧

  • /admin/sessions/new ログイン画面

  • /admin/posts 管理者向け投稿一覧(ログインしていないとみれない)


アプリケーションの作成

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

$ npm install -g express # まだ`express`をインストールしていない場合のみ

$ express -s express-bookshelf-user-auth
$ cd express-bookshelf-user-auth && npm install

ついでにUnderscoreもインストールしておきます、

$ npm install underscore --save


ルーティング

RESTfulなルーティングの定義をするためにexpress-resourceを導入します、

$ npm install express-resource --save

app.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サブアプリに切り出します、

$ mkdir admin

$ touch admin/index.js

admin/index.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に以下のダミーアクションを記述しておきます

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にログイン画面のルーティングを記述します、

exports.new = function(req, res) {

res.render('session/new');
};

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

$ mkdir views/session

$ touch views/session/new.jade

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にアクセスできることを確認します。

$ npm start


ログインの実装

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

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モデルの実装

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

$ npm install bookshelf sqlite3 knex bluebird bcrypt --save

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

module.exports = {

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

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

$ mkdir db

$ ./node_modules/.bin/knex migrate:make create_user -c ./config.js

作成されたマイグレーションファイルdb/migrations/<タイムスタンプ>_create_user.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');
};

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

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

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

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から作成します、

$ 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に以下を記述します、

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にアクセスするとトップにリダイレクトされます。