homehome

Session based authentication in Passport.js

Published

TocMenu目次

Passport.jsのローカル戦略で、セッションベースの認証を実装する方法です。セッションデータはMongoDBに保存し、ユーザ登録、ログイン、プロフィール(保護ルート)を作成します。

初歩的な内容ですが、ステップバイステップで解説していきます。ただし、本題と異なる内容に関しては一部割合しています。

プロジェクトを始める

以下は空ディレクトリから始めていますが、任意の方法を取ることが可能です。

mkdir session-example
cd session-example
yarn init

アプリケーションに必要なパッケージは以下です。

  • connect-mongo: MongoDBセッションストア
  • dotenv: 環境ファイルの読み込み
  • express: node.jsフレームワーク
  • express-session: セッションミドルウェア
  • mongoose: MongoDB用のオブジェクトデータモデリング(ODM)
  • passport: 認証ミドルウェア
  • passport-local: ユーザ名とパスワードで認証するpassport.jsライブラリ
  • passport-local-mongoose: ローカル戦略を簡素化するMongooseライブラリ
yarn add connect-mongo dotenv express express-session mongoose passport passport-local passport-local-mongoose

開発環境用にnodemonをインストールします。

yarn add --dev nodemon

package.jsonのエントリーポイントとスクリプトを以下に変更します。

{
  "main": "app.js",
  "scripts": {
    "dev": "nodemon app.js"
  },
  //...
}

割合しますが、eslintとprettierもこの段階でセットアップしておくと便利です。

Passport.js

Passport.jsはNode.jsの代表的な認証ライブラリです。認証方法は複数用意されていますが、今回はローカル戦略(Local Strategy)という方法を取ります。ローカル戦略とは昔ながらのユーザ名とパスワードによるログイン方法のことです。

先ほどいくつかのパッケージをインストールしましたが、それらはローカル戦略を扱うためのものです。これらのおかげでコードをほとんど書かずに安全なセッション管理を扱うことができます。

MongoDB

セッションを保管するストアが必要です。Dockerを使い、MongoDBを立ち上げます。

docker-compose.ymlを作成します。

version: '3.7'
services:
  mongodb:
    image: mongo:latest
    container_name: mongodb_contaner
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: password
      MONGO_INITDB_DATABASE: my_app
    ports:
      - 27017:27017
    volumes:
      - ./data/db:/data/db

ローカルに.envを作成し、以下を記述します。

MONGO_USER=root
MONGO_PASSWORD=password
MONGO_HOST=localhost
MONGO_DATABASE=my_app

以下のコマンドを実行します。

docker compose up -d

データベースファイルの構成

MongoDBと接続するファイル、config/db.jsを作成します。

const mongoose = require("mongoose");
require("dotenv").config();

const { MONGO_USER, MONGO_PASSWORD, MONGO_HOST, MONGO_DATABASE } = process.env;
const mongoURI = `mongodb://${MONGO_USER}:${MONGO_PASSWORD}@${MONGO_HOST}:27017/${MONGO_DATABASE}?retryWrites=true&writeConcern=majority&authSource=admin`;

function connectDB() {
  mongoose
    .connect(mongoURI)
    .then(() => {
      console.log("MongoDBに接続しました");
    })
    .catch((err) => {
      console.error("MongoDB Error:", err);
      process.exit(1);
    });
  return;
}

module.exports = {
  connectDB,
  mongoURI,
};

スキーマの作成

models/user.jsを作成します。

const passportLocalMongoose = require("passport-local-mongoose");
const mongoose = require("mongoose");
const Schema = mongoose.Schema;

const UserSchema = new Schema({
  username: {
    type: String,
    required: true,
    unique: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
  },
});

UserSchema.plugin(passportLocalMongoose);

const UserModel = mongoose.model("user", UserSchema);

module.exports = UserModel;

Mongooseのスキーマ定義に、passport-local-mongooseというモジュールを組み込みます。これにより、パスワードのハッシュ化、ユニークなユーザー名の強制、メールアドレスの検証などの面倒な処理を自動化してくれます。

後ほど実際の動作を確認する際に、その恩恵が見えてきます。

セッションの構成

app.jsを作成します。ここでローカル戦略によるセッションを構成します。

const express = require("express");
const session = require("express-session");
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const MongoStore = require("connect-mongo");
const User = require("./models/user");
const { connectDB, mongoURI } = require("./conifg/db");

connectDB();

const app = express();

// セッション構成と保存のためにMongoStoreを使用
app.use(express.urlencoded({ extended: false }));
app.use(
  session({
    secret: "your-secret",
    resave: false,
    saveUninitialized: false,
    store: MongoStore.create({
      mongoUrl: mongoURI,
      // 有効期限(30分)
      ttl: 60 * 30,
    }),
  })
);

// ユーザIDのみを保存するシリアル化と逆シリアル化関数を追加する
const strategy = new LocalStrategy(User.authenticate());
passport.use(strategy);
passport.serializeUser(User.serializeUser());
passport.deserializeUser(User.deserializeUser());
app.use(passport.initialize());
app.use(passport.session());

const indexRouter = require("./routes/index");

app.use("/", indexRouter);

app.listen(8000, () => console.log("Server started."));

シリアル化は、ユーザオブジェクトのような任意のデータ型を文字列に変換し、セッションストアへ安全に保存するために必要です。ここでは、passport.serializeUser(User.serializeUser())となっていますが、先ほどスキーマで組み込んだpassport-local-mongooseにより、ユーザIDがシリアル化されます。逆シリアル化(デシリアル化)処理も同様です。

