Node.js + Express で 登録画面 を作る

0 件のコメント

よくある「登録機能」を作るときの ポイント および 具体的な実装方法 をまとめてみました。 個別な課題は調べることができますが、「登録機能を作る」という観点でまとめているので、どのようなことを気を付けなければならないかがこの記事で理解できると思います。 この記事でざっくりとしたイメージを持っていただいたうえで、個別の詳しい内容を調べるとより理解が深まると思います。

概要

店舗情報を登録する機能を実装してみます。 画面上は見た通りのテキストボックスとボタンしかありませんが、登録機能では以下の4点がポイントになります。 ポイントのうち3つ(CSRF対策、1フォーム複数サブミット、2重送信防止)については個別記事もありますので以下の「関連記事」をご参照ください。

  • CSRF対策
  • 1フォームに複数サブミット(複数遷移先)を設定
  • 2重送信防止
  • 再送信防止

画面

データ登録する画面は「入力」、「確認」、「完了」の3画面構成になっています。 実装詳細はあとで見ていくので、まずは作ろうとしているもののざっくりとした画面イメージです。

  • 入力 (/shop/regist/input)

  • 確認 (/shop/regist/confirm)

  • 完了 (/shop/regist/complete)

データベース

以下のようなドキュメントスキーマを想定します。 データ登録する画面なので、特に初期データは準備しません。

database
test
collection
shops
document
shop = {
  name:     { type: string },
  location: { type: string },
  tel:      { type: string }
}

実装

登録画面の具体的な実装のサンプルコードを以下に載せます。 以下のリストにはありませんが、別途 jQuery も含まれていますのでご注意ください。

/app.js

var express = require("express");
var cookie = require("cookie-parser");
var session = require("express-session");
var bodyParser = require("body-parser");

// Expressインスタンスを生成
var app = express();

// テンプレートエンジンの設定
app.set("views", "./views");
app.set("view engine", "ejs");

// ミドルウエアの設定
app.use("/public", express.static("public"));
app.use(cookie());
app.use(session({ secret: "YOUR SECRET SALT", resave: true, saveUninitialized: true }));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// ルーティングの設定
app.use("/shop", require("./router.js"));

// サーバー起動
app.listen(3000);

L2-4
セッションおよびクッキーの利用、フォームからのポストがあるので cookie-parserexpress-sessionbody-parser を利用します。

L21
今回はルーティング処理が複数存在して煩雑になるので、別モジュール( router.js )に掃き出します。

/router.js

var router = require("express").Router();
var co = require("co");
var MongoClient = require("mongodb").MongoClient;
var Tokens = require("csrf");
var tokens = new Tokens();

/**
 * リクエストボディーからデータを抽出
 */
var extract = function (request) {
  return {
    name: request.body.name,
    location: request.body.location,
    tel: request.body.tel,
    _csrf: request.body._csrf
  };
};

/**
 *  リクエストデータを検証
 */
var validate = function (data) {
  var errors = data.errors = [];

  if (!data.name) {
    errors[errors.length] = "会社名を指定してください。";
  }

  if (!data.location) {
    errors[errors.length] = "所在地を指定してください。";
  }

  if (!data.tel) {
    errors[errors.length] = "電話番号を指定してください。";
  }

  return errors.length === 0;
};

/**
 * リクエストデータを登録
 */
var commit = function (data, callback) {
  const URL = "mongodb://localhost:27017/test";

  return co(function* () {
    var db = yield MongoClient.connect(URL);
    var collection = db.collection("shops");
    var result = yield collection.updateOne(
      { name: { $eq: data.name } },
      { $set: data },
      { upsert: true },
      (error, result) => {
        db.close();
        callback && callback();
      });
  }).catch((reason) => {
    console.error(JSON.stringify(reason));
  });
};

/**
 * GET: /shop/regist/input
 */
router.get("/regist/input", function (request, response) {
  // 新規に 秘密文字 と トークン を生成
  var secret = tokens.secretSync();
  var token = tokens.create(secret);

  // 秘密文字はセッションに保存
  request.session._csrf = secret;

  // トークンはクッキーに保存
  response.cookie("_csrf", token);

  // 入力画面の表示
  response.render("./shop/regist/input.ejs");
});

/**
 * POST: /shop/regist/input
 */
