本文書ではMERN環境においてJWT(JSON WEB TOKEN)を使ったユーザ認証の設定について説明しています。ユーザ認証を設定することでアクセスを許可されたユーザのみが特定のページにアクセスすることを許されます。認証はトークン(文字列)を利用して行い、バックエンドのサーバから発行されるトークンを含んだCookiesをフロントエンド側で受け取りリクエストと一緒にCookiesを送信します。Cookiesに含まれるトークンをバックエンド側で検証することでアクセスを許可するかどうかを判定します。トークンの管理方法を複数存在しますが、本文書ではトークンは有効期限の短いアクセストークンと有効期限が比較的長いリフレッシュトークンの2つを利用し、どちらのトークンもCookiesの中で保存します。

Cookiesではなくローカルストレージに保存するバージョンは公開済みです。

MERNとは

MERNはMongoDB, Express, React, Nodeで構成されたWEBフレームワークで各技術の名前の先頭の文字をとって名付けらたWEBフレームワークです。

MongoDBはNo SQLデータベースです。ExpressはNode.js環境で利用できるWEBサーバです。Reactはブラウザ上でインタラクティブな画面を描写する際に利用されるUIのライブラリです。Node.jsはブラウザではなくサーバ上でJavaScriptを動かすことができる実行環境です。

MERNは下図ような構成をしており、フロントエンドはReactで構成され、バックエンドはNode.js上で動作するExpressとデータベースのMongoDBで構成されます。図の中でフロントエンドとバックエンドが分けられている通り、本文書でもフロントエンドとバックエンドは別々のプロジェクトとして異なるフォルダを利用しています。フロントエンドとバックエンドは別々に構成されているためバックエンドを変更することなくフロントエンドをReactからVue.jsやSvelteに変更することも可能です。Vue.jsに変更した場合にはMEVN(MongoDB, Express, Vue.js, Node.js)と呼ばれます。

MERN構成
MERN構成

プロジェクトフォルダの作成

フロントエンド、バックエンドのプロジェクトを保存するプロジェクト全体のフォルダmern_authを作成します。作成後はmern_authフォルダに移動します。


 % mkdir mern_auth
 % cd mern_auth

バックエンドの設定

最初にバックエンド側の設定を行っていきます。バックエンドではExpress, MongoDBの設定を行います。

プロジェクトの作成

バックエンドのプロジェクトフォルダを作成するためmern_authフォルダの下にbackendフォルダを作成します。


 % mkdir backend
 % cd backend

作成したbackendフォルダに移動し、npm init -yコマンドを実行してpackage.jsonファイルを作成します。


% npm init -y
Wrote to /Users/mac/Desktop/mern_auth/backend/package.json:

