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

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

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] 3.0.2
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,cjs,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

アカウントの作成方法や初期データベースまで作成までの流れについてはMongoDB Atlasでも随時変更が行われているので下記の公開済みの記事を参考にしてください。

ExpressサーバからMongoDBへの接続には接続文字列が必要になります。Overviewの画面に表示されている”CONNECT”ボタンをクリックします。

Overview画面
Overview画面

接続に関する選択画面が表示されるので一番上の”Connect to your application”の”Drivers”をクリックします。

接続に関する画面
接続に関する画面

接続に必要となる情報が表示されます。特に3番目の”Add your connection string into your application code”が重要です。Expressからの接続時に利用します。

接続情報の確認
接続情報の確認

接続情報は.envファイルに保存します。接続の文字列の中の<your_password>にはユーザ作成時に設定したパスワードを入力してください。もしパスワードがわからない場合は管理画面の左側のメニューのDatabase Accessから変更することができます。


PORT=5000
DATABASE_URI=mongodb+srv://johodoe10:<YOUR PASSWORD>@cluster0.ya8yt7x.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}!`));

ここまでの設定でインストールしたライブラリのパッケージを確認するためにpackage.jsonファイルの中身を確認しておきます。


{
  "name": "backend",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "nodemon index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "mongoose": "^8.0.2",
    "nodemon": "^3.0.2"
  }
}

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ファイルの中にリクエストを記述していきます。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: 163
ETag: W/"a3-hMYmTYniiUA75ULhmHSivKapOIs"
Date: Wed, 06 Dec 2023 01:47:12 GMT
Connection: close