router.post("/regist/input", function (request, response) {
  // 入力データを取得
  var data = extract(request);

  // 入力画面の再表示
  response.render("./shop/regist/input.ejs", data);
});

/**
 * POST: /shop/regist/confirm
 */
router.post("/regist/confirm", function (request, response) {
  // 入力データを取得
  var data = extract(request);

  // 入力データの検証
  if (validate(data) === false) {
    return response.render("./shop/regist/input.ejs", data);
  }

  response.render("./shop/regist/confirm.ejs", data);
});

/**
 * POST: /shop/regist/complete
 */
router.post("/regist/complete", function (request, response) {
  // 秘密文字 と トークン を取得
  var secret = request.session._csrf;
  var token = request.cookies._csrf;

  // 秘密文字 と トークン の組み合わせが正しいか検証
  if (tokens.verify(secret, token) === false) {
    throw new Error("Invalid Token");
  }

  // 入力データを取得
  var data = extract(request);

  // 入力データの検証
  if (validate(data) === false) {
    return response.render("./shop/regist/input.ejs", data);
  }

  // 登録処理
  commit(data).then(() => {
    // 使用済み 秘密文字 と トークン の無効化
    delete request.session._csrf;
    response.clearCookie("_csrf");

    // 完了画面へリダイレクト
    response.redirect("/shop/regist/complete");
  });
});

/**
 * GET: /shop/regist/complete
 */
router.get("/regist/complete", function (request, response) {
  response.render("./shop/regist/complete.ejs");
});

module.exports = router;

L67-68,71,74
入力画面が初回呼び出しされたとき、CSRF対策として 秘密文字 と トークン を新規発行します。 秘密文字はセッションへ保存し、トークンはクライアントのクッキーに保存します。

L111-112,115-117
確定画面が呼び出されたとき、実際にデータ登録処理を行いますが、この確定処理が正しく呼び出されているかを検証します。 入力画面が呼び出されたときに準備した 秘密文字 と トークン をそれぞれ取り出して組み合わせが正しいか検証を行います。 組み合わせに問題があればエラーとして返してしまいます。

L130-131
確定画面が呼び出され、データ処理も正常に終わると、まずは CSRF対策 として準備していた 秘密文字 と トークン を破棄して無効化します。 これにより同じ 秘密文字 と トークン の組み合わせが2度以上使えなくなります。 秘密文字 と トークン の再利用を許可しないことで、トークンが漏洩した場合の対策になります。

L134
確定画面が呼び出され、データ処理も正常に終わると、完了画面へ リダイレクト させます。 リダイレクトさせることで完了画面で F5 を押下したときに確定処理が再実行されることを防止します。

/public/scripts/shop/regist/confirm.js

(function () {
  // 「戻る」ボタン押下時に呼び出されます。
  var btnBack_onclick = function (event) {
    var $form = $("#form");
    $form.attr("action", "/shop/regist/input");
    $form.submit();
  };

  // 「登録」ボタン押下時に呼び出されます。
  var btnRegist_onclick = function (event) {
    var $form = $("#form");
    $form.attr("action", "/shop/regist/complete");
    $form.submit();
  };

  // ドキュメント読み込み完了時に呼び出されます。
  var document_onready = function (event) {
    $("#btn-back").on("click", btnBack_onclick);
    $("#btn-regist").on("click", btnRegist_onclick).focus();
  };

  $(document).ready(document_onready);
})();

「確認」画面には1つのフォーム内に「戻る」ボタンと「確定」ボタンがあります。 どちらを押したかによって遷移先が異なるため、それぞれのボタン押下時に formaction 属性を書き換えて遷移先を切り替えます。

L19
起動時に「確定」ボタンへフォーカスを当てておくことで、この画面へ遷移した直後に Enter キー 押下で次の画面へ進めるようにしておきます。 ちょっとした配慮ですが…前画面が入力画面でキーボード操作をしているので対応しておくのが望ましいと思います。

/public/scripts/shop/regist/protect-double-submit.js

(function () {
  "use strict"

  var onsubmit = function (event) {
    $("form").off("submit", onsubmit).on("submit", false);
  };

  $("form").on("submit", onsubmit);
})();

2重送信防止スクリプトです。 フォームで一度サブミットが押されると2度目以降のサブミットが無効になるよう実装しています。