{
  "name": "backend",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

メインファイルであるindex.jsファイルをbackendフォルダの下に作成します。index.jsファイルの中にExpressのコードを記述していきます。


 % touch index.js

Expressの設定

バックエンドではExpressを利用するためにExpressのパッケージのインストールを行います。index.jsファイルの更新を検知してindex.jsの再読み込みを自動で行ってくれるnodemonも合わせてインストールします。


 % npm install express nodemon

インストールが完了したらpackage.jsonのscriptプロパティに追加を行います。


{
//略
  "scripts": {
    "start": "nodemon index.js", //追加
    "test": "echo \"Error: no test specified\" && exit 1"
  },

//略

設定後はnpm startコマンドを実行するとindex.jsファイルが実行されnodemonによりファイルの更新の監視が開始されます。


 % npm start

> backend@1.0.0 start
> nodemon index.js

[nodemon] 2.0.20
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node index.js`
[nodemon] clean exit - waiting for changes before restart

Expressの動作確認を行うために下記のコードをindex.jsファイルに記述します。


const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send('Hello World');
});

const port = 5000;

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

npm startコマンドによりnodemonのファイルの更新の監視を行っているので更新した内容はすぐに反映されます。

ブラウザからhttp://localhost:5000/にアクセスして”Hello World”が表示されればExpressは正常に動作しています。

Expressの動作確認
Expressの動作確認

環境変数の設定

Expressの起動するポートを5000に設定していましたが環境変数から設定できるようにdotenvパッケージをインストールします。


% npm install dotenv

インストール後backendフォルダ直下に.envファイルを作成してPORTを設定します。


PORT=5000

.envファイルを設定後にindex.jsファイルを更新します。起動するポートは5000で同じですが、.envファイルからPORTの値を取得しています。.envファイルが存在しない場合やPORTの環境変数が.envファイルに存在しない場合には5000が設定される設定も行っています。


const express = require('express');
const dotenv = require('dotenv');

dotenv.config();

const app = express();

app.get('/', (req, res) => {
  res.send('Hello World');
});

const port = process.env.PORT || 5000;

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

MongoDBの設定

MongoDBはクラウドのMongoDB Atlasを利用します。MongoDB Atlasには有償のサービスもありますが無料でも利用することができます。利用するためにアカウントの作成が必要になります。Emailアドレスでも登録を行うことができますがGoogleアカウントを利用して登録することもできます。下記の”Try free”から登録を行ってください。

本文書ではすでにアカウントの登録を行っているのでアカウントの登録の流れは省略しています。難しい箇所はないので各自で行ってください。
MongoDB
MongoDB

メールアドレスを入力後アカウントの設定を進めていく中でOrganizationの設定が必要となります。Organizationの設定後にProjectsの設定を行います。

Projectsの画面の右上にある”New Project”をクリックします。

プロジェクトの一覧
プロジェクトの一覧

任意のプロジェクトの名前を設定してください。ここではMERNと設定します。設定後は”Next”ボタンをクリックします。

プロジェクト名の設定
プロジェクト名の設定

メンバーの追加画面が表示されますが”Create Project”ボタンをクリックします。

メンバーの追加
メンバーの追加

プロジェクトの作成が完了しデータベースの作成画面が表示されます。現在利用しているIPアドレスからのアクセスしか許可しないかアラートが出ているので”Add Current IP Address”を設定します。

データベース作成画面
データベース作成画面

設定後に”Build a Database”ボタンをクリックします。データベースをDeployするためにプランが表示されるので右のFreeの”Shared”を選択して、”Create”ボタンをクリックします。

プランの選択
プランの選択

Cloud ProviderとRegionの選択を行うことができます。Cloud ProviderとRegionの組み合わせによってはFreeで提供されていないものもあります。ここではCloud ProviderにAWSのTokyoを選択します。下部に”Create Cluster”のボタンがあるのでクリックしてください。

Cloud ProviderとRegionの選択
Cloud ProviderとRegionの選択

ユーザとパスワードの設定が必要なので設定を行ってください。ユーザ名とパスワードはデータベースに接続する際に利用するので忘れないように控えておいてください。ユーザを作成すると下部に”Finish and Close”ボタンが表示されているのでクリックしてください。

ユーザの設定
ユーザの設定

データベースの作成が完了すると以下の画面となります。データベースに接続するための情報を確認するために”Connect”ボタンをクリックします。

データベース画面
データベース画面

クリックすると接続方法の選択画面が表示されるので上から2番目の”Connect your application”をクリックしてください。

接続の選択画面
接続の選択画面

データベースの接続情報が”2”に表示されるのでこの情報を利用します。

データベースへの接続情報
データベースへの接続情報

接続情報は.envファイルに保存します。<YOUR_PASSWORD>の箇所はユーザを作成した時に設定したパスワードを設定してください。


PORT=5000
DATABASE_URI=mongodb+srv://admin:<YOUR_PASSWORD>@cluster0.1faqbfn.mongodb.net/?retryWrites=true&w=majority

データベースへの接続

MongoDBに接続するためにmongooseのインストールを行います。mongooseを経由してMongoDBへの接続、操作を行います。mongooseを利用することでスキーマを定義してMongoDBを操作することができます。


 % npm install mongoose

MongoDBへの接続を行うためにindex.jsファイルを更新します。


const express = require('express');
const dotenv = require('dotenv');
const mongoose = require('mongoose');

dotenv.config();

const app = express();

mongoose.connect(process.env.DATABASE_URI).then(() => {
  console.log('Database Connected');
});

app.get('/', (req, res) => {
  res.send('Hello World');
});

const port = process.env.PORT || 5000;

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

更新するとデータベースの接続は行えますが以下のWARNINGが表示されます。


(node:38940) [MONGOOSE] DeprecationWarning: Mongoose: the `strictQuery` option will be switched back to `false` by default in Mongoose 7. Use `mongoose.set('strictQuery', false);` if you want to prepare for this change. Or use `mongoose.set('strictQuery', true);` to suppress this warning.
(Use `node --trace-deprecation ...` to show where the warning was created)

Mongoose 7からはデフォルトでstrictQueryの設定がfalseになるということなので事前にstrictQueryをfalseにするかstrictQueryをtrueで運用するか聞かれています。strictQueryがtrue, falseのどちらを設定してもWARNINGは消えます。


mongoose.set('strictQuery', false);
mongoose.connect(process.env.DATABASE_URI).then(() => {
  console.log('Database Connected');
});

ExpressからMongoDBへの接続は完了です。

ユーザ情報の保存

スキーマの定義

MongoDBにユーザ情報を保存できるようにmongooseを利用してスキーマの定義を行います。

backendフォルダ直下にmodelsフォルダを作成してUser.jsに下記を記述して保存します。


const mongoose = require('mongoose');

const UserSchema = new mongoose.Schema({
  name: {
    type: String,
    require: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
  },
  password: {
    type: String,
    required: true,
    minLength: 8,
  },
});

module.exports = mongoose.model('User', UserSchema);

MongoDBはNo SQLデータベースでリレーショナルデータベースとはデータを保存するための場所の名称が異なります。リレーショナルデータベースのテーブルはコレクション、レコードはドキュメント、コラムはフィールドに対応します。

上記のスキーマではname, email, passwordのフィールドを定義して各フィールドに型を設定しています。3つのフィールドの型はすべてString(文字列)型を設定しています。requireはドキュメントを作成する際に必須のフィールドでpasswordにのみ最小文字数(minLength)の8を設定しています。passwordの文字列が8未満ではドキュメントを作成することができません。

リレーションデータベースではスキーマを定義してデータを挿入する前に事前にテーブルを作成することになりますがNo SQLではその作業は必要ありません。

ルーティングの設定

/api/auth/signupにPOSTリクエストが送信されてきた場合にユーザの作成を行う処理が実行できるようにルーティングの追加を行います。


//略
const User = require('./models/User');
//略
app.post('/api/auth/signup', async (req, res) => {
  const { name, email, password } = req.body;
  const user = await User.create({
    name,
    email,
    password,
  });
  return res.status(201).json({ message: 'ユーザが作成されました。', user });
});
//略

リクエストに含まれるreq.bodyの中にはユーザ作成に必要なname, email ,passwordが含まれています。動作確認のため/api/auth/signupに対してPOSTリクエストの送信を行いますがフロントエンドの構築を行っていないためリクエストを送信するためのツールが必要となります。本文書ではVisual Studio Code(VS Code)を利用しているのでExtensionsの”REST Client”を利用します。REST Client以外にもPostmanなどを利用することができます。VS Codeの左メニューのExtensionsから”REST Client”を検索すると表示できるのでインストールを行ってください。

REST Clientのインストールを行い、プロジェクトフォルダ直下にtest.httpファイルを作成します。test.httpファイルの中にHTTPリクエストを記述していきます。REST Clientを利用してリクエストを送信するためにはファイルの拡張子を.httpか.restにする必要があります。

作成したtest.httpファイルにPOSTリクエストを記述します。下記のコードをtest.httpファイルに記述するとコードの上部に”Send Request”ボタンが表示されクリックすることが可能となります。


POST http://localhost:5000/api/auth/signup
Content-Type: application/json

{
    "name": "john",
    "email": "john@example.com",
    "password": "password"
}

クリックするとPOSTリクエストが送信されます。データはJSONとして送信しています。しかし現在の設定ではExpress側でJSONをparseすることができないのでreq.bodyには何も入っていない状態となりエラーとなります。


  const { name, email, password } = req.body;
          ^

TypeError: Cannot destructure property 'name' of 'req.body' as it is undefined.

送信したJSONデータをparseできるようにミドルウェアのexpress.json()を設定します。


const app = express();
app.use(express.json());

再度”Send Request”ボタンをクリックしてPOSTリクエストを送信するとユーザが作成されメッセージと作成されたユーザの情報が戻されます。


HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 166
ETag: W/"a6-j1MJL4dw6yWIpUEwm8YaEmHlPcg"
Date: Thu, 08 Dec 2022 06:27:34 GMT
Connection: close

{
  "message": "ユーザが作成されました。",
  "user": {
    "name": "john",
    "email": "john@example.com",
    "password": "password",
    "_id": "639183d656fccbdc826b6d94",
    "__v": 0
  }
}

パスワードのハッシュ化

REST Clientから送信したPOSTリクエストの戻り値を見るとpasswordがそのままの状態で保存されているのでハッシュ化を行います。ハッシュ化にはbcryptライブラリを利用するためインストールを行います。


 % npm install bcrypt

bcryptをインストール後、ユーザを作成する際はreq.bodyから取得したpasswordの値をbcryptでハッシュ化してハッシュ化したパスワードを指定します。


const express = require('express');
const dotenv = require('dotenv');
const mongoose = require('mongoose');
const User = require('./models/User');
const bcrypt = require('bcrypt');

//略

app.post('/api/auth/signup', async (req, res) => {
  const { name, email, password } = req.body;
  const salt = await bcrypt.genSalt();
  const passwordHash = await bcrypt.hash(password, salt);
  const user = await User.create({
    name,
    email,
    password: passwordHash,
  });
  return res.status(201).json({ message: 'ユーザが作成されました。', user });
});

const port = process.env.PORT || 5000;

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

bcryptによるハッシュ化の処理を追加後、REST Clientを利用して別のユーザを作成します。


POST http://localhost:5000/api/auth/signup
Content-Type: application/json

{
    "name": "jane",
    "email": "jane@example.com",
    "password": "password"
}

“Send Request”ボタンをクリックすると新たに作成されたユーザの情報が戻されますが今回はハッシュ化されたパスワードが戻されていることが確認できます。MongoDBのデータベースには一人目のユーザはPOSTリクエストで送信したパスワードがそのまま保存されていますが二人目のユーザはハッシュ化されたパスワードが保存されます。


HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 215
ETag: W/"d7-o+pFR0fNurKb3f/SCYm+sKGONqU"
Date: Tue, 13 Dec 2022 07:02:56 GMT
Connection: close

{
  "message": "ユーザが作成されました。",
  "user": {
    "name": "jane",
    "email": "jane@example.com",
    "password": "$2b$10$Ns2B1po32IlyKTUQ0e7h4e99h.3dK87g5lpjT5YH9ue8y/4gLUEcS",
    "_id": "63918c200b42d6169c0f176f",
    "__v": 0
  }
}

MongoDBのAtlasの管理画面からCollections(コレクション)の確認を行うことで現在保存されているドキュメントを確認することができます。管理画面ではコレクションの保存されているドキュメントを確認するだけではなく削除や更新も行うことができます。

MongoDBのコレクションの確認
MongoDBのコレクションの確認

routesフォルダの作成

index.jsファイルの中にExpressで行う処理をすべて記述することも可能ですがルーティングを別ファイルに分けるためbackendフォルダにroutesフォルダを作成します。

routesフォルダの中にauthRoutes.jsファイルを作成します。


const express = require('express');
const router = express.Router();
const bcrypt = require('bcrypt');
const User = require('../models/User');

router.post('/signup', async (req, res) => {
  const { name, email, password } = req.body;
  const salt = await bcrypt.genSalt();
  const passwordHash = await bcrypt.hash(password, salt);
  const user = await User.create({
    name,
    email,
    password: passwordHash,
  });
  return res.status(201).json({ message: 'ユーザが作成されました。', user });
});

module.exports = router;

index.jsファイルでは別ファイルにしたauthRoutes.jsファイルをモジュールとして読み込むため更新を行います。先ほどよりもindex.jsファイルのコードがすっきりしました。


const express = require('express');
const dotenv = require('dotenv');
const mongoose = require('mongoose');
const authRoutes = require('./routes/authRoutes');

dotenv.config();

const app = express();
app.use(express.json());
app.use('/api/auth', authRoutes);

mongoose.set('strictQuery', false);
mongoose.connect(process.env.DATABASE_URI).then(() => {
  console.log('Database Connected');
});

const port = process.env.PORT || 5000;

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

controllersフォルダの作成

さらにauthRoutes.jsファイルのルーティング以外の処理部分を別のファイルに分けるためbackendフォルダの下にcontrollersフォルダを作成します。

controllersフォルダの下に作成するauthController.jsにはsignupの処理を記述します。


const bcrypt = require('bcrypt');
const User = require('../models/User');

const signup = async (req, res) => {
  const { name, email, password } = req.body;
  const salt = await bcrypt.genSalt();
  const passwordHash = await bcrypt.hash(password, salt);
  const user = await User.create({
    name,
    email,
    password: passwordHash,
  });
  return res.status(201).json({ message: 'ユーザが作成されました。', user });
};

exports.signup = signup;

authRoutes.jsファイルではauthController.jsファイルをモジュールとして読み込むため更新を行います。


const express = require('express');
const router = express.Router();
const { signup } = require('../controllers/authController');

router.post('/signup', signup);

module.exports = router;

index.jsファイルからindex.js, authRoutes.js, authController.jsの3つのファイルに分かれましたがこれまでに設定した動作に変更はないのでREST Clientからユーザを作成することは可能です。

ログインの設定

データベースへのユーザ情報の登録を行うことができたので登録したユーザのメールアドレスとパスワードを利用してログイン処理を実装します。

authRoutes.jsファイルに新たにルーティング/auth/loginを追加してlogin関数を設定します。login関数は存在しないのでこれから作成します。/api/auth/loginにPOSTリクエストが送信されてくるとlogin関数が実行されることになります。


const express = require('express');
const router = express.Router();
const { signup,login } = require('../controllers/authController');

router.post('/signup', signup);
router.post('/login', login);

module.exports = router;

authController.jsファイルにlogin関数を追加します。ログイン処理ではフロントエンドのクライアントからPOSTリクエストでemailとpasswordが送信されてきます。


//略
const login = async (req, res) => {
  const { email, password } = req.body;
  const user = await User.findOne({ email });
  if (!user) {
    return res
      .status(400)
      .json({ message: 'メールアドレスかパスワードに誤りがあります。' });
  }

  const match = await bcrypt.compare(password, user.password);
  if (!match) {
    return res
      .status(400)
      .json({ message: 'メールアドレスかパスワードに誤りがあります。' });
  }

  return res.status(200).json({ message: 'ログインに成功しました。', user });
};

exports.signup = signup;
exports.login = login;

login関数ではemailを利用してデータベースからユーザ情報を取得します。取得したユーザ情報のパスワードとPOSTリクエストで送信したパスワードが一致するかbcrpytのcompareメソッドで確認し、一致した場合にはユーザ情報を戻しています。

REST Clientを利用して動作確認を行います。


###
POST http://localhost:5000/api/auth/login
Content-Type: application/json

{
    "email": "jane@example.com",
    "password": "password"
}

登録済みのユーザのemailとパスワードを利用してリクエストを行った場合にはユーザ情報が戻されますが、異なる情報でリクエストした場合にはエラーメッセージとステータスコード400が戻されます。

アクセストークンの設定

ユーザの登録とログイン処理を行うことができたので次はアクセストークンの設定を行います。アクセストークンはJWT(Json Web Token)を利用します。利用するjsonwebtokenのインストールを行います。


 % npm install jsonwebtoken

バックエンド側で作成したアクセストークンをフロントエンド側に渡すことで有効なアクセストークンを持つユーザのみがバックエンド側で提供するサービスにアクセスすることができます。

アクセストークンはインストールしたjsonwebtokenのsignメソッドで作成することができ作成にはシークレットキーを利用し有効期限を設定する必要があります。シークレットキーは外部に漏れないように厳重に管理する必要があります。signメソッドの第一引数に設定する情報はトークンの中に含まれますが暗号化されているわけではなくトークンの文字列さえわかればだれでも中身の情報を確認することができるので重要な情報を含めてはいけません。

authController.jsにトークンを作成するためのgenerateToken関数を追加します。


//略
const jwt = require('jsonwebtoken');
//略
const generateToken = (email) => {
  return jwt.sign({ email }, process.env.JWT_SECRET_KEY, {
    expiresIn: process.env.JWT_EXPIRES_IN,
  });
};

JWT_SECRET_KEYとJWT_EXPIRES_INは環境変数を利用しているので.envファイルに設定を行う必要があります。ここではJWT_SECRET_KEYはsecret、JWT_EXPIRES_INは10分に設定しています。動作確認を行うので有効期限は都度調整を行ってください。10秒にしたい場合には10sと設定してください。


//略
JWT_SECRET_KEY=secret
JWT_EXPIRES_IN=10m

login関数の中でgenerateToken関数を実行してトークンを作成しトークン情報を含んだCookieを戻すように設定します。httpOnlyをtrueにすることでクライアントのJavaScriptからCookieにアクセスすることができなくなります。


const login = async (req, res) => {
  const { email, password } = req.body;
  const user = await User.findOne({ email });
  if (!user) {
    return res
      .status(400)
      .json({ message: 'メールアドレスかパスワードに誤りがあります。' });
  }

  const match = await bcrypt.compare(password, user.password);
  if (!match)
    res
      .status(400)
      .json({ message: 'メールアドレスかパスワードに誤りがあります。' });

  res.cookie('token', token, { httpOnly: true });

  return res.status(200).json({ message: 'ログインに成功しました。' });;
};

アクセストークンの設定が完了したのでREST Clientを利用して動作確認します。


###
POST http://localhost:5000/api/auth/login
Content-Type: application/json

{
    "email": "jane@example.com",
    "password": "password"
}

送信するemailとpasswordがデータベースに存在するユーザの場合はSet-Cookeiにトークンの情報が含まれていることが確認できます。HttpOnlyという文字列も確認できます。


HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImphbmVAZXhhbXBsZS5jb20iLCJpYXQiOjE2NzEwNjU1NjgsImV4cCI6MTY3MTA2NjE2OH0.RIVly2oh5qtGrPxWNm2ZgyPvVV23TEBEyX3o1kCVYdU; Path=/; HttpOnly
Content-Type: application/json; charset=utf-8
Content-Length: 50
ETag: W/"32-PVYbt6z/4HKZIZrft8PKZX44yEQ"
Date: Thu, 15 Dec 2022 00:52:48 GMT
Connection: close

{
  "message": "ログインに成功しました。"
}

トークンの中身は誰でも確認できると説明しましたがjwt.ioというサイトでExpressから戻されたトークンをコピー&ペーストしてください。右側のDecodedのPayloadの部分にjwt.signで利用してメールアドレスが表示されていることが確認できます。

トークンの中身の確認
トークンの中身の確認

アクセストークンの検証

バックエンドサーバからCookieで受け取ったアクセストークンを今後はフロントエンドからリクエストと一緒に送信しアクセストークンが有効かどうか検証を行う必要があります。

これまでに作成した/api/auth/login、/api/auth/signupはアクセストークンを持っていなくてもアクセスを行うことができます。

ここで新たに/api/auth/userのルーティングを追加します。このURLにアクセスするとトークンを受け取ったユーザの情報を取得することができます。そのかわり事前にアクセストークンを取得しておく必要があります。

authRoutes.jsファイルに新たに/userのルーティングを追加します。/api/auth/userにGETリクエストが送信されてくるとuser関数が実行されます。user関数はauthController.jsファイルに記述します。


const express = require('express');
const router = express.Router();
const { signup,login,user } = require('../controllers/authController');

router.post('/signup', signup);
router.post('/login', login);
router.get('/user', user);

module.exports = router;

クライアントからアクセストークンを送信した場合にはrequestのヘッダーにトークンが含まれるのでその確認を行います。npm startコマンドを実行したターミナルにヘッダー情報が表示されるように設定しています。


//略

const user = (req, res) => {
  const token = req.cookies.token;

  return res.status(200).json({ token });
};


//略

exports.user = user;

/api/auth/loginにPOSTリクエストを送信して事前にCookieでアクセストークンを取得します。そのままGETリクエストを送信します。


###
POST http://localhost:5000/api/auth/login
Content-Type: application/json

{
    "email": "jane@example.com",
    "password": "password"
}

###
GET http://localhost:5000/api/auth/user

しかしnpm startコマンドを実行したコンソールには以下のエラーが表示されます。原因はrequestオブジェクトに含まれているcookieをExpressで確認することができないためです。


TypeError: Cannot read properties of undefined (reading 'token')

Cookieを読み込めるようにcookie-parserをインストールします。


 % npm install cookie-parser

cookie-parserインストール後index.jsでミドルウェアとして設定を行います。


//略
const cookieParser = require('cookie-parser');

dotenv.config();

const app = express();
app.use(express.json());
app.use(cookieParser());
//略

cookie-parserの設定後、再度GETリクエストを送信するとCookieに含まれているトークンの値が戻されます。Expressサーバ側でCookieの内容を読み込むことができるようになりました。


HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 176
ETag: W/"b0-GspS3X9X1zPetT+ozyYObLtCfOU"
Date: Thu, 15 Dec 2022 01:08:37 GMT
Connection: close

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImphbmVAZXhhbXBsZS5jb20iLCJpYXQiOjE2NzEwNjY0NTMsImV4cCI6MTY3MTA2NzA1M30.zMlhhccGplHH6eZRkP2hVp2M42S99IdTvjP7WdLTqa0"
}

CookieにTokenが含まれていない場合には空になります。


HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 2
ETag: W/"2-vyGp6PvFo4RvsFtPoIWeCReyIC8"
Date: Thu, 15 Dec 2022 01:19:12 GMT
Connection: close

{}

REST ClientでCookiesの設定をしていないのになぜ送信されているのか気になっている人もいるかと思います。REST Clientではドキュメントに記載通り(“Remember Cookies for subsequent requests”)Set-Cookie headerのCookiesを保存して次のリクエストで自動で利用してくれます。rest-client.rememberCookiesForSubsequentRequestsがデフォルトではtrueになっているのでfalseに設定するとCookiesの自動保存は行われません。

保存されたCookiesはユーザのホームディレクトリの.rest_clientにcookie.jsonという名前で保存されているのでこのファイルを削除すると自動保存したcookieは削除できます。削除した後はキャッシュをクリアする必要があるのでVS Codeを再起動する必要があります。

CookieからTokenを取り出すことができたので取り出したトークンをjwtのverifyメソッドで検証します。


const user = (req, res) => {
  const token = req.cookies.token;

  if (!token) {
    return res.status(400).json({ message: 'アクセストークンはありません。' });
  }

  jwt.verify(token, process.env.JWT_SECRET_KEY, (err) => {
    if (err) {
      return res.status(401).json({ message: '有効でないトークンです。' });
    } else {
      return res.status(200).json({ message: '有効なトークンです。' });
    }
  });
};

REST Clientを利用してCookiesを保存した状態でアクセスした場合には”有効なトークンです。”のメッセージが戻されること、Cookiesがない状態でアクセスした場合には”アクセストークンがありません。”と表示させることを確認してください。”有効でないトークンです”を確認したい場合は.envでJWT_EXPIRES_INを短い時間に変更して有効期限が過ぎた後にアクセスすると表示されます。

アクセストークンを検証する処理は/api/auth/user以外でも利用することができるので別のファイルにわけミドルウェアとして設定します。

backendフォルダの下にmiddlewareフォルダを作成してverifyToken.jsファイルを作成します。

verifyToken.jsファイルにトークンの検証の処理のみ記述します。トークンが有効な場合はnext()により次の処理に移動します。トークンが有効でない場合は次の処理に進むことはできずフロントエンド側にエラーが戻されます。


const jwt = require('jsonwebtoken');

const verifyToken = (req, res, next) => {
  try {
    const token = req.cookies.token;

    if (!token) {
      return res
        .status(400)
        .json({ message: 'アクセストークンはありません。' });
    }

    jwt.verify(token, process.env.JWT_SECRET_KEY, (err) => {
      if (err) {
        return res.status(400).json({ message: '有効でないトークンです。' });
      } else {
        next();
      }
    });
  } catch (err) {
    return res.status(401).json({ message: err.message });
  }
};

module.exports = verifyToken;

さらにverifyTokenの中でトークンの中に含まれているemail情報を取得します。verify関数のcallback関数ではエラーだけではなくトークンをデコードした値も取得できるのでその値をreqestオブジェクトのemailに設定します。requestオブジェクトに設定することで次の処理で設定した値を利用することが可能になります。


jwt.verify(token, process.env.JWT_SECRET_KEY, (err, decoded) => {
  if (err) {
    return res.status(401).json({ message: '有効でないトークンです。' });
  } else {
    req.email = decoded.email;
    next();
  }
});

verifyToken関数はミドルウェアとしてauthRoutes.jsの/api/auth/userで利用します。/userにアクセスがあるとミドルウェアのverifyToken関数が実行されトークンの検証に問題がなければ、次にuser関数が実行されます。


const express = require('express');
const router = express.Router();
const { signup, login, user } = require('../controllers/authController');
const verifyToken = require('../middleware/verifyToken');

router.post('/signup', signup);
router.post('/login', login);
router.get('/user', verifyToken, user);

module.exports = router;

user関数ではverifyToken関数でトークンから取り出したemailを利用してデータベースからユーザ情報を取得します。emailを利用してfindOneメソッドでユーザ情報を取得していますがユーザ情報を取得する際にpasswordは取り出さないようにしています。


const user = async (req, res) => {
  const email = req.email;
  const user = await User.findOne({ email }).select('-password');
  if (!user) {
    return res.status(404).json({ message: 'ユーザは存在しません。' });
  }

  return res.status(200).json({ user });
};

REST Clientを利用してまず/api/auth/loginにアクセスしてアクセストークンを持つCookieを取得します。その後/api/auth/userにアクセスするとCookieに含まれるトークンの検証が行われ、問題がなければトークンを取得した(ログインを行った)ユーザ情報が取得できることを確認してください。

ユーザ一覧の取得

新たにユーザ一覧が取得できるルーティングを追加した場合の設定を確認しておきます。

routesフォルダにusersRoutes.jsファイルを作成し”/”を追加しgetUsers関数を設定します。ミドルウェアのverifyTokenを設定しているのでアクセストークンがなければユーザ一覧の情報を取得することができません。


const express = require('express');
const router = express.Router();
const verifyToken = require('../middleware/verifyToken');
const { getUsers } = require('../controllers/usersController');

router.get('/', verifyToken, getUsers);

module.exports = router;

作成したuserRoutesをindex.jsファイルで読み込みますルーティングの追加を行います。


const express = require('express');
const dotenv = require('dotenv');
const mongoose = require('mongoose');
const authRoutes = require('./routes/authRoutes');
const usersRoutes = require('./routes/usersRoutes');
const cookieParser = require('cookie-parser');

dotenv.config();

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRoutes);
app.use('/api/users', usersRoutes);
//略

controllersフォルダにusersController.jsファイルを作成してgetUsers関数を追加します。ユーザ情報の中からselectメソッドを利用してnameとemailのみ取り出しています。


const User = require('../models/User');

const getUsers = async (req, res) => {
  const users = await User.find().select('name email');

  return res.status(200).json({ users });
};

exports.getUsers = getUsers;

アクセストークンを取得後に/api/users/にアクセスを行いユーザ一覧が取得できるか確認してください。


###
POST http://localhost:5000/api/auth/login
Content-Type: application/json

{
    "email": "jane@example.com",
    "password": "password"
}

###
GET http://localhost:5000/api/users

//Reponse
{
  "users": [
    {
      "_id": "639a69dbe508d097f6b98758",
      "name": "john",
      "email": "john@example.com"
    },
    {
      "_id": "639a6aa8400c18a6625efa01",
      "name": "jane",
      "email": "jane@example.com"
    },
  ]
}

バックエンドの設定がすべて完了したわけではありませんがこれからフロントエンドの設定を行いバックエンドとの連携の動作確認を行なっていきます。

フロントエンドの設定

フロントエンドではReactを利用するのでReactのプロジェクトの作成を行い、バックエンドで作成したAPIのエンドポイントにリクエストを送信することで情報を取得していきます。

プロジェクトの作成

Reactプロジェクトの作成はnpx create-react-appコマンドを利用して行います。mern-authフォルダでコマンドを実行してfrontendフォルダを作成します。フォルダ名は任意の名前をつけることができます。


 % npx create-react-app frontend

プロジェクト作成後、frontendフォルダに移動してルーティングライブラリのreact-router-domのインストールを行います。react-router-domを利用することでログイン、ユーザ登録ページなど複数ページで構成されたReactアプリケーションを構築することができます。


 % cd frontend
 % npm install react-router-dom

インストールしたReactのバージョンは18.2.0、React Routerのバージョンは6.4.5です。

Reactの動作確認を行うためにsrcフォルダのApp.jsを下記のように記述します。


function App() {
  return (
    <div
      style={{ display: 'flex', justifyContent: 'center', marginTop: '3em' }}
    >
      Hello World
    </div>
  );
}

export default App;

開発サーバを起動するためにnpm startコマンドを実行します。実行後にブラウザでlocalhost:3000にアクセスすると画面には”Hello World”が表示されます。

Hello Worldの表示
Hello Worldの表示

ページの作成

react-router-domをインストールしたのでURLの異なる複数のページを作成することができます。srcフォルダの下にroutesフォルダを作成してHome.js, Login.js, Signup.jsファイルを作成します。


const Home = () => {
  return <div>Home</div>;
};

export default Home;

const Signup = () => {
  return <div>Signup</div>;
};

export default Signup;

const Login = () => {
  return <div>Login</div>;
};

export default Login;

ページの作成後、ルーティングの設定を行います。index.jsファイルを開いてreact-router-domからBrowserRouterをimportしてAppコンポーネントをラップします。


import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { BrowserRouter } from 'react-router-dom';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

App.jsファイルの中で”/”へのアクセス時にはHomeコンポーネント、/signupへのアクセス時にはSignupコンポーネント、/loginへのアクセス時にはLoginコンポーネントの内容が表示されるようにルーティング設定を行います。


import { Route, Routes } from 'react-router-dom';
import Home from './routes/Home';
import Signup from './routes/Signup';
import Login from './routes/Login';

function App() {
  return (
    <div
      style={{ display: 'flex', justifyContent: 'center', marginTop: '3em' }}
    >
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/signup" element={<Signup />} />
        <Route path="/login" element={<Login />} />
      </Routes>
    </div>
  );
}

export default App;

App.jsファイルでのルーティングの設定後/, /signup, /loginにブラウザからアクセスを行い、それぞれページ上にHome, Signup, Loginの文字列が表示されることを確認してください。

バックエンドからのデータ取得

バックエンドからデータを取得するためにはbackendフォルダでnpm startコマンドを実行し、Expressを起動しておく必要があります。

データの取得にはaixosを利用するためaxiosライブラリのインストールを行います。


 % npm install axios

axiosの設定を行うためutilsフォルダを作成してaxios.jsファイルを作成してバックエンドのURLを設定します。baseURLを設定しておくことでaxiosを利用してリクエストを送信する際baseURLに設定したURLの部分を省略して設定することができます。


import axios from 'axios';

axios.defaults.baseURL = 'http://localhost:5000/api/';

export default axios;

Home.jsファイルからaxiosを利用してユーザ情報を取得するために以下のコードを記述します。


import axios from '../utils/axios';
import React, { useEffect, useState } from 'react';

const Home = () => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    const getUsers = async () => {
      try {
        const response = await axios.get('/users');
        setUsers(response.data.users);
      } catch (err) {
        console.log(err);
      }
    };
    getUsers();
  }, []);
  return (
    <div>
      <h1>Home</h1>
      <ul>
        {users &&
          users.map((user) => (
            <li key={user._id}>
              Name:{user.name}/Email:{user.email}
            </li>
          ))}
      </ul>
    </div>
  );
};

export default Home;

“/”にブラウザからアクセスするとhttp://localhost:5000/api/usersへのアクセスが行われますが現在のバックエンドの設定ではコンソールには必ず”Access to XMLHttpRequest at ‘http://localhost:5000/api/users’ from origin ‘http://localhost:3000’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.”が表示されます。これはCORS(Cross-Origin Resource Sharing)に関するエラーでフロントエンドhttp://localhost:3000からバックエンドhttp://localhost:5000に対してアクセスを行っていますがポート番号が異なっているため異なるオリジンとなりアクセスが制限されています。

CORSによるアクセスの制限を解除するためバックエンド側でcorsのインストールを行います。


 % npm install cors

corsのインストールが完了したらcorsの設定をindex.jsファイルに追加します。


const express = require('express');
const dotenv = require('dotenv');
const mongoose = require('mongoose');
const cors = require('cors'); //追加
const authRoutes = require('./routes/authRoutes');
const userRoutes = require('./routes/userRoutes');
const cookieParser = require('cookie-parser');

dotenv.config();

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(cors());  //追加
//略

設定後再度フロントエンドからアクセスすると次はステータスコード401(UnAuthorized)でエラーが戻されます。エラーの原因はアクセストークンがCookieに存在しないままリクエストを送信しているためミドルウェアのverifyTokenの処理の中でエラーが発生しているためです。この時点ではアクセストークンの取得処理をフロントエンド側で実装していないため一時的にverifyTokenのミドルウェアを/api/usersのルーティングから削除します。処理はバックエンド側で行います。verifyTokenを利用しないことで/api/usersにアクセスを行ってもアクセストークンの検証が行われません。


const express = require('express');
const router = express.Router();
const verifyToken = require('../middleware/verifyToken');
const { getUsers } = require('../controllers/usersController');

// router.get('/', verifyToken, getUsers);
router.get('/', getUsers);

module.exports = router;

再度”/”にアクセスするとバックエンドのMongoDBに保存されているユーザ情報がブラウザ上に表示されます。

ユーザ一覧表示
ユーザ一覧表示

フロントエンドからバックエンドにアクセスを行い、データが取得できるようになりました。

ユーザ登録設定

Signup.jsファイルにユーザ登録のフォームを追加します。Name, Email, Passwordの3つの入力項目を追加してユーザ登録ボタンをクリックするとhandleSubmit関数が実行され、ブラウザのコンソールに入力した値が表示されます。入力値のバリデーションなど何も行っていないので入力した値がそのまま表示されます。通常はフロントエンド側でもバリデーションを設定する必要があります。


import React, { useRef } from 'react';
import { Link } from 'react-router-dom';

const Signup = () => {
  const nameRef = useRef(null);
  const emailRef = useRef(null);
  const passwordRef = useRef(null);
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(
      nameRef.current.value,
      emailRef.current.value,
      passwordRef.current.value
    );
  };
  return (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      <h1>ユーザ登録</h1>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="name">Name:</label>
          <input id="name" name="name" ref={nameRef} />
        </div>
        <div>
          <label>Email:</label>
          <input id="email" name="email" ref={emailRef} />
        </div>
        <div>
          <label>Password:</label>
          <input
            type="password"
            id="password"
            name="password"
            ref={passwordRef}
          />
        </div>
        <div>
          <button type="submit">ユーザ登録</button>
        </div>
      </form>
      <div>
        ログインは<Link to="/login">こちら</Link>
      </div>
    </div>
  );
};

export default Signup;

ブラウザから/signupにアクセスするとユーザ登録の入力フォームが表示されます。入力フォームに文字列を入力して”ユーザ登録”ボタンをクリックして入力した文字がコンソールに表示されることを確認してください。

ユーザ登録画面
ユーザ登録画面

入力した値がコンソールに表示されることが確認できたらaxiosを利用して入力した値をPOSTリクエストでバックエンドに送信します。ユーザ登録が完了したら”/”にリダイレクトされるようにReact Router DomのuseNavigate Hookを利用しています。


import React, { useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import axios from '../utils/axios';

const Signup = () => {
  const nameRef = useRef(null);
  const emailRef = useRef(null);
  const passwordRef = useRef(null);
  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      const response = await axios.post('/auth/signup', {
        name: nameRef.current.value,
        email: emailRef.current.value,
        password: passwordRef.current.value,
      });
      console.log(response.data);
      navigate('/');
    } catch (error) {
      console.log(error);
    }
  };
//略

ユーザ登録画面から入力を行ってください。バックエンド側のユーザ作成の制限にひっかからなければコンソールにはメッセージ”ユーザが作成されました。”と作成したuserオブジェクトの情報が表示されます。

“/”にリダイレクトされるので登録したユーザもユーザ一覧に表示されます。

ユーザ登録処理の確認
ユーザ登録処理の確認

フロントエンドからバックエンドを経由してユーザ登録が行えるようになりました。

ログイン設定

登録したユーザの情報を利用してログイン処理が実行できるか確認するためにログインフォームを追加します。


import React, { useRef } from 'react';
import { Link } from 'react-router-dom';

const Login = () => {
  const emailRef = useRef(null);
  const passwordRef = useRef(null);
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(emailRef.current.value, passwordRef.current.value);
  };
  return (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      <h1>ログイン</h1>
      <form onSubmit={handleSubmit}>
        <div>
          <label>Email:</label>
          <input id="email" name="email" ref={emailRef} />
        </div>
        <div>
          <label>Password:</label>
          <input
            type="password"
            id="password"
            name="password"
            ref={passwordRef}
          />
        </div>
        <div>
          <button type="submit">ログイン</button>
        </div>
      </form>
      <div>
        ユーザ登録は<Link to="/signup">こちら</Link>
      </div>
    </div>
  );
};

export default Login;

ユーザ登録の処理とほとんど同じです。入力項目はemailとpasswordの2つとなり、入力後ログインボタンをクリックするとhandleSubmi関数が実行されブラウザのコンソールに入力した値が表示されます。

ブラウザから/loginにアクセスするとログインの入力フォームが表示されます。入力フォームに文字列を入力して”ログイン”ボタンをクリックして入力した文字がコンソールに表示されることを確認してください。

ログインフォーム
ログインフォーム

入力した値がコンソールに表示されることが確認できたらaxiosを利用して入力した値をPOSTリクエストでバックエンドに送信します。ログインが完了したら”/”にリダイレクトされるようにuseNavigate Hookを利用しています。


import React, { useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import axios from '../utils/axios';

const Login = () => {
  const emailRef = useRef(null);
  const passwordRef = useRef(null);
  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      const response = await axios.post('/auth/login', {
        email: emailRef.current.value,
        password: passwordRef.current.value,
      });
      console.log(response.data);
      navigate('/');
    } catch (error) {
      console.log(error);
    }
  };
//略

先ほどユーザ登録をしたユーザを利用してログインを行います。

ログインに成功するとブラウザのコンソールには”ログインに成功しました”のメッセージが表示されます。Cookiesがバックエンドから戻されているはずですがデベロッパーツールのApplicationのCookiesを確認してもCookieを確認することができません。

アクセストークンの検証を行わないために無効にしていたミドルウェアのverifyToken関数をバックエンド側で元の状態に戻します。


const express = require('express');
const router = express.Router();
const verifyToken = require('../middleware/verifyToken');
const { getUsers } = require('../controllers/usersController');

router.get('/', verifyToken, getUsers);

module.exports = router;

verityTokenを有効にした後、ブラウザから直接”/”にアクセスすると401エラーによりユーザ情報が取得できなくなります。

Cookiesを利用するためにaxiosの設定とバックエンド側でのcorsの追加設定が必要となります。

utilsフォルダのaxios.jsでwithCredentialsの値をtrueに設定します。設定することで今回のようなフロントエンドとバックエンドのポート番号が異なるクロスオリジンの環境でCookiesを扱えるようになります。

バックエンド側ではindex.jsのcorsの設定にオプションを追加設定します。設定することでlocalhost:3000で起動するフロントエンドでCookiesのやりとりが可能になります。


app.use(
  cors({
    credentials: true,
    origin: 'http://localhost:3000',
  })
);

axiosとcorsの設定後再度ログインを行うとデベロッパーツールのApplicationのCookiesでバックエンドから渡されたCookiesを確認することができます。

Cookiesの確認
Cookiesの確認

Cookiesに含まれるアクセストークンを利用することでvefityTokenの検証もパスするのでユーザ一覧がブラウザ上に表示されるようになります。

ユーザ情報の取得

ログインが正常に完了してアクセストークンを取得した後にユーザ情報の取得が行えるか確認するためにgetUser関数を利用して/api/auth/userへのアクセスを行います。ログイン完了後、ブラウザのコンソールにはバックエンドから戻されたユーザ情報が表示されます。


const handleSubmit = async (e) => {
  e.preventDefault();
  try {
    await axios.post('/auth/login', {
      email: emailRef.current.value,
      password: passwordRef.current.value,
    });
    await getUser();
    navigate('/');
  } catch (error) {
    console.log(error);
  }
};
const getUser = async () => {
  const response = await axios.get('/auth/user');
  console.log(response.data);
};

useAuth Hookの作成

login , signup関数やgetUser関数によって取得したユーザ情報を保存するためのuseAuth Hookを作成します。srcフォルダの下にhooksフォルダを作成してuseAuth.jsファイルを作成します。


import { useState } from 'react';
import axios from '../utils/axios';
import { useNavigate } from 'react-router-dom';

const useAuth = () => {
  const [user, setUser] = useState();
  const navigate = useNavigate();

  const signup = async (data) => {
    try {
      const response = await axios.post('/auth/signup', data);
      console.log(response.data);
      navigate('/');
    } catch (error) {
      console.log(error);
    }
  };

  const login = async (data) => {
    try {
      await axios.post('/auth/login', data);
      await getUser();
      navigate('/');
    } catch (error) {
      console.log(error);
    }
  };

  const getUser = async () => {
    try {
      const response = await axios.get('/auth/user');
      const user = response.data.user;
      setUser(user);
    } catch (error) {
      console.log(error);
    }
  };

  return { user, signup, login };
};

export default useAuth;

Login.js , Signup.jsでuseAuth hookをimportしてlogin, signup関数を利用します。


import React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import useAuth from '../hooks/useAuth';

const Login = () => {
  const { login } = useAuth();

  const emailRef = useRef(null);
  const passwordRef = useRef(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    login({
      email: emailRef.current.value,
      password: passwordRef.current.value,
    });
  };

  return (
//略
  );
};

export default Login;

import React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import useAuth from '../hooks/useAuth';

const Signup = () => {
  const { signup } = useAuth();

  const nameRef = useRef(null);
  const emailRef = useRef(null);
  const passwordRef = useRef(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    signup({
      name: nameRef.current.value,
      email: emailRef.current.value,
      password: passwordRef.current.value,
    });
  };
  return (
//略
  );
};

export default Signup;

バックエンドのsignupの処理

バックエンドのauthController.jsファイルのsignup関数でもTokenをCookiesで戻すように設定を行います。


const signup = async (req, res) => {
  const { name, email, password } = req.body;
  const salt = await bcrypt.genSalt();
  const passwordHash = await bcrypt.hash(password, salt);
  const user = await User.create({
    name,
    email,
    password: passwordHash,
  });

  const token = generateToken(user.email);

  res.cookie('token', token, { httpOnly: true });

  return res.status(201).json({ message: 'ユーザが作成されました。' });
};

フロントエンド側のuseAuth.jsのsignup関数ではユーザ作成後にgetUser関数を実行するように変更します。


const signup = async (data) => {
  try {
    await axios.post('/auth/signup', data);
    await getUser();
    navigate('/');
  } catch (error) {
    console.log(error);
  }
};

useContext Hookによるユーザ情報の共有

Cookiesに有効なアクセストークンが含まれいる場合/api/auth/userにアクセスするとトークンを作成したユーザの情報を取得することができます。ログイン後に取得できるユーザ情報をアプリケーション内で共有できるようにuseCotext Hookを利用します。

srcフォルダ直下にcontextフォルダを作成しAuthContext.jsファイルを作成します。useAuth.jsが持つuser, login, signupをアプリケーションを構成するコンポーネントで利用できるように設定します。


import { createContext, useContext } from 'react';
import useAuth from '../hooks/useAuth';

const AuthContext = createContext();

export function useAuthContext() {
  return useContext(AuthContext);
}

export function AuthProvider({ children }) {
  const { user, login, signup } = useAuth();

  const value = {
    user,
    login,
    signup,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

アプリケーション全体でAuthContextを利用できるようにindex.jsファイルでAuthContextからimportしたAuthProviderでAppコンポーネントをラップします。


import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <AuthProvider>
        <App />
      </AuthProvider>
    </BrowserRouter>
  </React.StrictMode>
);

Login.jsのlogin関数、Signup.jsのsignup関数はimportしたuseAuthを利用していましたがuseAuthからuseAuthContextに変更します。


import React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import { useAuthContext } from '../context/AuthContext';

const Login = () => {
  const { login } = useAuthContext();
//略

import React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import { useAuthContext } from '../context/AuthContext';

const Signup = () => {
  const { signup } = useAuthContext();
//略

useContextを設定後も先ほどと動作は変わりません。

ここまでは取得したユーザ情報の値を利用していませんでしたがuseAuthのgetUser関数を実行した際にsetUserで取得したユーザ情報を保存しています。保存したユーザ情報を表示できるようにcomponentsフォルダを作成しHeader.jsファイルを作成します。AuthContextに保存されているuserオブジェクトを分岐に利用して表示する内容を変更しています。userオブジェクトが保存されている場合(ログインが完了している場合)にはメールアドレスが表示され、保存されていない場合にはloginとsignupのリンクが表示されます。


import { Link } from 'react-router-dom';
import { useAuthContext } from '../context/AuthContext';
const Header = () => {
  const { user } = useAuthContext();
  return (
    <div style={{ display: 'flex', justifyContent: 'space-between' }}>
      <h1>
        <Link to="/">MERN</Link>
      </h1>
      <div>
        {user ? (
          <div>
            <span>{user.email}</span>
          </div>
        ) : (
          <div>
            <Link to="/login">Login</Link>
            <Link to="/signup">Signup</Link>
          </div>
        )}
      </div>
    </div>
  );
};

export default Header;

作成したHeaderコンポーネントはApp.jsファイルでimportして利用します。


import { Route, Routes } from 'react-router-dom';
import Home from './routes/Home';
import Signup from './routes/Signup';
import Login from './routes/Login';
import Header from './components/Header';

function App() {
  return (
    <>
      <Header />
      <div
        style={{ display: 'flex', justifyContent: 'center', marginTop: '3em' }}
      >
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/signup" element={<Signup />} />
          <Route path="/login" element={<Login />} />
        </Routes>
      </div>
    </>
  );
}

export default App;

localhost:3000/loginを開いてログイン処理を行います。userオブジェクトは保存されていないためLogin, Signupのリンクが表示されています。

ログイン画面
ログイン画面

ログインが完了するとgetUser関数によりuserオブジェクトが保存されるのでヘッダーにはメールアドレスが表示されます。

メールアドレスの表示
メールアドレスの表示

ページをリロードするとメモリ上からuserオブジェクトが消えるのでLoginとSignupのリンクが表示されます。

リロード後の表示
リロード後の表示

リロードしてもuserオブジェクトが取得できるようにAuthContext.jsファイルにuseEffct Hookを追加してgetUser関数を設定します。useEffectの依存配列にgetUserを設定します。


import { createContext, useContext, useEffect } from 'react';
import useAuth from '../hooks/useAuth';

const AuthContext = createContext();

export function useAuthContext() {
  return useContext(AuthContext);
}

export function AuthProvider({ children }) {
  const { user, login, signup, getUser } = useAuth();

  useEffect(() => {
    getUser();
  }, [getUser]);

  const value = {
    user,
    login,
    signup,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

useAuth.jsファイルではgetUserをreturnのオブジェクトに追加し、getUserにはuseCallback Hookを設定します。useCallback Hookを設定しない場合にはuseEffectの依存配列に設定したgetUser関数が再描写毎に異なるオブジェクトとみなされ無限ループが発生するので注意してください。


import { useCallback, useState } from 'react';
import axios from '../utils/axios';
import { useNavigate } from 'react-router-dom';

const useAuth = () => {
  const [user, setUser] = useState();
  const navigate = useNavigate();

//略

  const getUser = useCallback(async () => {
    try {
      const response = await axios.get('/auth/user');
      const user = response.data.user;
      setUser(user);
    } catch (error) {
      setUser(null);
      console.log(error);
    }
  }, []);

  return { user, signup, login, getUser };
};

export default useAuth;

上記の設定後はリロードすると/api/auth/userに対してアクセスが行われCookiesに含まれるトークンが有効な場合はトークンを利用してユーザ情報を取得するためメールアドレスが右上に表示されるようになります。

Homeページへのアクセス制限

“/”にCookiesを持っている場合にアクセスするとユーザ一覧が表示されていましたがログインが完了していない場合には”/”にアクセスできないようにアクセス制限をかけるために更新を行います。”/”にアクセスして有効なトークンを持たない場合には/loginにリダイレクトさせます。

componentsフォルダにProtectedRoute.jsファイルを作成して以下のコードを記述します。


import { Navigate } from 'react-router-dom';
import { useAuthContext } from '../context/AuthContext';

const ProtectedRoute = ({ children }) => {
  const { user } = useAuthContext();
  if (!user) {
    return <Navigate to="/login" />
  }
  return children;
};

export default ProtectedRoute;

AuthContextを利用してuserオブジェクトを取り出し、userオブジェクトに値がない場合には/loginにリダイレクトする設定を行っています。

ProtectedRoutes.jsファイルを作成後はアクセス制限を行いたいページをProtectedRoutesタグでラップします。


import { Route, Routes } from 'react-router-dom';
import Home from './routes/Home';
import Signup from './routes/Signup';
import Login from './routes/Login';
import Header from './components/Header';
import ProtectedRoute from './components/ProtectedRoute';

function App() {
  return (
    <>
      <Header />
      <div
        style={{ display: 'flex', justifyContent: 'center', marginTop: '3em' }}
      >
        <Routes>
          <Route
            path="/"
            element={
              <ProtectedRoute>
                <Home />
              </ProtectedRoute>
            }
          />
          <Route path="/signup" element={<Signup />} />
          <Route path="/login" element={<Login />} />
        </Routes>
      </div>
    </>
  );
}

export default App;

設定後ログインが完了するとユーザ情報を取得後に”/”にリダイレクトされるためユーザ一覧は表示されます。しかし、リロードを行うと”/login”にリダイレクトされます。

理由はAuthContext.jsファイルのuseEffect Hookの中のgetUser関数でユーザ情報を取得する前にProtectedRouteが実行されuserに値が存在しないため”/login”へとリダイレクトされるためです。ログインが完了しトークンが有効な場合は、リロードしても”/”でユーザ一覧が表示されるように設定を行います。

useAuth.jsファイルではuserオブジェクトの初期値に何も値を設定していないため値はundefinedとなっています。


const useAuth = () => {
  const [user, setUser] = useState();
//略

AuthContext.jsのuseEffectでは/api/auth/userからユーザ情報を取得できない場合はsetUserでuserオブジェクトの値をnullに設定しています。


const getUser = useCallback(async () => {
  try {
    const response = await axios.get('/auth/user');
    const user = response.data.user;
    setUser(user);
  } catch (error) {
    setUser(null);
    console.log(error);
  }
}, []);

getUser関数でデータ取得に成功した場合にはuserにはuser情報が保存され、取得に失敗した場合にはuser情報にはnullが保存されます。useEffectの処理が完了するまではuserの値はundefinedを持つことになります。

userの値には3つの状態があることがわかったのでProtectedRoute.jsにもう一つ分岐を追加します。userの値がundefinedの間はLoading…を表示させます。


import { Navigate } from 'react-router-dom';
import { useAuthContext } from '../context/AuthContext';

const ProtectedRoute = ({ children }) => {
  const { user } = useAuthContext();
  if (user === undefined) {
    return <p>Loading...</p>;
  }

  if (!user) {
    return <Navigate to="/login" />;
  }
  return children;
};

export default ProtectedRoute;

設定後にログインしてリロードを行うと一瞬画面には”Loading…”の文字が表示されますが”/login”にリダイレクトされることなくユーザ一覧が表示されます。

ログアウト処理の追加

ここまではログイン処理に注目しておりログアウトの処理は全く行っていませんでした。ログアウトする際にCookiesを削除する必要がありますがCookiesの削除はバックエンド側で行います。

authRoutes.jsファイルに/revoke_tokenのルーティングを追加します。DELTEリクエストが送信されてきた場合にrevokeToken関数を実行します。


const express = require('express');
const router = express.Router();
const {
  signup,
  login,
  user,
  revokeToken,
} = require('../controllers/authController');
const verifyToken = require('../middleware/verifyToken');

router.post('/signup', signup);
router.post('/login', login);
router.get('/user', verifyToken, user);
router.delete('/revoke_token', revokeToken);

module.exports = router;

revokeToken関数をauthController.jsファイルに追加します。clearCookieにトークンの名前を設定することでCookieが削除されます。


const bcrypt = require('bcrypt');
const User = require('../models/User');
const jwt = require('jsonwebtoken');

//略

const revokeToken = async (req, res) => {
  res.clearCookie('token');
  res.status(200).json({message:'Cookiesを削除しました。'});
};

exports.signup = signup;
exports.login = login;
exports.user = user;
exports.revokeToken = revokeToken;

バックエンド側の追加が完了したら今後はフロントエンド側でログアウトの処理を行うために新たにuseAuth.jsファイルにlogout関数を追加します。logout関数ではuserオブジェクトの値をクリアして/api/auth/revoke_tokenにDELETEリクエストを送信します。logout関数はuseAuth.jsファイルのreturnに追加します。


const logout = () => {
  setUser(null);
  axios.delete('/auth/revoke_token');
  navigate('/login');
};

return { user, signup, login, getUser, logout };

AuthContext.jsでは追加したlogoutを他のコンポーネントでも利用できるようにvalueの値に追加します。


import { createContext, useContext, useEffect } from 'react';
import useAuth from '../hooks/useAuth';

const AuthContext = createContext();

export function useAuthContext() {
  return useContext(AuthContext);
}

export function AuthProvider({ children }) {
  const { user, login, signup, getUser, logout } = useAuth();

  useEffect(() => {
    getUser();
  }, [getUser]);

  const value = {
    user,
    login,
    signup,
    logout,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

logout処理が実行できるようにHeaderコンポーネントにログアウトのボタンを追加します。


import { Link } from 'react-router-dom';
import { useAuthContext } from '../context/AuthContext';
const Header = () => {
  const { user, logout } = useAuthContext();
  return (
    <div style={{ display: 'flex', justifyContent: 'space-between' }}>
      <h1>
        <Link to="/">MERN</Link>
      </h1>
      <div>
        {user ? (
          <div>
            <span>{user.email}</span>
            <button onClick={logout}>Logout</button> //追加
          </div>
        ) : (
          <div>
            <Link to="/login">Login</Link>
            <Link to="/signup">Signup</Link>
          </div>
        )}
      </div>
    </div>
  );
};

export default Header;

ログインを行うと”logout”ボタンが表示されます。

ログインボタンの表示
ログインボタンの表示

追加したlogout関数についてはユーザ一覧の取得getUsersやユーザ情報の取得getUserでエラーが発生した場合にログイン情報やトークンの情報を削除するために設定しておきます。


import axios from '../utils/axios';
import { useEffect, useState } from 'react';
import { useAuthContext } from '../context/AuthContext';

const Home = () => {
  const [users, setUsers] = useState([]);
  const { logout } = useAuthContext();

  useEffect(() => {
    const getUsers = async () => {
      console.log('Home.js getUsers');
      try {
        const response = await axios.get('/users');
        setUsers(response.data.users);
      } catch (err) {
        console.log(err);
        logout(); //追加
      }
    };
    getUsers();
  }, [logout]);
//略

const getUser = useCallback(async () => {
  try {
    const response = await axios.get('/auth/user');
    const user = response.data.user;
    setUser(user);
  } catch (error) {
    console.log(error);
    logout(); //追加
  }
}, [logout]);

getUserの依存配列にlogoutを追加すると”The ‘logout’ function makes the dependencies of useCallback Hook (at line 55) change on every render. To fix this, wrap the definition of ‘logout’ in its own useCallback() Hoo”のメッセージが表示されるのでlogout関数にuseCallback Hookを設定します。useCallback Hookを設定しないと無限ループが発生します。


  const logout = useCallback(() => {
    setUser(null);
    axios.delete('/auth/revoke_token');
    navigate('/login');
    // eslint-disable-next-line
  }, []);

logout関数にuseCallback Hookを設定すると依存配列にnavigateを設定するようにWARNINGが表示されますがnavigateを設定するとページを移動する度にgetUser関数が実行されるようになるためここではeslint-disable-next-lineでWARNINGを抑えています。

navigateを依存配列に設定することでページの移動毎にgetUser関数が実行されることを確認するためにroutesフォルダにProfile.jsファイルを作成します。


import { useAuthContext } from '../context/AuthContext';

const Profile = () => {
  const { user } = useAuthContext();
  return (
    <>
      <h2>Profile</h2>
      <ul>
        <li>Name: {user.name}</li>
        <li>Email: {user.email}</li>
      </ul>
    </>
  );
};

export default Profile;

Profile.jsファイルではAuthContextからuserオブジェクトの情報を取得しているだけでデータの取得処理は行なっていません。

/profileにアクセスするとProfile.jsファイルの中身が表示されるようにApp.jsファイルにルーティングを追加します。


import { Route, Routes } from 'react-router-dom';
import Home from './routes/Home';
import Signup from './routes/Signup';
import Login from './routes/Login';
import Header from './components/Header';
import ProtectedRoute from './components/ProtectedRoute';
import Profile from './routes/Profile';

function App() {
  return (
    <>
      <Header />
      <div
        style={{ display: 'flex', justifyContent: 'center', marginTop: '3em' }}
      >
        <Routes>
          <Route
            path="/"
            element={
              <ProtectedRoute>
                <Home />
              </ProtectedRoute>
            }
          />
          <Route path="/signup" element={<Signup />} />
          <Route path="/login" element={<Login />} />
          <Route
            path="/profile"
            element={
              <ProtectedRoute>
                <Profile />
              </ProtectedRoute>
            }
          />
        </Routes>
      </div>
    </>
  );
}

export default App;

ルーティング追加後にHeaderコンポーネントにリンクを設定します。


import { Link } from 'react-router-dom';
import { useAuthContext } from '../context/AuthContext';
const Header = () => {
  const { user, logout } = useAuthContext();
  return (
    <div style={{ display: 'flex', justifyContent: 'space-between' }}>
      <h1>
        <Link to="/">MERN</Link>
      </h1>
      <div>
        {user ? (
          <div>
            <span>{user.email}</span>
            <span>
              <Link to="/profile">Profile</Link>
            </span>
            <button onClick={logout}>Logout</button>
          </div>
        ) : (
          <div>
            <Link to="/login">Login</Link>
            <Link to="/signup">Signup</Link>
          </div>
        )}
      </div>
    </div>
  );
};

export default Header;

ログインを行うとメールアドレスの横にリンクが表示されます。

Profileページへのリンクの追加
Profileページへのリンクの追加

logoutのuseCallback Hookの依存配列にnavigateを設定するとProfileページに移動するとgetUser関数が実行されます。navigateを設定しない場合にはProfileページに移動してもgetUser関数は実行されません。


const logout = useCallback(() => {
  console.log('useAuth logout');
  const token = null;
  axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
  setUser(null);
  localStorage.removeItem('token');
  localStorage.removeItem('refresh_token');
  navigate('/login');
}, [navigate]);

ここまでの設定でアクセストークンを利用した認証を実装することができました。

リフレッシュトークンの追加

バックエンドの設定

アクセストークンでの設定が完了したので次はリフレッシュトークンの追加を行います。バックエンド側でリフレッシュトークンの作成を行い、ログイン時とユーザ作成時にアクセストークンと一緒にCookiesで戻す設定を行います。generateRefreshToken関数を利用してリフレッシュトークンを作成しています。リフレッシュトークの作成方法はアクセストークンと同じですが、キーと有効期限を環境変数で別の値に設定しています。signup, login関数でrefresh_tokenを作成しCookiesを設定しています。revokeToken関数ではrefresh_tokenのCookieの削除処理を追加しています。


const bcrypt = require('bcrypt');
const User = require('../models/User');
const jwt = require('jsonwebtoken');

const signup = async (req, res) => {
  const { name, email, password } = req.body;
  const salt = await bcrypt.genSalt();
  const passwordHash = await bcrypt.hash(password, salt);
  const user = await User.create({
    name,
    email,
    password: passwordHash,
  });

  const token = generateToken(user.email);
  const refresh_token = generateRefreshToken(user.email);

  res.cookie('token', token, { httpOnly: true });
  res.cookie('refresh_token', refresh_token, { httpOnly: true });

  return res.status(201).json({ message: 'ユーザが作成されました。' });
};

const login = async (req, res) => {
  const { email, password } = req.body;
  const user = await User.findOne({ email });
  if (!user) {
    return res
      .status(400)
      .json({ message: 'メールアドレスかパスワードに誤りがあります。' });
  }

  const match = await bcrypt.compare(password, user.password);
  if (!match) {
    return res
      .status(400)
      .json({ message: 'メールアドレスかパスワードに誤りがあります。' });
  }

  const token = generateToken(user.email);
  const refresh_token = generateRefreshToken(user.email);

  res.cookie('token', token, { httpOnly: true });
  res.cookie('refresh_token', refresh_token, { httpOnly: true });

  return res.status(200).json({ message: 'ログインに成功しました。' });
};

const generateToken = (email) => {
  return jwt.sign({ email }, process.env.JWT_SECRET_KEY, {
    expiresIn: process.env.JWT_EXPIRES_IN,
  });
};

const generateRefreshToken = (email) => {
  return jwt.sign({ email }, process.env.REFRESH_TOKEN_SECRET_KEY, {
    expiresIn: process.env.REFRESH_TOKEN_EXPIRES_IN,
  });
};

const user = async (req, res) => {
//略
};

const revokeToken = async (req, res) => {
  res.clearCookie('token');
    res.clearCookie('refresh_token');
  res.status(200).json({ message: 'Cookiesを削除しました。' });
};

exports.signup = signup;
exports.login = login;
exports.user = user;
exports.revokeToken = revokeToken;

.envファイルにリフレッシュトークンに関する環境変数を追加しています。


PORT=5000
DATABASE_URI=mongodb+srv://admin:<YOURPASSWORD>@cluster0.9ffqwfn.mongodb.net/?retryWrites=true&w=majority
JWT_SECRET_KEY=secret
REFRESH_TOKEN_SECRET_KEY=refresh_secret
JWT_EXPIRES_IN=10m
REFRESH_TOKEN_EXPIRES_IN=60m

アクセストークンではミドルウェアのvefityToken関数で検証を行っていましたがリフレッシュトークンでは新たに/api/auth/refresh_tokenのルーティングを追加して検証とアクセストークンの処理を行います。追加はauthRoutes.jsファイルで行います。


const express = require('express');
const router = express.Router();
const {
  signup,
  login,
  user,
  revokeToken,
  refreshToken
} = require('../controllers/authController');
const verifyToken = require('../middleware/verifyToken');

router.post('/signup', signup);
router.post('/login', login);
router.get('/user', verifyToken, user);
router.delete('/revoke_token', revokeToken);
router.get('/refresh_token', refreshToken);

module.exports = router;

authController.jsファイルにrefreshToken関数を追加します。refreshTokenの検証を行い、有効な場合は新しいアクセストークンをCookiesを利用して戻します。


//略
const refreshToken = async (req, res) => {
  const { refresh_token } = req.body;
  console.log('refresh_token', req.body.refresh_token);

  jwt.verify(
    refresh_token,
    process.env.REFRESH_TOKEN_SECRET_KEY,
    (err, decoded) => {
      if (err) {
        return res.status(401).json({ message: '有効でないトークンです。' });
      } else {
        const email = decoded.email;
        const token = generateToken(email);

        return res
          .status(201)
          .json({ message: '新しいアクセストークンを作成しました。', token });
      }
    }
  );
};

exports.signup = signup;
exports.login = login;
exports.user = user;
exports.refreshToken = refreshToken;

フロントエンド側の設定

ログイン、ユーザ登録を行うとこれまではCookiesにアクセストークンのみ保存されていましたが設定後ログインを行うとCookiesにtokenとrefresh_tokenが保存されていることが確認できます。

2つのTokenの確認
Cookiesの2つのTokenの確認

フロントエンド側ではaxiosのinterceptorsの機能を利用してアクセストークンの有効期限が切れ、エラーステータスが401(UnAuthorized)が戻された場合にリフレッシュトークンを利用してアクセストークンの再取得を行います。

utilsのaxios.jsファイルに下記のinterceptorsの処理を追加します。


import axios from 'axios';

axios.defaults.baseURL = 'http://localhost:5000/api/';
axios.defaults.withCredentials = true;

axios.interceptors.response.use(
  (response) => response,
  (error) => {
    const originalConfig = error.config;
    if (
      error.response.status === 401 &&
      originalConfig.url === '/auth/refresh_token'
    ) {
      return Promise.reject(error);
    }

    if (error.response.status === 401 && !originalConfig._retry) {
      originalConfig._retry = true;
      return axios.get('/auth/refresh_token').then(() => {
        return axios(originalConfig);
      });
    }
    return Promise.reject(error);
  }
);
export default axios;

エラーが発生した場合にエラーが発生した時のaxiosの設定情報をoriginalConfigとして保存します。エラーコードが401でurlが/auth/refresh_tokenの場合はリフレッシュトークンの検証に失敗しているのでエラーとなります。


  const originalConfig = error.config;
  if (
    error.response.status === 401 &&
    originalConfig.url === '/auth/refresh_token'
  ) {
    return Promise.reject(error);
  }

ステータスが401で_retryの値がfalseの場合はアクセストークンを利用した最初のエラーなのでGETリクエストで/auth/refresh_tokenにアクセスを行い、フリレッシュトークンの検証に成功したら新たに発行されたアクセストークンを利用して再度リクエストを送信します。


if (error.response.status === 401 && !originalConfig._retry) {
  originalConfig._retry = true;
  return axios.get('/auth/refresh_token').then(() => {
    return axios(originalConfig);
  });
}
return Promise.reject(error);
}

リフレッシュトークンの動作確認を行う場合はバックエンドの.envファイルでJWT_EXPIRES_IN, REFRESH_TOKEN_EXPIRES_INの設定値を短い時間に設定して行ってください。ブラウザのデベロッパーツールのコンソールまたはネットワークタブを確認しながら401のステータスコードや表示されるメッセージでリフレッシュトークンが利用されていることを確認してください。

logout関数を実行して/api/auth/revoke_refreshにDELETEリクエストを送信した場合はアクセストークンだけではなくリフレッシュトークンの削除も行える処理も追加されているかバックエンドのauthController.jsファイルのrevokeToken関数を確認しておきます。


const revokeToken = async (req, res) => {
  res.clearCookie('token');
  res.clearCookie('refresh_token');
  res.status(200).json({ message: 'Cookiesを削除しました。' });
};

リフレッシュトークンのDB保存

リフレッシュトークンは有効期間が長いためリフレッシュトークンを作成すると有効期限が切れるまで利用することができます。データベースでフレッシュトークンを管理することで有効期限内でも必要のないリフレッシュトークンを無効化することができます。

Tokenスキーマの設定

リフレッシュトークンを保存するコレクションを作成するためスキーマTokenを作成します。バックエンドのmodelsフォルダにToken.jsファイルを作成します。


const mongoose = require('mongoose');

const TokenSchema = new mongoose.Schema({
  userId: { type: mongoose.Schema.Types.ObjectId, required: true },
  token: { type: String, required: true },
  createdAt: { type: Date, default: Date.now, expires: 60 }, 
});

module.exports = mongoose.model('Token', TokenSchema);

useIdとtokenとcreateAtフィールドを設定します。createdAtフィールドではexpiresを設定することで指定した時間経過後に削除することができます。上記ではドキュメントを作成後60秒後に自動で削除されます。

リフレッシュトークンの保存

リフレッシュトークンのドキュメントへの追加はリフレッシュトークンを作成した直後に行います。authController.jsファイルのsignup、login関数に追加します。


const Token = require('../models/Token');
//略
const refresh_token = generateRefreshToken(user.email);

await Token.create({
  userId: user._id,
  token: refresh_token,
});

リフレッシュトークンの取得

リフレッシュトークンの検証を行う際にデータベースのTokenコレクションに存在するかどうかチェックを行います。存在する場合はリフレッシュトークンの検証を行いますが存在しない場合にはリフレッシュトークンが有効かどうかに関わらず検証の前にエラーを戻します。


const refreshToken = async (req, res) => {
  const refresh_token = req.cookies.refresh_token;

  const token = await Token.findOne({ token: refresh_token });
  if (!token) {
    return res.status(401).json({ message: '有効でないトークンです。' });
  }

//略

リフレッシュトークンの削除

データベースに保存したリフレッシュトークンはフロントエンドでユーザがログアウトした場合にも削除を行えるようにauthRoutes.jsのrevoke_tokenに処理を追加します。

req.bodyからrefresh_tokenを取り出しfindOneメソッドでrefresh_tokenと同じ値を持つトークンを取得して削除しています。


//略
const revokeToken = async (req, res) => {
  const refresh_token = req.cookies.refresh_token;

  try {
    const token = await Token.findOne({ token: refresh_token });
    if (!token) {
      return res
        .status(200)
        .json({ message: 'ログアウト処理が完了しました。' });
    } else {
      await token.remove();
      return res
        .status(200)
        .json({ message: 'ログアウト処理が完了しました。' });
    }
  } catch (err) {
    return res.status(401).json({ message: err.message });
  }
};

exports.signup = signup;
exports.login = login;
exports.user = user;
exports.refreshToken = refreshToken;
exports.revokeToken = revokeToken;

フロントエンド側の処理

logout関数を実行した際に/api/auth/revoke_tokenにDELETEリクエストを送信します。送信するリフレッシュトークンはローカルストレージから取り出します。


const logout = useCallback(() => {
  const token = null;
  axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
  setUser(null);
  const refresh_token = localStorage.getItem('refresh_token');
  if (refresh_token) {
    axios.delete('/auth/revoke_token', {
      data: { refresh_token },
    });
  }
  localStorage.removeItem('token');
  localStorage.removeItem('refresh_token');

  navigate('/login');
  //eslint-disable-next-line
}, []);

ログインを行うとMongoDBのTokenコレクションに追加されlogoutを実行するとTokenコレクションから削除が行われます。expiresで設定した時間経過するとコレクションから自動で削除されることを確認することができます。

ここまででアクセストークン、リフレッシュトークンをCookiesに保存して認証に利用する設定を確認することができました。