Node.jsにおいてExpressはWebアプリケーション作成時のほぼデファクトスタンダードですが、こんかいはさらにORMにBookshelfをもちいた場合のユーザ認証です。BookshelfはBackboneを下敷きにプロミスインタフェースを提供するRDB向けORMです。MySQL、PostgreSQL、Sqlite3をサポートしています。バリデーションロジック等をもっておらずシンプルなことが特徴です。
ユーザ認証は多くのアプリケーションにとって必須の機能です。今回は単純なユーザ認証付きのブログアプリケーションを作成してみます。作成したアプリケーションは以下リポジトリにおいて有ります:
ルーティングは以下の通り:
- / 投稿一覧
- /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にアクセスするとトップにリダイレクトされます。