{
  "message": "ユーザが作成されました。",
  "user": {
    "name": "john",
    "email": "john@example.com",
    "password": "password",
    "_id": "656fd2a0acfb4ee97c9cf973",
    "__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-rv+2FlFqdQQxNJfl1dWZbFt1XzA"
Date: Wed, 06 Dec 2023 01:48:39 GMT
Connection: close

{
  "message": "ユーザが作成されました。",
  "user": {
    "name": "jane",
    "email": "jane@example.com",
    "password": "$2b$10$3Y11U04y4DPQ7x0OeRXX..1Bp98U3B1hNGlb12ZfLzcllwg0dRuTK",
    "_id": "656fd2f7691d81c7a1ba67ab",
    "__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を利用して動作確認を行うためapi/auth/loginに対してPOSTリクエストを送信します。


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

{
    "email": "jane@example.com",
    "password": "password"
}
REST Clientのtest.httpファイルを利用して複数のリクエストを記述する場合はリクエストの選択に”###”を追加をします。

登録済みのユーザの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関数を実行してトークンを作成しトークンを戻すように設定します。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: 'メールアドレスかパスワードに誤りがあります。' });

  const token = generateToken(user.email);

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

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


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

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

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


HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 390
ETag: W/"186-oOQSNQW0sTshkg/ii8CyHNu6MeY"
Date: Tue, 13 Dec 2022 08:06:01 GMT
Connection: close

{
  "message": "ログインに成功しました。",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImphbmVAZXhhbXBsZS5jb20iLCJpYXQiOjE2NzA0ODY3NjEsImV4cCI6MTY3MDQ4Njc5MX0.hEKfZAaAgtMnBjRGIHTNmDC7sVP8HVcHMUcXv00lgXk"
}

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

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

アクセストークンの検証

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

これまでに作成した/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 = async (req, res) => {
  console.log(req.headers);
};

//略

exports.user = user;

/api/auth/loginにPOSTリクエストを送信してアクセストークンを取得します。取得したアクセストークンはREST Clientで下記のように設定することでヘッダー情報として送信することができます。


##
GET http://localhost:5000/api/auth/user
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImphbmVAZXhhbXBsZS5jb20iLCJpYXQiOjE2NzA5ODYxMDMsImV4cCI6MTY3MDk4NjExM30.KCEFCC0QMTYBRLr2on5srfnliu0jE9rrz4q2lWzw2Bc

REST Clientからアクセストークンを設定したリクエストを送信します。npm startコマンドを実行しているターミナルにヘッダー情報が表示されます。


{
  'user-agent': 'vscode-restclient',
  authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImphbmVAZXhhbXBsZS5jb20iLCJpYXQiOjE2NzA5ODYxMDMsImV4cCI6MTY3MDk4NjExM30.KCEFCC0QMTYBRLr2on5srfnliu0jE9rrz4q2lWzw2Bc',
  'accept-encoding': 'gzip, deflate',
  host: 'localhost:5000',
  connection: 'close'
}

表示されているメッセージからauthorizationに送信したトークンが保存されていることがわかります。authorizationの値からアクセストークンのみを取り出します。


const bearToken = req.headers['authorization'];
const token = bearToken.split(' ')[1];

トークンが存在する場合はverifyメソッドでトークンの検証を行います。


const user = async (req, res) => {
  const bearToken = req.headers['authorization'];
  const token = bearToken.split(' ')[1];
  if (!token) {
    return res.status(400).json({ message: 'アクセストークンはありません。' });
  }

  console.log('token', token);

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

REST Clientを利用して有効なアクセストークンを送信した場合には”有効なトークンです。”のメッセージが戻されること、有効でないトークンを送信した場合には”有効でないトークンです。”が戻されることを確認してください。

ミドルウェアの設定

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

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

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


const jwt = require('jsonwebtoken');

const verifyToken = (req, res, next) => {
  try {
    const bearToken = req.headers['authorization'];
    const token = bearToken.split(' ')[1];

    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 {
        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 verifyToken = require('../middleware/verifyToken');
//略
router.get('/user', verifyToken, user);

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からアクセストークンを取得します。その後アクセストークンを利用して/api/auth/userにアクセスするとトークンを取得した(ログインを行った)ユーザ情報が取得できることを確認してください。

ユーザ一覧の取得

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

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 authRoutes = require('./routes/authRoutes');
const usersRoutes = require('./routes/usersRoutes');
//略
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/にアクセスを行いユーザ一覧が取得できるか確認してください。


###
GET http://localhost:5000/api/users
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImphbmVAZXhhbXBsZS5jb20iLCJpYXQiOjE2NzA5ODg5OTcsImV4cCI6MTY3MDk5MDc5N30.2g-r5GN7HWdX81nSZIopmK0WMg569julTSOJ9W8GHuM

//Reponse
{
  "users": [
    {
      "_id": "639183d656fccbdc826b6d94",
      "name": "john",
      "email": "john@example.com"
    },
    {
      "_id": "63918c200b42d6169c0f176f",
      "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によるアクセスの制限を解除するためバックエンド側(backendフォルダの中)で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');

dotenv.config();

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

設定後再度フロントエンドからアクセスすると次はステータスコード401(UnAuthorized)でエラーが戻されます。エラーの原因はアクセストークンがヘッダーに存在しないままリクエストを送信しているためミドルウェアの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に保存されているユーザ情報がブラウザ上に表示されます。

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

フロントエンドのReactからバックエンドのExpressサーバにアクセスを行い、MongoDB Atlasに保存されているデータを取得してブラウザ上に表示できるようになりました。

ユーザ登録設定

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オブジェクトの情報が表示されます。

“/”にリダイレクトされるので登録したユーザもユーザ一覧に表示されます。ここではkevinという名前のユーザを作成しています。

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

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

ログイン設定

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


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);
    }
  };
//略

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

ログインに成功するとブラウザのコンソールにはmessage以外にトークンが戻されていることが確認できます。


{message: 'ログインに成功しました。', token: 'eyJhbGcOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6I…Q3Mn0.zgzM6CDMJqNxUYlWQRABpBwZDt2GcMnz95s-d3Dgano'}

ログインが成功するとアクセストークンが取得できることがわかったので取得したTokenの設定を行います。REST Clientでヘッダーにトークンを設定したようにaxiosのヘッダーに取得したトークンを設定しています。


const handleSubmit = async (e) => {
  e.preventDefault();
  try {
    const response = await axios.post('/auth/login', {
      email: emailRef.current.value,
      password: passwordRef.current.value,
    });
    const token = response.data.token;
    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    navigate('/');
  } catch (error) {
    console.log(error);
  }
};

アクセストークンがヘッダーに存在してないために無効にしていたミドルウェアの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エラーによりユーザ情報が取得できなくなります。

ログインを行ってください。ログインが成功した場合にはバックエンドから受け取ったアクセストークンをヘッダーに設定できるように更新したため”/”にリダイレクトされると/api/usersにGETリクエストとトークンが送信されます。トークンの検証に成功した場合にはユーザ一覧が表示されます。

アクセストークンを取得後にブラウザをリロードするとアクセストークンの情報がメモリ上から消えるため”/”にアクセスすると401エラーが発生します。現在の設定ではユーザ一覧を取得できるのはログイン処理を行った直後だけです。

ユーザ情報の取得

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


const handleSubmit = async (e) => {
  e.preventDefault();
  try {
    const response = await axios.post('/auth/login', {
      email: emailRef.current.value,
      password: passwordRef.current.value,
    });
    const token = response.data.token;
    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    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 {
      const response = await axios.post('/auth/login', data);
      const token = response.data.token;
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
      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関数ではユーザ登録にユーザ情報を戻していましたがlogin関数と同様にトークンを作成して戻すように変更を行います。


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);

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

フロントエンド側のuseAuth.jsのsignup関数も戻されるトークンをヘッダーに設定できるように更新します。


const signup = async (data) => {
  try {
    const response = await axios.post('/auth/signup', data);
    const token = response.data.token;
    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    getUser();
    navigate('/');
  } catch (error) {
    console.log(error);
  }
}; 

ローカルストレージへのトークンの保存

現在の設定ではページのリロードを行うとメモリ上に保存されたトークン情報が消えるためユーザ一覧を表示するためにはログインかユーザ登録を行う必要があります。

ログインを完了後にトークン情報をローカルストレージに保存することでページのリロード後でもユーザ一覧が表示できるように設定を行います。

ログイン後にlocalStorageのsetItemメソッドを利用してtokenという名前でバックエンドサーバから取得したアクセストークンの値を保存します。


const login = async (data) => {
  try {
    const response = await axios.post('/auth/login', data);
    const token = response.data.token;
    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    localStorage.setItem('token', token); //追加
    getUser();
    navigate('/');
  } catch (error) {
    console.log(error);
  }
};

設定後ログインを行い、ブラウザのデベロッパーツールのapplicationタブの開いて左側のStorageメニューからLocal Storageの中身を確認します。keyにtoken, Valueにトークンを確認することができます。

local Storageに保存したTokenの確認
local Storageに保存したTokenの確認

保存したトークンを取り出す処理を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 { useEffect } from 'react';
import axios from './utils/axios';

function App() {
  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    }
  }, []);

  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;

ローカルストレージにトークンが存在し、有効期限が切れていない有効なトークンである場合はページをリロードしても”/”にアクセスするとユーザ一覧が表示できるようになります。しかしデベロッパーツールのコンソールには401のエラーが表示されています。React 18ではuseEffectが2回実行され、1回目は失敗し2回目は成功することでユーザ一覧が表示されています。1回目の失敗の理由はローカルストレージからTokenの値を取得してヘッダーに設定する前にHome.jsファイルの/api/usersへのGETリクエストが実行されるためです。Home.jsの/api/usersへのGETリクエストが必ずTokenの値の取得後に実行されるように後ほど設定を行います。

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

有効なアクセストークンを持っている場合/api/auth/userにアクセスするとトークンを作成したユーザの情報を取得することができます。ログイン後に取得できるユーザ情報をアプリケーション内で共有できるようにuseContext 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 { useEffect } from 'react';
import axios from './utils/axios';
import Header from './components/Header';

function App() {
  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    }
  }, []);

  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オブジェクトが取得できるようにApp.jsのuseEffectで行っているローカルストレージのTokenの値の取得に加えてTokenを利用してユーザ情報を取得する処理を追加します。

useEffectの処理はApp.jsからAuthContext.jsファイルに移動します。useEffectにgetUser関数とsetUser関数を追加しています。どちらもuseEffectの依存配列に追加しuseAuthから利用しています。


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

const AuthContext = createContext();

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

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

  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
      getUser();
    } else {
      setUser(null);
    }
  }, [getUser, setUser]);

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

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

useAuth.jsファイルではsetUserと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 signup = async (data) => {
//略
  };

  const login = async (data) => {
//略
  };

  const getUser = useCallback(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, setUser, getUser };
};

