2015年4月29日水曜日

Express4 + Passport を使った会員サイトの簡単サンプル

今回はMVCフレームワークの Express4 と 認証用ミドルウェアの Passport を使って簡単な会員サイトのサンプルアプリを作ってみたいと思います。


今回利用する主なモジュールのバージョンは以下の通りです。

node v0.12.2
express v4.12.1
mongod v2.4.9
passport v0.2.1
passport-local v.1.0.0
passport-local-mongoose v.1.0.0

今回のサンプルアプリではデータベースに MongoDB を利用します。MongoDB のインストールが未だでしたらこちらを参考にMongoDBをインストールしておいてください。

Expressプロジェクトの雛形を作成


Express4なプロジェクトの雛形を作るためにまずは express-generator をインストールして
$ sudo npm install -g express-generator

雛形生成を実行します。プロジェクト名は passport-express4-example としました。
$ express passport-express4-example

生成されたExpress4プロジェクト構成はこのようになります。
$ tree passport-express4-example/
passport-express4-example/
├── app.js
├── bin
│   └── www
├── package.json
├── public
│   ├── images
│   ├── javascripts
│   └── stylesheets
│       └── style.css
├── routes
│   ├── index.js
│   └── users.js
└── views
    ├── error.jade
    ├── index.jade
    └── layout.jade

依存関係を追加


package.json に dependencies (依存ライブラリ)を追加しましょう。太字は追加した箇所です。

{
  "name": "passport-express4-example",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/www"
  },
  "dependencies": {
    "body-parser": "~1.12.0",。。。。。
    "cookie-parser": "~1.3.4",
    "debug": "~2.1.1",
    "express": "~4.12.2",
    "jade": "~1.9.2",
    "morgan": "~1.5.1",
    "serve-favicon": "~2.2.0",
    "express-session": "^1.10.1",
    "mongoose": "^4.0.2",
    "passport": "^0.2.1",
    "passport-local": "^1.0.0",
    "passport-local-mongoose": "^1.0.0",
    "should": "~2.1.0",
    "chai": "~1.8.1",
    "mocha": "~1.14.0"
  }
}

npm install で依存モジュールをインストールします。

$ cd passport-express4-example
$ sudo npm install

さて、ここで一旦サンプルアプリが動くことを確認しておきましょう。

$ npm start

http://localhost:3000/ にアクセスしてWelcomeページが表示されているか確認してください。
ちゃんと動いていることが確認できたら Ctrl+C でサンプルアプリを強制終了して次に進みます。

MongoDBの起動


次に MongoDB を起動します。
ここでは datapath と logpath を指定していますが、ここは皆さんの環境に合わせて書き換えてください。

$ sudo mongod --dbpath ~/tmp/mongodb/data/ --logpath ~/tmp/mongodb/mongodb.log &

app.js


では app.js を編集しましょう。こちらも太字が追加した箇所です。

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var mongoose = require('mongoose');
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;


var routes = require('./routes/index');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

// uncomment after placing your favicon in /public
//app.use(favicon(__dirname + '/public/favicon.ico'));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({
  extended: false
}));
app.use(cookieParser());

app.use(require('express-session')({
  secret: 'secret secret',
  resave: false,
  saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());

app.use(express.static(path.join(__dirname, 'public')));

app.use('/', routes);


// passport config
var Account = require('./models/account');
passport.use(new LocalStrategy(Account.authenticate()));
passport.serializeUser(Account.serializeUser());
passport.deserializeUser(Account.deserializeUser());
// mongoose
mongoose.connect('mongodb://localhost/passport_local_mongoose_express4');


// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handlers

// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
  app.use(function(err, req, res, next) {
    res.status(err.status || 500);
    res.render('error', {
      message: err.message,
      error: err
    });
  });
}

// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
  res.status(err.status || 500);
  res.render('error', {
    message: err.message,
    error: {}
  });
});


module.exports = app;

modelsを作成


次にプロジェクトルート直下に models フォルダを作り、account.js を新規作成します。これは MongoDB に格納されるユーザアカウント情報のモデルです。

var mongoose = require('mongoose');
var Schema = mongoose.Schema;
var passportLocalMongoose = require('passport-local-mongoose');

var Account = new Schema({
  username: String,
  password: String
});

Account.plugin(passportLocalMongoose);

module.exports = mongoose.model('Account', Account);

routesを作成


次にプロジェクトルート直下の routes フォルダの index.js を以下のように編集します。今回はルーティング情報を index.js に集約させることにしましょう。

var express = require('express');
var passport = require('passport');
var Account = require('../models/account');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  console.log("req.user", req.user);
  res.render('index', {
    user: req.user
  });
});

/* GET register page */
router.get('/register', function(req, res) {
  res.render('register', {});
});

/* Register a user account */
router.post('/register', function(req, res) {
  Account.register(new Account({
    username: req.body.username
  }), req.body.password, function(err, account) {
    console.log("req.body.username", req.body.username);
    if (err) {
      console.log("err", err);
      return res.render("register", {
        errInfo: "Sorry. That username already exists. Try again."
      });
    }
    passport.authenticate('local')(req, res, function() {
      res.redirect('/');
    });
  });
});

