Node.js + Express + passport で 認証認可 の 仕組み を 作る

0 件のコメント

Node.js + Express + Passport + MongoDB を利用したユーザー認証 および アクセス制御 の実装を行ってみます。 今回は3画面作成するので、少し前置き(全体像)から入っていきます。

概要

今回は認証認可処理の基本的な実装として、以下のようなフローを実現したいと思います。 作成する画面、画面遷移、機能は以下のようなものになります。 …なんだかんだと書いていますが、作成するのは「機能一覧」にある機能を実装していきます。

フロー

画面一覧

画面名 URL 説明
トップ /home/index トップページ。 誰でもアクセスできる。
ログイン /account/login ユーザー認証するためのログイン画面。
プロフィール /acount/profile ユーザー認証後に会員情報を表示する画面。

画面遷移

機能一覧

機能名 メソッド URL 認可要否
トップ画面を表示する GET /home/index 不要
ログイン画面を表示する GET /account/login 不要
ログインする POST /account/login 不要
ログアウトする POST /account/logout 必要
プロフィール画面を表示する GET /account/profile 必要

プロジェクト準備

フォルダ構成

<PROJECT_ROOT>
│  .bowerrc
│  app.js
│  bower.json
│  package.json
│
├─models
│      user.js
│
├─node_modules
│  ├─body-parser
│  ├─connect-flash
│  ├─cookie-parser
│  ├─express-session
│  ├─mongoose
│  ├─passport
│  └─passport-local
│
├─public
│  └─third_party
│      ├─bootstrap
│      ├─font-awesome
│      └─jquery
│
└─views
    ├─account
    │      login.ejs
    │      profile.ejs
    │
    └─home
            index.ejs

初期化

npm と bower を使うので、まずはそれぞれ初期化(npm init , bower init)します。 bower については保存先を /public/third_party/ 配下としたいので .bowerrc ファイルも作成します。

.bowerrc

{
  "directory": "public/third_party"
}

詳しくは以下のページに記載していますので、参照してください。

パッケージの追加

利用するパッケージおよびそれぞれの依存関係は以下の通りです。 記載のモジュールを一通り取り込んでおきます。

npm

パッケージ 依存
mongoose -
body-parser -
cookie-parser -
express-session -
connect-flash
cookie-parser
express-session
passport
body-parser
connect-flash
passport-local passport

bower

パッケージ 依存
jquery -
font-awesome -
bootstrap jquery

データベース作成

スキーマ

idemail が意味合いとして重複してそうですが…以下のようなスキーマを考えます。

database
sample
collection
user
document
{
    "id":       String,
    "email":    String,
    "name":     String,
    "password": String,
    "role":     String
}

データ作成

サンプルデータとして投入するユーザーは、ユーザー名:tanaka@sample.com 、 パスワード:password 、ロール:group1 のユーザーです。 その他は使っていないので適当です。

> use sample

> db.createCollection("user");

> db.user.insert({
...   id: "tanaka",
...   email: "tanaka@sample.com",
...   name: "tanaka",
...   password: "password",
...   role: "group1"
... })

Webアプリケーション作成

アプリケーションルート作成 ( /app.js )

var express = require("express");
var cookieParser = require("cookie-parser");
var bodyParser = require("body-parser");
var flash = require("connect-flash");
var session = require("express-session");
var mongoose = require("mongoose");
var passport = require("passport");
var LocalStrategy = require("passport-local").Strategy;
var User = require("./models/user.js");

// MongoDB 接続先設定
mongoose.connect("mongodb://localhost/sample");

// passport が ユーザー情報をシリアライズすると呼び出されます
passport.serializeUser(function (id, done) {
    done(null, id);
});

// passport が ユーザー情報をデシリアライズすると呼び出されます
passport.deserializeUser(function (id, done) {
    User.findById(id, (error, user) => {
        if (error) {
            return done(error);
        }
        done(null, user);
    });
});