/views/shop/regist/input.ejs

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>登録</title>
    <style>
    .alert {
      color: #a94442;
      background-color: #f2dede;
      border-color: #ebccd1;
    }
    </style>
  </head>
  <body>
    <h1>登録</h1>
    <% if (locals.errors) { %>
    <ul class="alert">
    <%   for (var i = 0; i < errors.length; i++) { %>
      <li><%= errors[i]%></li>
    <%   } %>
    </ul>
    <% } %>
    <form method="POST" action="/shop/regist/confirm">
      <div>
        <label for="name">会社名:</label>
        <input type="text" id="name" name="name" value="<%= locals.name ? name : ""%>"/>
      </div>
      <div>
        <label for="location">所在地:</label>
        <input type="text" id="location" name="location" value="<%= locals.location ? location : ""%>"/>
      </div>
      <div>
        <label for="tel">電話:</label>
        <input type="text" id="tel" name="tel" value="<%= locals.tel ? tel : ""%>" />
      </div>
      <div>
        <input type="submit" value="確認" />
      </div>
    </form>
    <script type="text/javascript" src="/public/third_party/jquery/dist/jquery.min.js"></script>
    <script type="text/javascript" src="/public/scripts/shop/regist/protect-double-submit.js"></script>
  </body>
</html>

L16-22
入力画面の初回表示でエラーが出ることはありませんが、入力したデータを検証したとき問題があった場合にエラー内容をこの部分に表示します。 ejd へは errors オブジェクトを渡すようにしていますが、存在有無をチェックする際は errors とせず locals.erros で判定します。 単純な errors だとオブジェクトがないと怒られてしまいます…。。

L26,30,34
入力値を検証した後、戻ってくる際、入力済みの値があれば復元します。 上記と同じく locals のプロパティとしてアクセスするようにします。

/views/shop/regist/confirm.ejs

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>確認</title>
    <style>
      input[readonly] {
        background-color: #eee;
        color: #555;
        border: 1px solid #ccc;
      }
    </style>
  </head>
  <body>
    <h1>確認</h1>
    <form id="form" method="POST" action="">
      <div>
        <span>会社名:</span>
        <input type="text" id="name" name="name" value="<%= name%>" readonly />
      </div>
      <div>
        <span>所在地:</span>
        <input type="text" id="location" name="location" value="<%= location%>" readonly />
      </div>
      <div>
        <span>電話:</span>
        <input type="text" id="tel" name="tel" value="<%= tel%>" readonly />
      </div>
      <div>
        <input type="button" id="btn-back" value="戻る" />
        <input type="submit" id="btn-regist" value="登録" />
      </div>
    </form>
    <script type="text/javascript" src="/public/third_party/jquery/dist/jquery.min.js"></script>
    <script type="text/javascript" src="/public/scripts/shop/regist/confirm.js"></script>
    <script type="text/javascript" src="/public/scripts/shop/regist/protect-double-submit.js"></script>
  </body>
</html>

L34-36
単一JavaScriptは読み込まれた順に実行されるので、スクリプトの読み込み順が重要です。 すべての基本となる jQuery () が一番最初、 次にフォーム内に複数ボタン配置して遷移先を変更するスクリプト( confirm.js )、 最後に2重送信防止のスクリプト ( protect-double-submit.js ) を読み込みます。

/views/shop/regist/complete.ejs

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>完了</title>
  </head>
  <body>
    <h1>完了</h1>
    <p>登録が完了しました。</p>
  </body>
</html>

「完了」画面は単純なHTMLなので、残念ながらあまり説明することはありません…。

テスト

上記までの実装が終わったらテスト実行してみます。 以下では「テスト」と呼ばれるような内容ではなく単純に正常系の動作を確認してみます。

  1. 入力画面

  2. 確認画面

  3. 完了画面

今回はデータ登録を行う画面の基本的な実装をテーマに Node.js + Express での実装をまとめてみました。 本記事(データ登録)におけるポイントは以下の通りです。

  • CSRF対策
    • csrf ミドルウェア を利用
    • 登録完了したら CSRF 秘密文字 と トークン を破棄
  • 同一フォーム内で複数遷移先へPOSTはJavaScriptで実装
  • 2重送信防止は JavaScript で実装
  • 完了画面はリダイレクト表示させることで F5 対策

関連記事