Dominoアクセスになんちゃって認証を追加する

前回「DominoとNode.jsの同居サーバ構築 - Chiburu Systemsのブログ」でNodeからDominoにRESTを通じてデータベースのタイトルを取得し、Webページに反映させました。このとき、認証は埋め込みのユーザ名、パスワードを使いました。今回は、それをWebユーザが認証するところまで持って行きます。

理想を言えば、Dominoで認証してセッションを回すようにしたいところですが、遺憾ながら今回はセキュリティもへったくれもない、非常になんちゃって感の強い方法にしました。その点はご容赦いただきたく。

前回のプロジェクトから追加したパッケージは以下の通りです。

  • connect-flash
  • crypto
  • express-session
  • mongoose
  • passport
  • passport-local

これらを、「npm install --save ****」としてプロジェクトに追加します。追加後のpackage.jsonは以下の通りです。

{
  "name": "domtest",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/www"
  },
  "dependencies": {
    "body-parser": "~1.18.2",
    "connect-flash": "^0.1.1",
    "cookie-parser": "~1.4.3",
    "crypto": "^1.0.1",
    "debug": "~2.6.9",
    "express": "~4.15.5",
    "express-session": "^1.15.6",
    "mongoose": "^4.12.0",
    "morgan": "~1.9.0",
    "node-rest-client": "^3.1.0",
    "passport": "^0.4.0",
    "passport-local": "^1.0.0",
    "pug": "2.0.0-beta11",
    "serve-favicon": "~2.4.5"
  }
}

MongoDB、Mongoose

mongooseはmongodbをNode.jsで扱うモジュールになります。

www.mongodb.com

Mongoose ODM v4.12.0

mongodbはNoSQLデータベースで、データ形式JSONに似たBSONというもので、JavaScriptと相性もいいので、Node.jsでもよく使われます。今回、mongodbは認証の保管先として使います。最終的に認証をするのはDominoのユーザ情報ですが、Node.jsとDominoはクライアント-サーバの関係なので、Node.js側にログイン情報を持たなければなりません。そこでmongodbを使用して、ユーザが入力したユーザ名、パスワードをお預かりしなければならないので、少しいびつな形をしています。また、mongodbに預けたパスワードは、頑丈なセキュリティで守るか、復号化可能な仕組みで暗号化をかけないと、だだ漏れる危険があります。今回はそのような措置もとらないので、「なんちゃって認証」としてあらかじめお断りしています。

mongodbのインストールやセットアップは他のサイトに譲り、ここでは、mongodbにアカウントを一つ追加するコンソール操作を示します。

> use domtest
switched to db domtest
> db.users.find()
> db.users.insert({username:'*****',password:'*****'})
WriteResult({ "nInserted" : 1 })
> db.users.find()
{ "_id" : ObjectId("59d9c17b4697f5ca11fc044a"), "username" : "*****", "password" : "*****" }

最初の「use domtest」は「データベースdomtestをデフォルトにします。」という意味で、SQLのように「CREATE DATABASE」する必要はありません。以降の「db」はこのdomtestを表すようになります。

「db.users」というのは、domtestデータベースのusersテーブルというような意味になりますが、これも「CREATE TABLE」せずに使えます。このあたりはNotesDBの「フォーム設計がなくても文書を追加できる」のと似ています。「findメソッド」は文字通り検索コマンドで、引数の指定がなければすべてのレコードを表示します。例えば「db.users.find({username:'John'})とすれば、usernameが「John」であるユーザを検索して表示します。「insertメソッド」はテーブルにアイテムを追加します。テーブル設計なしにアイテムを追加できるところがNoSQLらしさですね。

express-session

express-sessionはExpressのサブモジュールで、Webサーバのセッション機能を司るミドルウェアです。かつてはExpressに同梱されていたようですが、現在は分離しているので、別途インストール必要があります。

connect-flash

connect-flashはセッション機能に「メッセージフラッシュ」機能を追加するモジュールです。通常ページ感のデータ受け渡しはセッションに名前付きのデータを追加することでできますが、メッセージフラッシュは、ログイン時の成功、失敗のような一時的なデータの出し入れを楽にしてくれます。メッセージを追加するには、

req.flash('error', {message:'ログインに失敗しました。'});

とし、取り出すときは、

req.flash('error')
// {message: 'ログインに失敗しました。'} を返す。

となり、フラッシュデータの'error'オブジェクトは呼び出し後に削除されます。

crypto

パスワードのハッシュ化のために使いますが、ハッシュ化してしまったパスワードはREST呼び出し時には使えないので、今回は参考として入れています。

app.js

app.jsの役割はExpress全体の初期設定になります。

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 session = require('express-session');

// フラッシュメッセージ
var flash = require('connect-flash');

// 【カスタム】認証機能の初期化
var auth = require('./auth.js');

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

// 【カスタム】ログイン用のルーティング
var login = require('./routes/login');

var app = express();

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

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

// セッション機能を追加する。
app.use(session({
  secret: "notesdomino"
  , saveUninitialized: false
  , resave: false
}));

// フラッシュ機能を追加する。
app.use(flash());

// パスポートモジュールを追加する。
app.use(auth.passport.initialize());
app.use(auth.passport.session());

app.use('/', index);
app.use('/users', users);

// ログイン用のルーティング追加する。
app.use('/login', login.router(auth.passport));

// 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 handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

auth.js

app.js内に追加されているカスタムモジュールは2つです。そのうちの一つがauth.jsで、人気のある認証モジュール「Passport」を使って、認証機能を設定しています。

passportjs.org