// passport における具体的な認証処理を設定します。
passport.use(
    "local-login",
    new LocalStrategy({
        usernameField: "username",
        passwordField: "password",
        passReqToCallback: true
    }, function (request, username, password, done) {
        process.nextTick(() => {
            User.findOne({ "email": username }, function (error, user) {
                if (error) {
                    return done(error);
                }
                if (!user || user.password != password) {
                    return done(null, false, request.flash("message", "Invalid username or password."));
                }
                // 保存するデータは必要最低限にする
                return done(null, user._id);
            });
        });
    })
);

// 認可処理。指定されたロールを持っているかどうか判定します。
var authorize = function (role) {
    return function (request, response, next) {
        if (request.isAuthenticated() &&
            request.user.role === role) {
            return next();
        }
        response.redirect("/account/login");
    };
};

// express の実態 Application を生成
var app = express();

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

// ミドルウェアの設定
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(flash());
app.use("/public", express.static("public"));

// passport設定
app.use(session({ secret: "some salt", resave: true, saveUninitialized: true }));
app.use(passport.initialize());
app.use(passport.session());

// ルーティング設定
app.use("/", (function () {
    var router = express.Router();
    router.get("/home/index", function (request, response) {
        response.render("./home/index.ejs");
    });
    router.get("/account/login", function (request, response){
        response.render("./account/login.ejs", { message: request.flash("message") });
    });
    router.post("/account/login", passport.authenticate(
            "local-login", {
                successRedirect: "/account/profile",
                failureRedirect: "/account/login"
            }));
    router.post("/account/logout", authorize("group1"), function (request, response){
        request.logout();
        response.redirect("/home/index");
    });
    router.get("/account/profile", authorize("group1"), function (request, response){
        response.render("./account/profile.ejs");
    });
    return router;
})());

// サーバーをポート 3000 で起動
app.listen(3000);

アプリケーションルートの実装。 本記事の主題になるところなので、ポイントになる箇所をピックアップして説明していきます。

L12: mongoose.connect()

MongoDB への接続を設定します。 接続先はテスト用なので ホスト が local 、 データベース が sample となっています。 ここではサンプルなので接続文字列をべた書きにしていますが、本来は設定ファイルとして切り出した方が良いと思います。

L15-17, L20-27: passport.serializeUser(), passport.deserializeUser()

passport.js は cookie に ユーザー情報を保存するときシリアライズを行って保存しています。 これらのメソッドはコールバックで、それぞれシリアライズしたときに呼び出し、デシリアライズしたときに呼び出しとなっています。 よく見る記事ではデシリアライズする際(=ユーザー認証が終わった後にアクセスされた場合)、データベースからユーザー情報を取り直して再設定しているコードをよく見ます。

L30-50: passport.use()

第1引数にはあとからストラテジーを呼び出すために使う一意な名前を指定します(今回のサンプルだと L96 で passport.authenticate() の引数に指定している)。 第2引数にはストラテジーを指定します。 第2引数に、今回は一番よくつかわれると思われる LocalStrategy を指定します。 この LocalStrategy はインスタンス生成時に2津引数を取ります。 1つ目にはログイン画面のフォームに指定しているユーザー名およびパスワードのテキストボックスの名前を指定します。 usernameField には ユーザー名を示すテキストボックスの名前を、 passwordField には パスワード を示す テキストボックスの名前を指定します。 2つ目は認証処理を定義します。 mongoose を利用してユーザーを検索して存在しておりパスワードが一致しているかどうかをチェックします。 認証出来たら必要なデータだけに絞って done() へ引き渡します。 ここで渡した値は passport.serializeUser() のコールバックの第1引数にわたっていきます。

L53-61: authorize()

認可処理を実現するための補助関数を定義します。 利用方法は Express のミドルウェアとして認可処理が必要な処理の手前に差し込みます。 今回のサンプルであれば POST: /account/logoutGET: /account/profile に挿入しています。

L78-80: app.use()

passport を利用できるように express のミドルウェアとして追加します。 passport は express-session を利用しているので、session()passport.initialize()passport.session() の順で追加します。

L91-95: router.post("/account/login", ...)

ログイン処理を定義します。 passport.authenticate() にストラテジー名を指定することで実現できます。

L96-99: router.post("/account/logout", ...)

ログアウト処理を定義します。 ログアウトは request.logout() で行えます。