export default useAuth;

上記の設定後はリロードするとローカルストレージに保存されているTokenを利用してユーザ情報を取得するためメールアドレスが右上に表示されるようになります。

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

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

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”にリダイレクトされます。

理由はローカルストレージからトークンを取得しgetUser関数でユーザ情報を取得する前にProtectedRouteが実行されuserに値が存在しないため”/login”へとリダイレクトされるためです。リロードしても”/”でユーザ一覧が表示されるように設定を行います。

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


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

AuthContext.jsのuseEffectではローカルストレージに値がない場合はsetUserでuserオブジェクトの値をnullに設定しています。


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

  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
      getUser();
    } else {
      setUser(null);
    }
  }, [getUser, setUser]);
//略

getUser関数でデータ取得に成功した場合にはuserにはuser情報が保存され、ローカルストレージにTokenがない場合は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”にリダイレクトされることなくユーザ一覧が表示されます。

ログアウト処理の追加

ここまではログイン処理に注目しておりログアウトの処理は全く行っていませんでした。ログアウトの処理を行うために新たにuseAuth.jsファイルにlogout関数を追加します。logout関数ではローカルストレージ、ヘッダー、userオブジェクトの値をクリアしています。追加したlogout関数は、useAuth.jsのreturnのオブジェクトに追加します。


  const logout = () => {
    const token = null;
    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    setUser(null);
    localStorage.removeItem('token');
    navigate('/login');
  };
//略
  return { login, signup, user, setUser, getUser, logout };

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


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

