Lesson 11

認証方式の紹介

Lesson 11 Chapter 1
ユーザー認証

Lesson11では、Webアプリケーションに欠かせない「ユーザー認証」について見ていきましょう。ここでのユーザー認証とは、アプリケーションの利用者を登録されている情報と照らし合わせ、識別する仕組みを指します。

一例として、Webブラウザからチャットツールを使う場面を思い浮かべてください。最初にチャットツールを使う際は、ログイン画面にIDとパスワードを入力し、自分のアカウントからメッセージを投稿するはずです。

この時、Webアプリケーションはリクエストを送ってきた人物が誰であるかを識別しています。相手がアカウント本人と確認できなければ、もちろんメッセージの投稿はできません。

それでは、どのような仕組みでユーザー認証は行われているのでしょうか。本章では、まずユーザー認証の方式について説明し、その実装へと進んでいきます。

認証方式の種類

繰り返しになりますが、ユーザー認証はWebアプリケーションの利用者かどうかを識別する仕組みです。

具体的なユーザー認証の方法として、大きく次の2種類が挙げられます。

認証方法 説明
パスワード認証 IDとパスワードをサーバーに送り、アプリケーション内で認証する。
OpenID Connect 外部のプロバイダが発行するIDトークンで認証する

以下でそれぞれ詳しく見ていきましょう。

パスワード認証

パスワード認証では、「ID」と「パスワード」を使ってユーザーを識別します。インターネットの初期から使われており、一般に広く普及している方式です。

パスワード認証では、通常アプリケーションの開発者がユーザー認証のロジックを考え、コードに落とし込みます。ただし、近年は認証機構を備えたフレームワークやライブラリも多く登場していおり、実装自体は特に難しくありません。

注意すべき点として、パスワード認証にはセキュリティ上のリスクが伴います。ユーザーの多くは複数のサービスで同一のID・パスワードを使い回しており、流出の危険性が高いためです。

不正ログインを防止するため、できるだけワンタイムパスワードや二段階認証など、他の仕組みと組み合わせるようにしてください。

OpenID Connect

一方のOpenID Connectでは、外部のプロバイダが発行する「IDトークン」を使ってユーザー認証を行います。

IDトークンとは、以下の画像のように英数字と記号を組み合わせた文字列で、OpenIDプロバイダの認証を経て発行されます。

id-token-example.png IDトークンの例

IDトークンを用いた認証の流れは以下の通りです。

  1. クライアントアプリがOpenIDプロバイダにIDトークンの発行を要求する
  2. OpenIDプロバイダは保存している情報をもとにユーザー認証を行う
  3. ユーザーの本人確認後、OpenIDプロバイダはクライアントアプリにIDトークンを発行する
  4. ユーザーはIDトークンを用いて各サービスにログインする

OpenIDの利点として、ユーザーの認証情報を一元的に管理しつつ、複数のWebサービスにログインできる点が挙げられます。ユーザーにとってはパスワード管理の手間が省けますし、アプリにとってはアカウント情報を自身のサーバーで管理する手間が省けます。

OpenID Connectはユーザー・開発者の両方にとってメリットが大きいことから、パスワード認証に代わる方式として導入が進んできました。

情報

OpenID Connectは、「OAuth」と呼ばれる仕組みがベースになっています。もともとOAuthは、アクセストークンを発行して外部サービスへの「認可」を与える仕組みでした。OpenID Connectはこの仕組みを拡張し、認可だけでなく認証も行えるように標準化したのです。

次のChapterでは、実際にExpress+EJSを用いて簡単な認証画面を作成してみましょう。

Lesson 11 Chapter 2
認証画面を作成

前回のChapter1では、ユーザー認証の仕組みを解説しました。「認証」と聞くと難しく感じられるかもしれませんが、Node.jsの外部モジュールを利用することで簡単に実装できます。

EJSによるログイン画面の作成とあわせて、さっそく取り組んでみましょう。

認証画面の実装

認証ロジックの前に、まずはEJSを使ったログイン画面の実装から説明します。これまで学んだ知識を活かし、IDとパスワードの入力フォームを作成してみましょう。

プロジェクトの作成