ルートの作成

passportの構成は完了しているため、あとはルートを作成します。ここからは動作検証の兼ね合いのため、コードを少しずつ記述していきます。

ユーザ登録

まずはユーザ登録を行う/registerルートです。

// route/ndex.js
const express = require("express");
const router = express.Router();
const passport = require("passport");
const User = require("../models/user");

router.post("/register", function (req, res) {
  User.register(
    new User({
      email: req.body.email,
      username: req.body.username,
    }),
    req.body.password,
    function (err, account) {
      console.log("new user:", account);
      if (err) {
        res.send(err);
      } else {
        res.send({ message: "Successful" });
      }
    }
  );
});

module.exports = router;

Postmanなどのクライアントツールから、password, username, emailをリクエストボディに与えたリクエストを作成し、/registerにpostします。以下のような結果が返れば、問題なく動作しています。

{
    "message": "Successful"
}

既に同名のユーザ名が登録されている場合は、登録を受け付けないといった機能もデフォルトで実装されています。

{
    "name": "UserExistsError",
    "message": "A user with the given username is already registered"
}

さて、上記でパスワードは暗号化(ハッシュ化)しなくていいのか?と疑問に思った方もいるかもしれません。しかし、パスワードは暗号化する必要はありません。スキーマの定義で使用したpassport-local-mongooseは、パスワードのハッシュ化と検証を自動的に行ってくれます。

パスワードに触れたついでに、さらに思い出しましょう。先ほどuserスキーマを定義しましたが、このスキーマはusernameとemailの2つのフィールドしかありませんでした。パスワードはどこにあるのでしょう?

これもpassport-local-mongooseが舞台裏で頑張ってくれています。ここでデータベースの中身を見てみましょう。以下はVSCodeのDatabase Clientという拡張機能でuserコレクションを表示しています(Dockerで走っているDB内のデータを確認できます)

user-collections

hashがハッシュ化済みのパスワードです。

ログイン

次に作成したユーザをログインさせるルートを作成します。ここでPassportのローカル戦略を使用し、ログインが成功すればlogin-sccess、失敗すればlogin-failureルートへリダイレクトさせます。

// ローカル戦略を使用する
router.post(
  "/login",
  passport.authenticate("local", {
    failureRedirect: "/login-failure",
    successRedirect: "/login-success",
  }),
  (err, req, res, next) => {
    if (err) next(err);
  }
);

router.get("/login-failure", (req, res, next) => {
  res.send("ログインに失敗");
});

router.get("/login-success", (req, res, next) => {
  res.send("ログイン成功");
});

先ほど登録したusernameとpasswordをリクエストボディに入れてログインを行うと、ログイン成功が得られます。また、セッションクッキーが返されています。

session-cookie

このときセッションが確立するため、DB内のsessionコレクションも更新されます。

session-collection

成功時のコールバック関数内のreqには様々な情報が含まれています。

  • req.sessionID - セッションID(MongoDB内のsessions._idと等しい)
  • req.user - userオブジェクト(MongoDB内のusersと等しい)
  • req.session - セッションオブジェクト

req.sessionを書き出すと、以下のような内容が返ります。

Session {
  cookie: { path: '/', _expires: null, originalMaxAge: null, httpOnly: true },
  passport: { user: 'test user' }
}

既にセッションの構成でTTLを設定してありますが、これはサーバ側(DB内)のセッションの有効期限であり、セッションクッキーの有効期限ではありません。このため上記の_expiresはnullになっています。上記のPosmanの成功処理を見ると、expiresはSessionとしか記載されていません。この場合、ブラウザを閉じるまでが有効期限ということです。

以下のようにセッションクッキーの有効期限を設定することもできます。

app.use(
  session({
    secret: "your-secret",
    resave: false,
    saveUninitialized: false,
    store: MongoStore.create({
      mongoUrl: mongoURI,
      ttl: 60 * 30,
    }),
  	cookie: {
      secure: true,
      // 60分(1000 * 60 * 60)
      maxAge: new Date(Date.now() + 3600000)
    },
  })
);

このように設定すると、req.sessionは以下のようになります。

Session {
  cookie: {
    path: '/',
    _expires: 2024-08-09T09:54:33.051Z,
    originalMaxAge: 3593010,
    httpOnly: true
  },
  passport: { user: 'test user' }
}

保護されたルート

ログイン状態に応じて、処理を分岐させるルートです。

// 保護されたルート
router.get("/profile", function (req, res) {
  // isAuthenticated()を呼び出してリクエストが認証されているかをチェックする
  if (req.isAuthenticated()) {
    res.json({
      message: "安全なプロフィールに到達しました",
    });
  } else {
    res.json({
      message: "あなたは認証されていません",
    });
  }
});

先程のログイン処理後にこのルートへGETリクエストを行うと、「安全なプロフィールに到達しました」とJSONが返るはずです。

{
    "message": "安全なプロフィールに到達しました"
}

ここでTTLもしくはセッションクッキーの有効期限を短くして、セッション切れの動作も検証してみましょう。再度ログインし、有効期限の切れたタイミングで/profileにアクセスすると、「あなたは認証されていません」と表示されるはずです。

{
    "message": "あなたは認証されていません"
}

まとめ

今回利用したPassportと周辺ライブラリを使用すると、ほとんど処理を記述せずセッションを実装することができます。データベース内のsessionコレクションは、自動で古いセッションデータを削除(クリーニング)してくれるなども便利です。