JWT を用いた認証処理の流れについて確認してみた

2022.11.25

こんにちは、アノテーションの及川です。

2 者間で安全にクレーム(E メールアドレスやパスワード等のユーザー情報)をやりとりするための方式である、JWT(Json Web Token)について手を動かしながらの理解を深めるため、こちらを参考にソースコードを実際に書いて動作を確認してみるなどの検証を行ってみました。

JWT(Json Web Token)とは

  • ざっくり簡単に言うと、サイト(サービス)にアクセスするための 許可証 みたいなもの
  • Cookie(クッキー) や Session(セッション)の考え方に近い
  • 詳細はこちらをご参照ください

今回検証する内容

ローカルサーバーを立ち上げて、ユーザーの新規登録の流れに沿いながら、JWT について検証していきます。

ユーサー新規登録の流れ

本検証では下記の流れが確認できるデモ用のローカルサーバを準備して、Postmanを用いて JWT の検証を行います。

  1. E メールアドレスとパスワードを入力
  2. 1.の入力された E メールアドレスとパスワードのバリデーションチェック(入力有無、パスワードの最低文字数を守れているか等)
  3. 1.の入力された E メールアドレスとパスワードのユーザーが DB(データーベース)に既に登録されているか確認
  4. (新規ユーザーの場合)パスワードの暗号化
  5. (新規ユーザーの場合)DB(データーベース)に保存
  6. JWT の発行

JWT 検証時に使用するツール

  • Postman 本検証で使用するため、事前にアプリ版をダウンロードするなどして準備をしておいてください。

Source Code を書く前の事前準備

package.json の作成

$ npm init -y

Express と Nodemon のインストール

$ npm i express nodemon

nodemon をインストール後、ソースコードを保存するたびにローカルサーバを自動で再起動してくれる様にするため、下記のように package.json の一部編集します。

package.json

## Before
"scripts": {
    "start": "node server.js"
  }

## After
"scripts": {
    "start": "nodemon server.js"
  }

その他本検証用に必要なパッケージのインストール

$ npm i bcrypt express-validator jsonwebtoken

Source Code

server.js

const express = require('express');
const app = express();
const PORT = 4000; // ← 3000番等、使用していないポート番号であればOKです。
const auth = require('./routes/auth');
const post = require('./routes/post');

app.use(express.json());
app.use('/auth', auth);
app.use('/post', post);

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

app.listen(PORT, () => {
  console.log('サーバーを起動中・・・');
});

package.json

{
  "name": "jwt-auth-tutorial",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "nodemon server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "bcrypt": "^5.0.1",
    "express": "^4.18.1",
    "express-validator": "^6.14.2",
    "jsonwebtoken": "^8.5.1",
    "nodemon": "^2.0.19"
  }
}

routes/auth.js

const router = require('express').Router();
const { body, validationResult } = require('express-validator');
const { User } = require('../db/User');
const bcrypt = require('bcrypt');
const JWT = require('jsonwebtoken');

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

// ユーザー新規登録
router.post(
  '/register',
  body('email').isEmail(),
  body('password').isLength({ min: 7 }),
  async (req, res) => {
    const email = req.body.email;
    const password = req.body.password;

    // バリデーションチェック
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }

    // DB にユーザーが存在しているか確認
    const user = User.find((user) => user.email === email);
    if (user) {
      return res.status(400).json([
        {
          message: 'そのユーザーはすでに存在しています。',
        },
      ]);
    }

    // パスワードの暗号化
    let hashedPassword = await bcrypt.hash(password, 10);
    console.log(hashedPassword);

    // DB へ保存
    User.push({
      email,
      password: hashedPassword,
    });

    // クライアントへJWT発行(JSON Web Token: 許可証)
    const token = await JWT.sign(
      {
        email,
      },
      'SECRET_KEY', // ←ここの文字列は何でも良いです、今回検証のため分かりやすく左記の文字列を指定しています。
      {
        expiresIn: '12h',
      },
    );

    return res.json({
      token: token,
    });
  },
);

// ログイン用のAPI
router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  const user = User.find((user) => user.email === email);

  if (!user) {
    return res.status(400).json([
      {
        message: '入力されたユーザーは存在しません',
      },
    ]);
  }

  // パスワードの復号、照合
  const isMatch = await bcrypt.compare(password, user.password);

  if (!isMatch) {
    return res.status(400).json([
      {
        message: '入力されたパスワードが異なります。',
      },
    ]);
  }

  const token = await JWT.sign(
    {
      email,
    },
    'SECRET_KEY',
    {
      expiresIn: '12h',
    },
  );

  return res.json({
    token: token,
  });
});

//DBのユーザーを確認するAPI
router.get('/allUsers', (req, res) => {
  return res.json(User);
});

module.exports = router;

routes/post.js

const router = require('express').Router();
const { publicPosts, privatePosts } = require('../db/Post');
const checkJWT = require('../middleware/checkJWT');

// 誰でも見れる記事閲覧用のAPI
router.get('/public', (req, res) => {
  res.json(publicPosts);
});

// JWTを持っている人用のAPI
router.get('/private', checkJWT, (req, res) => {
  res.json(privatePosts);
});

module.exports = router;

db/Post.js

本検証では、検証の手間を軽減するため、以下のように擬似的な DB(Post.js) を用意します。

const publicPosts = [
  {
    title: '1. 誰でも見れる記事',
    body: '1. 誰でも見れる記事です',
  },
  {
    title: '2. 誰でも見れる記事-2',
    body: '2. 誰でも見れる記事です。',
  },
];