ユーザーモデル作成 ( /models/user.js )

var mongoose = require("mongoose");

var schema = mongoose.Schema({
    "id":       String,
    "email":    String,
    "name":     String,
    "password": String,
    "role":     String
});

module.exports = mongoose.model("User", schema, "user");

トップ画面作成 ( /views/home/index.ejs )

<!DOCTYPE html>
<html lang="ja">
  <head>
    <%- include("../_head.ejs", {title: "/home/index"}) %>
  </head>
  <body>

    <%- include("../_nav.ejs") %>

    <div class="container">
      <div class="starter-template">
        <h1>Authenticate / Authorize Sample Project</h1>
        <p class="lead"><br>
          This sample use Node.js, Express, Passport, MongoDB.
          You can learn how to build authn/authz function.
        </p>
        <p class="lead">
          <a type="button" class="btn btn-primary btn-lg" href="/account/login">Sign-in</a>
        </p>
      </div>
    </div><!-- /.container -->
 
    <%- include("../_scripts.ejs") %>
  </body>
</html>

ログイン画面作成 ( /views/account/login.ejs )

<!DOCTYPE html>
<html lang="ja">
  <head>
    <%- include("../_head.ejs", {title: "/account/login"}) %>
  </head>
  <body>

    <%- include("../_nav.ejs") %>

    <div class="container">
      <div class="col-sm-offset-3 col-sm-6">
        <h1><i class="fa fa-sign-in"></i> Login</h1>
        <div class="panel panel-default">
          <div class="panel-body">
            <form class="form-horizontal" method="POST" action="/account/login">
              <div class="form-group">
                <label for="username" class="col-sm-4 control-label">User Name</label>
                <div class="col-sm-8">
                  <input type="text" class="form-control" id="username" name="username" />
                </div>
              </div>
              <div class="form-group">
                <label for="password" class="col-sm-4 control-label">Password</label>
                <div class="col-sm-8">
                  <input type="password" class="form-control" id="password" name="password" />
                </div>
              </div>
              <div class="form-group">
                <div class="col-sm-offset-4 col-sm-8">
                  <input type="submit" class="btn btn-default btn-lg" value="Sign-in" />
                </div>
              </div>
              <% if (message && message.length !== 0) {%>
              <div class="alert alert-danger" role="alert">
                <i class="fa fa-"></i>
                <span class="sr-only">ERROR: </span>
                <%= message %>
              </div>
              <%}%>
            </form>
          </div>
        </div>
      </div>
    </div><!-- /.container -->
 
    <%- include("../_scripts.ejs") %>
  </body>
</html>

ユーザー名、パスワードを示すテキストボックスの名前は app.js 中の usernameField および passwordField に一致させます。 エラーメッセージは message として受け取るので、存在していれば表示するようにします。

プロフィール画面作成 ( /views/home/profile.ejs )

<!DOCTYPE html>
<html lang="ja">
  <head>
    <%- include("../_head.ejs", {title: "/account/login"}) %>
  </head>
  <body>

    <%- include("../_nav.ejs") %>

    <div class="container">
      <div class="col-sm-offset-3 col-sm-6">
        <h1><i class="fa fa-user"></i> profile page</h1>
        <table class="table table-bordered">
        <% for (var key in user) { %>
          <tr>
            <th class="col-sm-3"><%= key %></th>
            <td class="col-sm-9"><%= user[key] %></td>
          </tr>
        <% }%>
        </table>
        <form class="text-right" method="POST" action="/account/logout">
          <input type="submit" class="btn btn-default btn-lg" value="sign-out" />
        </form>
      </div>
    </div><!-- /.container -->
 
    <%- include("../_scripts.ejs") %>
  </body>
</html>

ログアウトは POST: /account/logout なので、フォームで呼び出すように実装します。

テスト

ここまでくればアプリは完成しているので実際に実行してみます。 Visual Studio Code であれば F5 でデバッグ実行します。

デバッグ実行状態でブラウザを立ち上げて //localhost:3000/home/index へアクセスするとトップ画面が表示します。 ログイン画面へ移動して tanaka@sample.com / password でログインします。 きっとうまく動くハズ…

参考記事