プロジェクトのディレクトリ構成は以下の通りです。

11-2-tree.png ファイル構成

コマンドラインから入力する場合、以下に従って入力してください。

~\working-directory\signin-form\(Windows)
npm init -y
New-Item app.js
mkdir views
New-Item views/signin.ejs
                    
~/working-directory/signin-form/(Mac / Linux)
npm init -y
touch app.js
mkdir views
touch views/signin.ejs
                    

次のコマンドを実行し、必要となるモジュールをインストールしてください。

  • npm install express
  • npm install ejs

ログイン画面の作成

まずは、「app.js」を次のように編集してください。

app.js
const express = require("express");
const app = express();
const port = 5000;

app.set("engine", "ejs");
app.set("views", "./views");

app.get("/", (req, res) => {
  res.render("./signin.ejs");
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

現時点では、特に特別な記述はありません。今回は5000番ポートを使用している点に注意しましょう。

次は「views」ディレクトリ内の「signin.ejs」を次のように編集してください。

signin.ejs
<!DOCTYPE html>
  <body>
    <form>
      <label>ユーザーID</label>
      <input type="text" name="userId">
      <label>パスワード</label>
      <input type="password" name="password">
      <button type="submit">認証実行</button>
    </form>
  </body>
</html>

inputタグを使用し、ユーザID(userId)とパスワード(password)の入力フォームを作成しています。

ファイルの準備が整ったら、コマンドラインからnode app.jsを入力し、サーバーを起動します。

Webブラウザから「http://localhost:5000」にアクセスして、次のような表示になることを確認してください。

authentication-form.png 表示される認証画面

以上で、認証画面の作成は完了です。次のChapterでは、いよいよ認証ロジックの実装に入ります。

Lesson 11 Chapter 3
認証ロジックを作成

Chapter3では、本章のメインである認証ロジックの実装に入ります。

今回の認証ロジックには「Passport」を利用します。Passportは、Node.js用の認証ミドルウェアとして広く使われているモジュールです。

一般的なパスワード認証はもちろん、本章のChapter1で触れたOpenID Connect認証にも対応するなど、高い拡張性を特徴としています。

本章ではPassportの公式ページの記述を参考にサンプルコードを記述しています。

なお、コードの説明は最後に行います。先にサンプルコードを実行し、認証に成功/失敗したときの挙動を確認してみましょう。

プロジェクトの準備

まずは新規に「authentication-logic.js」と「user-info.txt」のファイルを作成します。

プロジェクトのディレクトリ構成は以下の通りです。

11-1-tree.png ファイル構成

コマンドラインから作成する場合は以下に従ってください。

~\working-directory\signin-form\(Windows)
New-Item authentication-logic.js user-info.txt
~/working-directory/signin-form/(Mac / Linux)
touch authentication-logic.js user-info.txt

プロジェクト直下で次のコマンドを入力し、必要なモジュールをインストールしてください。

  • npm install passport
  • npm install passport-local
  • npm install express-session

以上でプロジェクトの準備は完了です。

認証ロジックの記述

次に、「app.js」を次の通り編集してください。

app.js
const express = require("express");
const passport = require("passport");
const app = express();
const port = 5000;

app.set("engine", "ejs");
app.set("views", "./views");

app.use(express.urlencoded({extended: false}));

require("./authentication-logic")(app);

app.get("/", (req, res) => {
  res.render("./signin.ejs");
});
  
app.post(
"/signin",
passport.authenticate("local", {
  successRedirect: "/success",
  failureRedirect: "/"
})
);

app.get("/success", (req, res) => {
  res.send("認証成功");
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

続いて、「authentication-logic.js」を以下の通り編集してください。

authentication-logic.js
const crypto = require("crypto");
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const expressSession = require("express-session");
const fs = require("fs");

module.exports = function (app) {

passport.serializeUser((user, done) => {
  done(null, user.id);
});

passport.deserializeUser((id, done) => {
  done(null, id + "と一致するユーザー情報");
});

passport.use(
  new LocalStrategy(
    {
      usernameField: "userId",
      passwordField: "password"
    },
    (userId, password, done) => {
      new Promise((resolve, reject) => {
        fs.readFile("user-info.txt", (err, data) => {
          if (err) reject(err);
          resolve(data);
        });
      })
        .then((data) => {
          const buf = data.toString().split(",");
          const myUserId = buf[0];
          const myPassword = buf[1];

          const passwordHashValue = crypto
            .scryptSync(password, "salt", 24)
            .toString("base64");

          if (myUserId === userId && myPassword === passwordHashValue) {
            return done(null, { id: myUserId, verify: true });
          } else {
            return done(null, false);
          }
        })
        .catch((error) => {
          return done(null, false);
        });
    }
  )
);

app.use(
  expressSession({
    secret: "sample-secret",
    name: "session",
    resave: false,
    saveUninitialized: true,
    cookie: {
      path: "/",
      httpOnly: true,
      secure: false
    }
  })
);

  app.use(passport.initialize());
  app.use(passport.session());
};

さらに、「user-info.txt」を以下の通り記述してください。

user-info.txt
userA,pY6ABXWr2SgCW4eOWArQF0EtkR5POJes

最後に、「signin.ejs」を次のように編集してください。

signin.ejs
<!DOCTYPE html>
  <body>
    <form action="/signin" method="post">
      <label>ユーザID</label>
      <input type="text" name="userId">
      <label>パスワード</label>
      <input type="password" name="password">
      <button type="submit">認証実行</button>
    </form>
  </body>
</html>

認証ロジックの動作確認

ファイルの準備が整ったら、コマンドラインからnode app.jsを入力してサーバーを起動します。

Webブラウザで「http://localhost:5000」にアクセスして、次の表示になることを確認してください。

authentication-form.png 認証画面の表示

次は、認証画面に以下の値を入力し、「認証実行」をクリックしてください。

  • ユーザーID: 「userA」
  • パスワード: 「sample」

authentication-form-inputed.png 認証画面の入力内容

ユーザーID・パスワードが正しければページ遷移します。「認証成功」と表示されることを確認してください。

authentication-success.png 認証成功の表示

また、先ほどの認証画面でユーザーID・パスワードを上記以外の値にしてみましょう。今度は「認証実行」をクリックしても「認証成功」のページは表示されません。

以上が、認証ロジックのコードと挙動です。ここからは、処理の流れを追いながらコードの説明をしていきます。

認証ロジックの解説

ここで実装した処理の流れは次の通りです。

  1. 認証画面に入力されたユーザーID・パスワードを送信する
  2. 送信されたユーザーID・パスワードが登録内容と一致するか照合する
  3. 送信されたユーザーID・パスワードが正しければ認証成功画面を表示する

それでは、それぞれの処理に対応するコードを見ていきましょう。

1. ユーザーID・パスワードの送信

まずは、認証画面に入力されたユーザーID・パスワードを送信する部分です。この部分は「signin.ejs」が担っています。

<form action="/signin" method="post">の部分で、データの送信先・送信方法を決めています。今回は、/signin宛てに「POST」メソッドでデータを送るように指定しました。

2. ユーザーID・パスワードの照合

次は、送信されたユーザーID・パスワードが登録内容と一致するか照合する部分です。認証ロジックの肝と言える箇所であり、さらに次の処理に細分化されます。

  1. 送信されたユーザーID・パスワードを受け取る
  2. アプリケーションに登録されたユーザーID・パスワードのハッシュ値を読み出す
  3. 入力されたパスワードからハッシュ値を生成する
  4. 入力されたユーザーIDとアプリケーションに登録されたユーザーIDを比べる
  5. 入力されたパスワードから生成したハッシュ値とアプリケーションに登録されたパスワードのハッシュ値を比べる

以下で詳しく見ていきましょう。

【参考】ハッシュ値とは

先ほどの説明で「ハッシュ値」という新しい言葉が出てきました。ユーザー認証で使われるハッシュ値は、次の画像のようなものです。

hash-value.png ユーザー認証で使われるハッシュ値の例

ハッシュ値は「ハッシュ関数」により生成されます。ハッシュ関数とは、任意の長さのデータを固定長のデータに圧縮する関数で、次の性質を持ちます。

  • 出力値から入力値を発見することが困難である
  • ある入力値Aと同じハッシュ値となる別の入力値Bを求めることが困難である
  • 同じ出力値を生成する2つの入力値を発見することが困難である

ブラウザに入力された英数字の形式でパスワードを保存すると、万が一保存したパスワードが流出してしまった際、すぐに悪用されてしまいます。ハッシュ値にすることで、ブラウザに入力された英数字の形式でパスワードの流出を防げます。

そのような事情もあり、パスワードをアプリケーションに保存する際、ハッシュ値にして保存することが一般的です。もちろん、ハッシュ値にしてパスワードを保存したからといって、パスワードが流出した際、問題がないということではありません。

2.1 送信されたユーザーID、パスワードを受け取る

さて、コードの説明に戻ります。送信されたユーザーID・パスワードを受け取る処理は、次の部分です。

app.js
//中略

app.post(
  "/signin",
  passport.authenticate("local", {
    successRedirect: "/login-success",
    failureRedirect: "/failure-redirect"
  })
);

//中略

ここで、/signinにPOSTメソッドで送られたリクエストに対する処理を定義しています。送られたHTTPリクエストは「authentication-logic.js」に記述したpassport.use(...)の内容通りに処理されます。

2.2 登録されたユーザーID、パスワードのハッシュ値を読み出す

「authentication-logic.js」に記述したpassport.use(...)の処理内容は次の通りです。

  • アプリケーションに登録されたユーザーID、パスワードのハッシュ値を読み出す
  • 入力されたパスワードからハッシュ値を生成する
  • 入力されたユーザーIDとアプリケーションに登録されたユーザーIDを照合する
  • 入力されたパスワードから生成したハッシュ値とアプリケーションに登録されたパスワードのハッシュ値を照合する

アプリケーションに登録されたユーザーID・パスワードのハッシュ値を読み出す処理は、次の部分です。今回は「user-info.txt」というファイルから、ユーザーID・パスワードのハッシュ値を読み出すようにしています。

authentication-logic.js
//中略
passport.use(
  new Promise((resolve, reject) => {
    fs.readFile("user-info.txt", (err, data) => {
      if (err) reject(err);
      resolve(data);
    });
  })
  .then((data) => {
    const buf = data.toString().split(",");
    const myUserId = buf[0];
    const myPassword = buf[1];
    //中略
    ).catch(
    //中略
  )
)
// 中略

「user-info.txt」は、カンマを区切り文字として、ユーザーID・パスワードのハッシュ値を1行に記述しています。ファイルの読み込みに成功したときは、分割した値を変数に代入する仕組みです。

2.3 入力されたパスワードからハッシュ値を生成する

次は、入力されたパスワードからハッシュ値を生成する部分です。

authentication-logic.js
//中略
  passport.use(
    //中略
      (userId, password, done) => {
        new Promise(
          //中略
        }).then(
          //中略
        const passwordHashValue = crypto
        .scryptSync(password, "salt", 24)
        .toString("base64");
      ).catch(
      //中略
    )
  }
)
//中略

今回、ハッシュ値の生成にはNode.jsが提供する「crypto」というモジュールを利用しました。crypto.scryptSync以下の部分で、入力されたパスワードからエンコードした24桁のハッシュ値を生成しています。

2.4 入力ユーザーIDと登録されたユーザーIDを照合する

2.5 入力パスワードから生成したハッシュ値と登録されたパスワードのハッシュ値を照合する

次は、入力されたユーザーIDと登録されたユーザーID、入力されたパスワード(ハッシュ値)と登録されたパスワード(ハッシュ値)を照合する箇所です。

authentication-logic.js
//中略
passport.use(
  //中略
  (userId, password, done) => {
    new Promise(
      //中略
      ).then(
      //中略
    if (myUserId === userId && myPassword === passwordHashValue) {
      return done(null, { id: myUserId, verify: true });
    } else {
      return done(null, false);
    }
  ).catch(
    //中略
    )
  }
)
//中略

照合自体はif文で記述して問題ありません。今回、 Passportを利用して認証ロジックを実装しています。そのため、認証情報が正しいとき、認証情報に誤りがあるときについては、Passportの公式ページの内容を参考に記述しています。

認証情報が正しいときは、doneの第2引数に認証済みのユーザー情報を与えます。今回は、done(null, { id: myUserId, verify: true })と記述しています。また、認証情報に誤りがあるときは、doneの第2引数にfalseを与えます。

ここまでが、送信されたユーザーID・パスワードがアプリケーションに登録されている内容と一致するか照合する部分です。

3. 認証成功画面の表示

最後は、認証成功画面を表示する部分です。

app.js
//中略
app.get("/success", (req, res) => {
  res.send("認証成功");
});
//中略

今回は、認証処理に成功したときsuccessRedirect: "/success"により送信されるHTTPリクエストを処理します。

その他のコード解説

ここまで、処理の流れに沿ってコードを解説しました。ここからは、まだ言及できていない箇所について解説します。

express.urlencodedメソッド

express.urlencodedは、POSTされたフォームのデータをExpressで処理するため必要な記述です。また、 PassportがGitHub上で公開している例に従い、extended: falseと指定しています。

app.js
// 中略

// POSTされたフォームのデータをexpressで処理するために必要
app.use(express.urlencoded({extended: false}));

// authentication-logic.js を読み込む
require("./authentication-logic")(app);

// 中略

2行目require("./authentication-logic")(app)は「authentication-logic.js」を読み込むための記述です。

他のサービスを利用した認証

Passportは、GoogleやFacebookなどを利用した認証を実装することもできます。例えば、Facebookを利用した認証を実装するときは、passport-facebookという専用のモジュールを利用して実装します。

今回は、ユーザーID・パスワードによる認証を独自に実装するため、passport-localというモジュールを利用して、次のように記述しています。

authentication-logic.js
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
// 中略
passport.use(
  new LocalStrategy(
    {
      usernameField: "userId",
      passwordField: "password"
    },
    (userId, password, done) => {
    // 中略
    }
  )
)
// 中略

上記のpassport.use(...)の部分が、Passportを使って認証ロジックを実装する記述方法です。また、usernameFieldpasswordFieldには、「signin.ejs」に記述したフォーム内のinput要素name属性で指定した値をそれぞれ記述しています。

シリアライズ/デシリアライズ機能

次に説明する部分は、Passport独自の機能であり、本質的には認証ロジックと関係がありません。認証した後、ユーザー情報が扱いやすくなるようにと、Passportが気を利かせて提供している機能です。

今回、利用している機能は、次の2つです。

  • シリアライズ機能: req.session.passport.userにデータを保存する
  • デシリアライズ機能: req.userにデータを保存する
authentication-logic.js

const expressSession = require("express-session");

// 中略

passport.serializeUser((user, done) => {
  done(null, user.id);
});

passport.deserializeUser((id, done) => {
  done(null, id + "と一致するユーザー情報");
});

// 中略

app.use(
  expressSession({
    secret: "sample-secret",
    name: "session",
    resave: false,
    saveUninitialized: true,
    cookie: {
      path: "/",
      httpOnly: true,
      secure: false
    }
  })
);

app.use(passport.initialize());
app.use(passport.session());

シリアライズ機能により、添付画像のようにデータをセッションに保存できます。認証に成功したユーザーのユーザーIDを保存することが一般的です。

passport-serialize.png VSCodeのデバッグ機能を利用した req.session.passport.user の確認

デシリアライズ機能により、添付画像のようにデータをreq.userに保存することができます。デシリアライズの際には、ユーザーIDと一致するユーザー情報をデータベースから取得して、req.userに保存することが多いです。

データベース接続

今回は認証ロジックの解説に重点を置くため、データベースとの連携は行いません。

passport-deserialize.png VSCodeのデバッグ機能を利用した req.user の確認

app.use(expressSession(...)の部分は、シリアライズ機能がセッションを使う関係で、必要な記述です。今回は、express-sessionモジュールを導入して、セッションを使うようにしています。

app.use(passport.initialize())は、Passportを初期化するために必要な記述です。また、app.use(passport.session())は、デシリアライズ機能を利用するために必要な記述です。

今回は、Passportを利用した認証ロジックの実装を見てきました。モジュールを利用することで、自前で実装するより簡単に記述できる上に、保守性が高いコードが実現可能です。