const privatePosts = [
  {
    title: '1. JWTを持っているユーザーしか見れない記事',
    body: '1. JWTを持っているユーザーしか見れない記事です。',
  },
  {
    title: '2. JWTを持っているユーザーしか見れない記事',
    body: '2. JWTを持っているユーザーしか見れない記事です。',
  },
];

module.exports = { publicPosts, privatePosts };

db/User.js

「db/Post.js」 と同様に以下のように疑似的な DB(User.js) を用意します。

const User = [
  {
    email: 'test01@gmail.com',
    password: '123456789',
  },
];

module.exports = { User };

middleware/checkJWT.js

const JWT = require('jsonwebtoken');

module.exports = async (req, res, next) => {
  // JWTを持っているか確認->リクエストヘッダの中の x-auth-token を確認
  const token = req.header('x-auth-token');

  if (!token) {
    res.status(400).json([{ message: '権限がありません' }]);
  } else {
    try {
      let user = await JWT.verify(token, 'SECRET_KEY');
      console.log(user);
      req.user = user.email;
      next();
    } catch {
      return res.status(400).json([
        {
          message: 'トークンが一致しません',
        },
      ]);
    }
  }
};

検証

Postman 設定

Collections より、検証用に 5 つの Request の API エンドポイントを追加します。

REGISTER

  • API TEST / REGISTER
  • POST
    • http://localhost:4000/auth/register
  • Body
    • raw
    • JSON
  • JSON
{
  "email": "test02@gmail.com",
  "password": "123456789"
}

LOGIN

  • API TEST / LOGIN
  • POST
    • http://localhost:4000/auth/login
  • Body
    • raw
    • JSON
  • JSON
{
  "email": "test02@gmail.com",
  "password": "123456789"
}

GET ALL USERS

  • API TEST / GET ALL USERS
  • GET
    • http://localhost:4000/auth/allUsers

GET PUBLIC POST

  • API TEST / GET PUBLIC POST
  • GET
    • http://localhost:4000/post/public

GET PRIVATE POST

  • API TEST / GET PRIVATE POST
  • GET
    • http://localhost:4000/post/private

ローカルサーバ起動

$ npm start

Postman - 既存の登録ユーザー確認

GET ALL USERS(/auth/allUsers) API にて、「Send」を押下すると、既存の登録ユーザーの情報を確認することができます。

[
  {
    "email": "test01@gmail.com",
    "password": "123456789"
  }
]

Postman - 新規にユーザー登録

REGISTER(/auth/register) API にて、「Send」を押下すると、ユーザーを新規に登録することができます。

ユーザーが無事に登録完了の場合、token(JWT)も発行されるようになっています。

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3QwMkBnbWFpbC5jb20iLCJpYXQiOjE2NjE0MzIzODksImV4cCI6MTY2MTQ3NTU4OX0.XqSMhNyjFzcFUYA_5AvqPSj0Zh1S4-inLpBVyyZtxNg"
}

Postman - 【再】登録のユーザー確認

GET ALL USERS(/auth/allUsers) API にて、「Send」を押下すると、先程新規に登録したユーザーの情報も含めて確認することができます。

また、password が暗号化されて保存できたことが確認できます。

(本検証では比較のため、既存データは平文の状態で保存しています。)

[
  {
    "email": "test01@gmail.com",
    "password": "123456789"
  },
  {
    "email": "test02@gmail.com",
    "password": "$2b$10$WCofSK09qK/0ZTBMEU40n.NTO/Z/USGPENtNs3IdK.VPBeQKqXp4u"
  }
]

Postman - 記事閲覧(パブリック)

GET PUBLIC POST(/post/public) API にて、「Send」を押下すると、以下のように疑似 DB に保存した記事情報について確認することができます。

[
  {
    "title": "1. 誰でも見れる記事",
    "body": "1. 誰でも見れる記事です"
  },
  {
    "title": "2. 誰でも見れる記事",
    "body": "2. 誰でも見れる記事です。"
  }
]

Postman - 記事閲覧(プライベー ト)

GET PRIVATE POST(/post/private) API にて、「Send」を押下すると、権限がありませんと表示されますが、プライベート(個人)の記事は、認証情報(token:JWT)をリクエストの際に API エンドポイントに渡す必要があるため、これは正しい挙動となります。

[
  {
    "message": "権限がありません"
  }
]

Postman - ログイン

プライベートの記事情報を見るため、下記のように  LOGIN(/auth/login) API にて、事前にログインを行い、認証情報であるtoken(JWT)を取得します。

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3QwMkBnbWFpbC5jb20iLCJpYXQiOjE2NjE0MzQ4NjQsImV4cCI6MTY2MTQ3ODA2NH0.qd8xaec5XSLdIBWMYa2zTYxyCVY4eDEbMuqBu0OSReE"
}

Postman - 記事閲覧(プライベート)

GET PRIVATE POST(/post/private) API にて、ログイン後に取得した token(JWT) をヘッダー(x-auth-token)に設定して「Send」ボタンを押下すると、以下のようにプライベー トの記事情報のレスポンスを受け取ることができます。

[
  {
    "title": "1. JWTを持っているユーザーしか見れない記事",
    "body": "1. JWTを持っているユーザーしか見れない記事です。"
  },
  {
    "title": "2. JWTを持っているユーザーしか見れない記事",
    "body": "2. JWTを持っているユーザーしか見れない記事です。"
  }
]

まとめ

JWT について、なんとなくの知識としては知ってはいますが、実際どのようにして使用されているのか等がうまく整理できていませんでした。

そのため、本記事のように実際に手を動かしての実践形式で試すことは、知識を定着させることにおいても大切だと実感しました。

この記事が少しでも誰かのお役にたてば幸いです。

参考資料

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。少しでもご興味あれば、アノテーション株式会社 WEB サイトをご覧ください。