/* GET login page */
router.get('/login', function(req, res) {
  res.render('login', {
    user: req.user,
    errInfo: req.query.err
  });
});

/* Try to login */
router.post('/login', passport.authenticate('local', {
  failureRedirect: "/login?err=unauthorized"
}), function(req, res) {
  res.redirect('/');
});

/* Do logout */
router.get('/logout', function(req, res) {
  req.logout();
  res.redirect('/');
});

/* GET member-only page */
router.get('/member', function(req, res) {
  if (!req.isAuthenticated()) res.redirect("/login");
  res.render('member', {
    user: req.user
  });
});

/* For checking if webserver will be dead or alive */
router.get('/ping', function(req, res) {
  res.status(200).send("pong!");
});

module.exports = router;

ここまできたらもう一度アプリを立ち上げてエラーが無いか確認しておきましょう。

$ npm start 

問題なくアプリが立ち上がったら http://localhost:3000/ping を叩いてみてください。/ping はアプリの生存確認のために作ったAPIです。画面に pong! が返ってくればアプリは正常に動いています。

viewsを作成


次に画面を編集 or 新規作成します。画面を定義する jade ファイルが views フォルダ配下にあります。

layout.jade (全画面共通レイアウト)
doctype html
html
  head
    title= title
    meta(name='viewport', content='width=device-width, initial-scale=1.0')
    link(href='http://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css', rel='stylesheet', media='screen')
    link(rel='stylesheet', href='/stylesheets/style.css')
  body
    block content

  script(src='http://code.jquery.com/jquery.js')
  script(src='http://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js')

index.jade (トップページ)
extends layout

block content
  if (!user)
    a(href="/login") Login
    br
    a(href="/register") Register
  if (user)
    p You are currently logged in as #{user.username}
    a(href="/logout") Logout


login.jade (ログインページ)
extends layout

block content
  .container
    h1 Login Page
    p.lead Say something worthwhile here.
    br
    form(role='form', action="/login",method="post", style='max-width: 300px;')
      .form-group
          input.form-control(type='text', name="username", placeholder='Enter Username')
      .form-group
        input.form-control(type='password', name="password", placeholder='Password')
      button.btn.btn-default(type='submit') Submit
       
      a(href='/')
        button.btn.btn-primary(type="button") Cancel

register.jade (会員登録ページ)
extends layout

block content
  .container
    h1 Register Page
    p.lead Say something worthwhile here.
    br
    form(role='form', action="/register",method="post", style='max-width: 300px;')
      .form-group
          input.form-control(type='text', name="username", placeholder='Enter Username')
      .form-group
        input.form-control(type='password', name="password", placeholder='Password')
      button.btn.btn-default(type='submit') Submit
       
      a(href='/')
        button.btn.btn-primary(type="button") Cancel
      br
      h4(style="color:red")= errInfo

member.jade (会員専用ページ)
extends layout

block content
  if(user)
    p Hi #{user.username}. This page is member-only.
    a(href="/logout") Logout

express4 + passport な会員サイトのサンプルアプリは以上で出来上がりです。
出来上がったアプリの機能一覧は以下のとおりとなります。

トップページ
http://localhost:3000/
会員登録ページ
http://localhost:3000/register
ログインページ
http://localhost:3000/login
会員専用ページ
http://localhost:3000/member
ログアウト
http://localhost:3000/logout

Unit Test


アプリができたら念の為Unitテストもしておきましょう。
初めに package.json の dependencies に追加した mocha, chai, should はUnitテストのためのモジュールです。
プロジェクトルートに test フォルダを新規作成し、 test.user.js を新規作成します。

var should = require("should");
var mongoose = require('mongoose');
var Account = require("../models/account.js");
var db;

describe('Account', function() {

  before(function(done) {
    db = mongoose.connect('mongodb://localhost/test');
    done();
  });

  after(function(done) {
    mongoose.connection.close();
    done();
  });

  beforeEach(function(done) {
    var account = new Account({
      username: '12345',
      password: 'testy'
    });

    account.save(function(error) {
      if (error) console.log('error' + error.message);
      else console.log('no error');
      done();
    });
  });

  it('find a user by username', function(done) {
    Account.findOne({
      username: '12345'
    }, function(err, account) {
      account.username.should.eql('12345');
      console.log("   username: ", account.username);
      done();
    });
  });

  afterEach(function(done) {
    Account.remove({}, function() {
      done();
    });
  });

});

上記の test.user.js ではユーザアカウントの新規登録・取得・削除までをひと通りテストします。
以下のコマンドでUnitテストを実行することができます。

$ node_modules/mocha/bin/mocha test/ test.user.js
child_process: customFds option is deprecated, use stdio instead.

  no error
   username:  12345
․

  1 passing (222ms)

エラーは検出されませんでしたので、これにてUnitテストも無事終了です。

■参考にさせていただいたサイト
User Authentication with Passport and Express 4 - Michael Herman

■関連記事
Express4+PassportでGoogle OAuth認証の簡単なサンプル

0 件のコメント:

コメントを投稿