Passportは認証機能を単純化するだけでなく、その魅力はFacebookTwitterなど多くの認証サイトのストラテジ(認証方式)を持っていることです。今回は基本的なローカルストラテジ(ユーザ名、パスワード方式)を使います。このユーザ情報の保管、検索先がmongodbになり、mongodbを扱うモジュールがmongooseになります。

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

// 本来ならパスワードをハッシュ化したいところ。
// var crypto = require('crypto');
//
// var secretKey = "NotesDomino";
// var getHash = function(target)
// {
//   var sha = crypto.createHmac("sha256", secretKey);
//   sha.update(target);
//   return sha.digest("hex");
// };

// MongoDBに接続。
var db = mongoose.createConnection(
  "mongodb://localhost/domtest"
  , function(error, res){}
);

// ユーザ情報のスキーマ。
var UserSchema = new mongoose.Schema({
  username: {type: String, require: true}
  , password: {type: String, require: true}
});

// ユーザ情報のモデル。
var User = db.model("User", UserSchema);

// パスワード検証用メソッド。
User.prototype.validPassword = function(pass)
{
  return this.password === pass;
  // return this.password === getHash(pass);
};

// セッションサポート用の直列化、非直列化。

passport.serializeUser(function(user, done)
{
  done(null, user._id);
});
passport.deserializeUser(function(id, done)
{
  User.findById(id, function(err, user)
  {
    done(err, user);
  });
});

// ユーザ検証戦略の設定。
passport.use(new LocalStrategy(
  function (username, password, done)
  {
    User.findOne({ username: username }, function(err, user)
    {
      if (err) { return  done(err); }
      if (!user) {
        return done(null, false, {message: 'ユーザID/パスワードが違います。'});
      }
      if (!user.validPassword(password))
      {
        return done(null, false, {message: 'ユーザID/パスワードが違います。'});
      }
      return done(null, user);
    })
  }
));

module.exports = {
  passport: passport
};

routes/login.js

ログイン用のルーティングは、GET用と、POST用に分かれます。GETはログイン画面の表示に、POSTはログインデータの検査に使います。

// ログイン用のルーティング。
var express = require('express');
var router = express.Router();

module.exports = {
  // Passportオブジェクトを受け取ります。
  "router": function(passport)
  {
    // GET時はログイン画面の表示。
    router.get('/', function(req, res)
    {
      res.render('login', {
        message: req.flash('error')
      })
    });

    // POST時はログイン検査。
    router.post('/'
      , passport.authenticate('local'
        , {
          successRedirect: '/'
          , failureRedirect: '/login'
          , failureFlash: true
        }
      )
    );
    return router;
  }
};

views/login.pug

ログインルーティングにある「res.render('login', ...);」の「login」は、viewsフォルダ内のlogin.pugテンプレートを参照しています。極めて簡単なログイン画面を作ります。

extends layout

block content
  if(message)
    p= message
  form(action='/login', method='post')
    input(type='text', name='username')
    br
    input(type='password', name='password')
    br
    input(type='submit', value='Login')

messageの部分は、res.renderの第2引数が参照元になります。

views/index.pug

index.pugの方は、ログインへのリンクが追加されただけになります。

extends layout

block content
  h1= title
  p Welcome to #{title}
  a(href="/login") Login

routes/index.js

正しくログインした場合、そのログイン情報を持って、DominoにRESTでアクセスし、前回紹介したように目的のデータベースのタイトルを表示するようになります。

var express = require('express');
var router = express.Router();

// RESTクライアントモジュールを使用する。
var Client = require('node-rest-client').Client;

/* GET home page. */
router.get('/', function(req, res, next)
{
  // リクエスト中にユーザ情報が含まれているか。
  if (req.user)
  {
    // 認証オブジェクトを使ってRESTクライアントを作成。
    var client = new Client({
      user: req.user.username
      , password: req.user.password
    });

    client.get('http://localhost/api/data', function(data, response)
    {
      // レスポンスが配列でない場合はエラー。
      if (!(data instanceof Array))
      {
        res.render('index', { title: 'Express(' + data.message + ')' });
        console.log(data);
      }
      else
      {
        var item = data.find(function(elem, index, array)
        {
          return elem['@filepath'] === 'domtest.nsf';
        });
        res.render('index', { title: item['@title'] });
      }
    })
    .on('error', function (err)
    {
      res.render('index', { title: 'Express(Error)' });
      console.log(err);
    });
  }
  // ユーザ情報がないときの表示。
  else
    res.render('index', { title: 'Domino REST' });
});

module.exports = router;

動作確認

プロジェクトフォルダ上で、「npm start」とすれば、localhost:3000でアクセスできるようになります。データサービスが使える同一マシン上のDominoサーバも立ち上げておいて下さい。

f:id:takahide-kondoh:20171008201023p:plain

未認証の場合はこのようになります。リンクをクリックして、ログイン画面に遷移します。

f:id:takahide-kondoh:20171008201038p:plain

例えば、ログインに失敗すると次のようになります。

f:id:takahide-kondoh:20171008201052p:plain

ログインに成功すれば、トップページのタイトルが、NotesDBのタイトルに変化します。

f:id:takahide-kondoh:20171008201107p:plain

さいごに

Dominoが持っている認証機能をどのように肩代わりするか、その辺りが課題として残りましたが、パスワードデータの保護方法が確立できれば、DominoのデータをJSONで扱えるようになり、DominoのWebサーバとしての負担を軽減することができます。

ユーザの認証ができるようになったので、次はWebSocketによるリアルタイム通信にチャレンジしてみたいと思います。