const AuthContext = createContext();

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

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

  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
      getUser();
    } else {
      setUser(null);
    }
  }, [getUser, setUser]);

  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ボタンをクリックするとログイン画面が表示されます。logoutボタンをクリック後にログインを行うとログインは完了していますが再度ログイン画面が表示されます。login処理の中ではgetUser関数を実行していますが現在の設定ではgetUser関数が完了する前に”/”にリダイレクトされる設定になっています。logoutを行なっているためローカルストレージにTokenも存在しないのでログイン画面を開いた時にuserの値がnullに設定されています。ProtectedRoutes.jsではuserの値がnullなので”/”にリダイレクトされてもそのまま”/login”にリダイレクトされるためログイン後に再度ログイン画面が表示されます。

getUser関数の処理が完了するのを待つためにawaitを追加します。


const login = async (data) => {
  try {
    const response = await axios.post('/auth/login', data);
    const token = response.data.token;
    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    localStorage.setItem('token', token);
    await getUser(); // await追加
    navigate('/');
  } catch (error) {
    console.log(error);
  }
};

await追加後はlogoutボタンを押してログアウト後にログインすると”/”にリダイレクトされますがgetUserによりユーザ情報の取得が完了しているので/loginにリダイレクトされることがなくなります。

追加した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(() => {
  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');
  //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(() => {
  const token = null;
  axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
  setUser(null);
  localStorage.removeItem('token');
  localStorage.removeItem('refresh_token');
  navigate('/login');
}, [navigate]);

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

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

バックエンドの設定

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


const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
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,
  });

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

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

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); //追加

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

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) => {
//略
};


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 verifyToken = require('../middleware/verifyToken');

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

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

module.exports = router;

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


//略
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;

フロントエンド側の設定

ログイン、ユーザ登録を行うとこれまではアクセストークンのみ戻されていましたが今後はリフレッシュトークンも一緒に戻されます。戻されたトークンはどちらもローカルストレージに保存します。


const login = async (data) => {
  try {
    const response = await axios.post('/auth/login', data);
    const token = response.data.token;
    const refresh_token = response.data.refresh_token;

    axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    localStorage.setItem('token', token);
    localStorage.setItem('refresh_token', refresh_token);
    await getUser();
    navigate('/');
  } catch (error) {
    console.log(error);
  }
};

設定後ログインを行うとlocalStorageにrefresh_tokenが保存されていることが確認できます。

ローカルストレージの確認
ローカルストレージの確認

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

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


import axios from 'axios';

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

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

    if (error.response.status === 401 && !originalConfig._retry) {
      originalConfig._retry = true;
      const refresh_token = localStorage.getItem('refresh_token');
      return axios
        .post('/auth/refresh_token', {
          refresh_token,
        })
        .then((response) => {
          originalConfig.headers[
            'Authorization'
          ] = `Bearer ${response.data.token}`;
          localStorage.setItem('token', response.data.token);
          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'
) {
  localStorage.removeItem('token');
  localStorage.removeItem('refresh_token');
  return Promise.reject(error);
}

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


if (error.response.status === 401 && !originalConfig._retry) {
  originalConfig._retry = true;
  const refresh_token = localStorage.getItem('refresh_token');
  return axios
    .post('/auth/refresh_token', {
      refresh_token,
    })
    .then((response) => {
      originalConfig.headers[
        'Authorization'
      ] = `Bearer ${response.data.token}`;
      console.log('new token', response.data.token);
      localStorage.setItem('token', response.data.token);
      return axios(originalConfig);
    });
}

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

signup関数にもrefresh_tokenの設定を行うのでuseAuth.jsファイルは下記のようになります。


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

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

  const signup = async (data) => {
    try {
      const response = await axios.post('/auth/signup', data);
      const token = response.data.token;
      const refresh_token = response.data.refresh_token;

      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
      localStorage.setItem('token', token);
      localStorage.setItem('refresh_token', refresh_token);
      await getUser();
      getUser();
      navigate('/');
    } catch (error) {
      console.log(error);
    }
  };

  const login = async (data) => {
    try {
      const response = await axios.post('/auth/login', data);
      const token = response.data.token;
      const refresh_token = response.data.refresh_token;

      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
      localStorage.setItem('token', token);
      localStorage.setItem('refresh_token', refresh_token);
      await getUser();
      navigate('/');
    } catch (error) {
      console.log(error);
    }
  };

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

  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]);

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

export default useAuth;

リフレッシュトークンの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.body;

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

//略

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

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


const express = require('express');
const router = express.Router();
const verifyToken = require('../middleware/verifyToken');

const {
  signup,
  login,
  user,
  refreshToken,
  revokeToken,
} = require('../controllers/authController');

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

module.exports = router;

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


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

  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で設定した時間経過するとコレクションから自動で削除されることを確認することができます。

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