Chapter 17

パスワードを安全に保存しよう

wkb
wkb
2021.01.29に更新

本章ではパスワードの扱いついて学び、パスワードの保存処理を適切なものに修正します。

パスワードの保存に潜む危険性とその対策を学ぼう

パスワードの保存処理は7章でユーザー登録機能を作成する際に実装しましたが、この保存処理にはある問題があります。

まずは、パスワードの保存処理が現在どのようになっているかを確認しましょう。

以下は、signup.jsでパスワードをデータベースに保存している箇所です (関連している箇所以外は省略しています)。

router.post('/', function (req, res, next) {

...
...
...

const password = req.body.password;
const repassword = req.body.repassword;

knex("users")
    .where({name: username})
    .select("*")
    .then(function (result) {
      if (result.length !== 0) {
        res.render("signup", {
          title: "Sign up",
          errorMessage: ["このユーザ名は既に使われています"],
          isAuth: isAuth,
        })
      } else if (password === repassword) {
        knex("users")
          .insert({name: username, password: password})
          .then(function () {
            res.redirect("/");
          })
          .catch(function (err) {
            console.error(err);
            res.render("signup", {
              title: "Sign up",
              errorMessage: [err.sqlMessage],
              isAuth: isAuth,
            });
          });
      } else {
        res.render("signup", {
          title: "Sign up",
          errorMessage: ["パスワードが一致しません"],
          isAuth: isAuth,
        });
      }
    })
    .catch(function (err) {
      console.error(err);
      res.render("signup", {
        title: "Sign up",
        errorMessage: [err.sqlMessage],
        isAuth: isAuth,
      });
    });

処理の流れはこのようになっています。

  1. サインアップページからPOSTリクエストを受け取る
  2. フォームの入力データ (パスワード等) を受け取る
  3. 入力されたユーザ名が既に使われていないかを確認する
  4. パスワードと再入力されたパスワードが一致することを確認する
  5. 一致した場合、他のデータと共にパスワードをデータベースに保存する

問題点は、最後の「パスワードをデータベースに保存する」のところでパスワードを そのまま データベースに保存している点です。
また、そのままのデータのことを平文といいます。

平文で保存すると、データベースに不正アクセスされた場合、加工されていない生のパスワードが漏洩してしまうことになります。

このように漏洩する危険を考慮し、パスワードを平文ではなく加工して保存する必要があります。

データを加工する技術の1つにハッシュ化があります。
ハッシュ化とはハッシュ関数という特殊な計算方式でデータを変換する技術で、変換されたデータは復元できません。
復元できないため、パスワードをハッシュ化して保存することで、万が一漏洩しても生のパスワードは分かりません。

bcryptでパスワードをハッシュ化しよう

それでは、パスワードをハッシュ化して保存するように処理を変更してみましょう。
今回は、「bcrypt」というモジュールを利用してパスワードをハッシュ化します。

まず、コンソール画面を開き、TodoAppフォルダに移動してください。

以下のコマンドで、「bcrypt」を導入しましょう。

npm install bcrypt

signup.jsを開き、require関数でbcryptをインポートしましょう。

const knex = require("../db/knex");の下に、以下を追加してください。

const bcrypt = require("bcrypt");

これで、bcryptを利用できる状態になりました。

bcryptでデータをハッシュ化する方法は以下です。

bcrypt.hash(ハッシュ化したいデータ, salt)

saltにはハッシュ化を実行する回数を指定します (一般的には10を指定します)。

それでは、signup.jsでパスワードを保存する処理の直前にパスワードをハッシュ化する処理を追加し、ハッシュ化したパスワードを保存するように変更しましょう。
該当の箇所は、パスワードと再入力されたパスワードが一致することを確認しているif文内です。

該当箇所は以下のようになります。

else if (password === repassword) {
  const hashedPassword = bcrypt.hash(password, 10);
  knex("users")
    .insert({name: username, password: hashedPassword})
    .then(function () {
      res.redirect("/");
    })
    .catch(function (err) {
      console.error(err);
      res.render("signup", {
        title: "Sign up",
        errorMessage: [err.sqlMessage],
        isAuth: isAuth,
      });
    });
}

const hashedPassword = bcrypt.hash(password, 10);を追加し、パスワードのハッシュ化とハッシュ化されたパスワードを定数hashedPasswordに格納しています。
また、.insert内のpasswordカラムに入れる値を定数hashedPasswordに変更しています。

サーバを起動し、サインアップしてみましょう。

エラーが発生し、サインアップできません。

const hashedPassword = bcrypt.hash(password, 10);の下にconsole.log(hashedPassword);を追加し、ハッシュ化されたパスワードが取得できるかを調べてみましょう。

サーバを再起動後、サインアップを行い、コンソール画面を確認してください。

console.log(hashedPassword);によってコンソール画面にPromise { <pending> }が出力されました。

これは処理中を意味しており、ハッシュ化処理が完了する前に次の処理に進んでしまったためハッシュ化されたパスワードを取得できず、データベースに保存するところでエラーとなっていたことが分かります。

では、JavaScriptのasync関数とawait演算子を用いて、ハッシュ化処理の完了を待って次の処理に進むようにしましょう。

await演算子はasync関数の中でのみ使用でき、await演算子を付けることで、その処理が完了するまでasync関数内の処理を停止できます。

const hashedPassword = bcrypt.hash(password, 10);は.thenで実行される関数内で行われる処理のため、以下のように25行目.then(function (result) {}のfunctionの前にasyncを付けます。

.then(async function (result) {}

このように、functionの前にasyncを付けることでasync関数を定義できます。

次に、完了を待つ必要がある処理にawait演算子を付けます。

const hashedPassword = await bcrypt.hash(password, 10);

現時点で、signup.jsのPOSTリクエストの処理部分は以下のようになります。

router.post('/', function (req, res, next) {
  const userId = req.session.userid;
  const isAuth = Boolean(userId);
  const username = req.body.username;
  const password = req.body.password;
  const repassword = req.body.repassword;

  knex("users")
    .where({name: username})
    .select("*")
    .then(async function (result) {
      if (result.length !== 0) {
        res.render("signup", {
          title: "Sign up",
          errorMessage: ["このユーザ名は既に使われています"],
          isAuth: isAuth,
        })
      } else if (password === repassword) {
        const hashedPassword = await bcrypt.hash(password, 10);
        console.log(hashedPassword);
        knex("users")
          .insert({name: username, password: hashedPassword})
          .then(function () {
            res.redirect("/");
          })
          .catch(function (err) {
            console.error(err);
            res.render("signup", {
              title: "Sign up",
              errorMessage: [err.sqlMessage],
              isAuth: isAuth,
            });
          });
      } else {
        res.render("signup", {
          title: "Sign up",
          errorMessage: ["パスワードが一致しません"],
          isAuth: isAuth,
        });
      }
    })
    .catch(function (err) {
      console.error(err);
      res.render("signup", {
        title: "Sign up",
        errorMessage: [err.sqlMessage],
        isAuth: isAuth,
      });
    });
});

それでは、サーバを再起動し、サインアップしてみましょう。

先ほどのエラーが解消され、サインアップできました。
コンソールを確認すると、先ほどはPromise { <pending> }になっていたハッシュ化されたパスワードが、今回は正常に取得できていることが分かります。

最後に、確認用に追加したconsole.log(hashedPassword);を削除してください。

パスワードの照合をハッシュ化に対応させよう

パスワードをハッシュ化して保存できましたが、今のままでは、サインイン時に入力したパスワードとデータベースに保存されているハッシュ化されたパスワードが当然一致しないため、サインインできません。
ここでは、ハッシュ化に対応したパスワードの照合方法に変更することで、サインインできるようにします。

まず、現状の確認として、サインアップを行い、そのサインアップしたユーザ情報でサインインしてください。

サインインできないことが確認できました。

signin.jsを開いてください。

現時点では、以下のように、入力されたユーザ名とパスワードに一致するユーザデータがusersテーブルから取得できるかどうかで認証を行っています。

const express = require('express');
const router = express.Router();
const knex = require("../db/knex");

router.get('/', function (req, res, next) {
  const userId = req.session.userid;
  const isAuth = Boolean(userId);
  res.render("signin", {
    title: "Sign in",
    isAuth: isAuth,
  });
});

router.post('/', function (req, res, next) {
  const userId = req.session.userid;
  const isAuth = Boolean(userId);
  const username = req.body.username;
  const password = req.body.password;

  knex("users")
    .where({
      name: username,
      password: password,
    })
    .select("*")
    .then(function (results) {
      if (results.length === 0) {
        res.render("signin", {
          title: "Sign in",
          errorMessage: ["ユーザが見つかりません"],
          isAuth: isAuth,
        });
      } else {
        req.session.userid = results[0].id;
        res.redirect('/');
      }
    })
    .catch(function (err) {
      console.error(err);
      res.render("signin", {
        title: "Sign in",
        errorMessage: [err.sqlMessage],
        isAuth: isAuth,
      });
    });
});

module.exports = router;

4章でユーザ名を管理するnameカラムにunique属性を付け、7章でサインアップ時にユーザ名の重複チェックを実装しました。
そのため、パスワードは含めず、入力されたユーザ名に一致するユーザデータがusersテーブルから取得できるかどうかで認証することもできます。

今回は、入力されたユーザ名を元にusersテーブル内のユーザデータを検索し、一致するデータが取得できた場合はそのユーザデータのパスワード (ハッシュ化済み) と入力されたパスワードを照合するような処理に変更しましょう。

まずは、usersテーブル内のユーザデータの検索条件からpasswordを除きます。

.where内は以下のようになります。

.where({
  name: username,
})

サーバを再起動し、先ほどサインインできなかったユーザ情報で再度サインインをすると、問題なくサインインできるようになっています。
これは先ほどのエラーの原因になっていたパスワードの照合を除いたためです。

では次に、usersテーブルから取得したユーザデータのパスワード (ハッシュ化済み) と入力されたパスワードで照合する処理を追加しましょう。

照合にbcryptを利用するため、const knex = require("../db/knex");の下に以下を追記し、bcryptをインポートしてください。

const bcrypt = require("bcrypt");

bcryptでパスワードとハッシュ化済みのパスワードを照合する方法は以下です。

bcrypt.compare(パスワード, ハッシュ化済みのパスワード)

.then内の先頭にパスワードとハッシュ化済みのパスワードを照合する処理を追加しましょう。

.then内は以下のようになります。

.then(function(results) {
  const comparedPassword = bcrypt.compare(password, results[0].password);
  if (results.length === 0) {
    res.render("signin", {
      title: "Sign in",
      errorMessage: ["ユーザが見つかりません"],
      isAuth: isAuth,
    });
  } else {
    req.session.regenerate((err) => {
      req.session.userid = results[0].id;
      req.session.username = results[0].name;
      res.redirect('/');
    });
  }
})

comparedPasswordのデータを確認するため、const comparedPassword = bcrypt.compare(password, results[0].password);の下に、console.log(comparedPassword);を追加しましょう。

サーバーを再起動し、サインインを行ってください。

コンソール画面を確認すると、console.log(comparedPassword);Promise { <pending> }が出力されています。

先ほど、パスワードをハッシュ化する際に発生したものと同様で、今回はパスワードとハッシュ化済みのパスワードを照合する処理が完了する前に次の処理に進んでしまっています。

先ほどのようにasync関数とawait演算子を用いて、この問題を解消しましょう。

.thenで実行されるfunctionにasyncを、パスワードとハッシュ化済みのパスワードを照合する処理にawaitをつけてください。

.then内は以下のようになります。

.then(async function (results) {
  const comparedPassword = await bcrypt.compare(password, results[0].password);
  console.log(comparedPassword);
  if (results.length === 0) {
    res.render("signin", {
      title: "Sign in",
      errorMessage: ["ユーザが見つかりません"],
      isAuth: isAuth,
    });
  } else {
    req.session.userid = results[0].id;
    res.redirect('/');
  }
})

サーバーを再起動し、サインインを行ってください。

コンソール画面を確認すると、console.log(comparedPassword);trueが出力されています。

これで、bcrypt.compare()はBoolean (真理値) を返すことが分かりました。

それでは、一旦以下の2行を削除してください。

const comparedPassword = await bcrypt.compare(password, results[0].password);
console.log(comparedPassword);

入力されたデータを元にユーザデータが取得できた場合、さらにawait bcrypt.compare(password, results[0].password);がtrueであればサインインを行うように変更しましょう。

.then内は以下のようになります。

.then(async function (results) {
  if (results.length === 0) {
    res.render("signin", {
      title: "Sign in",
      errorMessage: ["ユーザが見つかりません"],
      isAuth: isAuth,
    });
  } else if (await bcrypt.compare(password, results[0].password)) {
    req.session.userid = results[0].id;
    res.redirect('/');
  } else {
    res.render("signin", {
      title: "Sign in",
      errorMessage: ["ユーザが見つかりません"],
      isAuth: isAuth,
    });
  }
})

パスワードをハッシュ化する処理を追加する前にサインアップしたユーザ(root)でサインインしてみましょう。
パスワードの照合がfalseになり、サインインできない、が正しい挙動です。

ハッシュ化に対応したことで、rootユーザがサインインできなくなったため、ここで一旦usersテーブルを削除します。
MySQLにログインし、以下のSQL文を実行してください。

truncate todo_app.users;

最後に、rootユーザを作り直しましょう。
サーバを起動し、rootユーザをサインアップしてください。