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 |
データベース作成
スキーマ
id
と email
が意味合いとして重複してそうですが…以下のようなスキーマを考えます。
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/logout
、 GET: /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
でログインします。
きっとうまく動くハズ…
参考記事