【Node.js / Express】JWTを使用した認証機能を実装

## ゴール

- ログインAPI実行時にJWTを生成する。
- ローカルストレージに生成したトークンを保存し、リクエスト時には保存したトークンをヘッダー`Authorization`に自動で付与されるようにする。
- ヘッダーに付与されたトークンを元に認証チェックを行うミドルウェアを作成する。
- Axiosのインターセプターで、401エラー発生時にログイン画面に遷移するようにする。

## 前提
- [こちらのリポジトリ](https://github.com/whopper1962/vue-express-base-js)のフォルダ構成/アーキテクチャに基づいて実装しています。
- 今回の認証機能を実装したサンプルコードは[こちらのブランチ](https://github.com/whopper1962/vue-express-base-js/tree/feature/auth)を参照してください。

## CL(Vue)側実装

### ログイン画面の実装
シンプルなログイン画面を実装しておきます。
~~~vue:src/views/LoginView.vue
<template>
  <div>
    <form>
      <h1 class="border-bottom my-4">Login</h1>
      <div class="mb-3">
        <label for="exampleInputEmail1" class="form-label">Email address</label>
        <input type="email" class="form-control" v-model="email" />
      </div>
      <div class="mb-3">
        <label for="exampleInputPassword1" class="form-label">Password</label>
        <input
          type="password"
          class="form-control"
          autocomplete
          v-model="password"
        />
      </div>
      <button type="button" class="btn btn-primary" @click="login()">
        Login
      </button>
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      email: "",
      password: "",
    };
  },
  methods: {
    async login() {
      try {
        const requestBody = {
          email: this.email,
          password: this.password,
        };
        const { data } = await this.axios.post("/login", requestBody);
        console.log("GENERATED TOKEN:", data);
        localStorage.setItem("accessToken", data);
        this.$router.push({
          name: "homeView",
        });
      } catch {
        alert("Authorization failed!");
      }
    },
  },
};
</script>
~~~
![Screen Shot 2023-07-19 at 10.15.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/35b28df5-ae89-0bfd-1dd6-0a2f7e3cb9a8.png)


### Axiosインターセプター設定
API実行時に401エラーが返却された場合は、作成したログイン画面に遷移するようにしようと思います。

API実行の度に上記の処理を書くのは面倒なので、エラーレスポンスを受けた際の共通処理として、インターセプターを定義します。
> インターセプターとは、リクエスト送信時/レスポンス受信時に挟み込むことができる処理です。共通する処理はインターセプターとして定義することで共通化することができます。詳しくは[こちらの記事](https://qiita.com/buntafujikawa/items/78e9204cc9ea7eaabd3d)で解説されています。

#### リクエスト
ローカルストレージに保存されたトークン（`accessToken`）を取得して、リクエストヘッダー「`Authorization`」を付与する処理を書きます。
~~~javascript
myAxios.interceptors.request.use((config) => {
  const accessToken = localStorage.getItem("accessToken");
  config.headers = {
    ...config.headers,
    Authorization: `Bearer ${accessToken}`,
  };
  return config;
});
~~~
> `Authorization`ヘッダーにJWTを付与する際は、JWTの前に「`Bearer`」という文字列を付与することが一般的です。これは`Authorization`ヘッダーに含めるトークンがJWTであることを明示的にすることにより、不正なトークン形式が入れられないようにするなどの効果があります。

#### レスポンス
エラーレスポンスを受けた際に、ステータスが401の場合はローカルストレージに保存されたトークン（`accessToken`）を削除して、ログイン画面（`loginView`）に遷移させる処理を書きます。
~~~javascript
myAxios.interceptors.response.use((response) => {
  return response;
}, (error) => {
  if (error.response.status === 401) {
    alert("Your session has expired, please login again.");
    localStorage.removeItem("accessToken");
    router.push({
      name: "loginView"
    });
  }
  return Promise.reject(error);
});
~~~

## API(Express)側実装
今回はJWTを使用した認証を実装します。

JWTとは、JSON Web Tokenの略で、何やら情報（ペイロード）を持たせることができる暗号のような文字列のことを指します。ログインしているユーザーをペイロードで指定したトークンの生成を行い、詳しい説明は[こちらの記事](https://qiita.com/knaot0/items/8427918564400968bd2b)を参照してください。

今回は、JWTの生成や検証をすることができるnpmパッケージ「[jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken)」を使用します。以下コマンドでインストールをしてください。
~~~bash
npm install jsonwebtoken
~~~
`sign`メソッドを使用してJWTを生成することができます。
~~~javascript
const jwt = require("jsonwebtoken");

jwt.sign(
  { userId: 1, email: "whopper@example.com" },  // ペイロード（持たせる情報）
  "SECRET_KEY",  // 秘密鍵（.envファイル等で定義することを推奨します）
  {
    expiresIn: "100000" // オプション（主に有効期限/msを設定）
  }
);
~~~
`verify`メソッドを使用して検証を行います。
~~~javascript
const jwt = require("jsonwebtoken");

jwt.verify(
  token, // 検証対象のJWT
  "SECRET_KEY" // JWT生成時に使用する秘密鍵
);
~~~

### ログインAPIを実装
実際にログインAPIを実装していきます。

まずはエンドポイントの追加です。ログインAPIなので`/login`にしましょう。実行するコントローラーメソッドは`AuthController.login`とします。
~~~javascript:src/router.js
const AuthController = require("./controllers/AuthController.js");

router.post("/login", AuthController.login);
~~~

コントローラーを追加します。主に`AuthService`クラスの`login`メソッドを実行する処理を書きます。メソッド実行時には、引数としてリクエストボディを渡します。
~~~javascript
const AuthService = require("../services/AuthService");

module.exports = class AuthController {
  static async login(request, response, next) {
    try {
      const { body } = request;
      const authInfo = await new AuthService().login(body);
      response.json(authInfo);
    } catch (e) {
      next(e);
    }
  }
};
~~~
サービスを追加します。ここでは受け取ったリクエストボディ`email`と`password`を元に、実際にトークンを生成する処理を書きます。
~~~javascript
const jwt = require("jsonwebtoken");
const User = require("../models/user");

module.exports = class AuthService {
  async login(body) {
    const { email, password } = body;
    const loggedInUser = await User.findOne({
      where: {
        email,
        password,
      },
    });

    if (!loggedInUser) throw new Error("Authorization failed!");

    try {
      const token = jwt.sign(
        { userId: loggedInUser.id, email: loggedInUser.email },
        "SECRET_KEY",
        { expiresIn: "600000" } // 10 minutes
      );
      return token;
    } catch {
      throw new Error("Token generation failed!");
    }
  }

  async signUp() {}
};
~~~

では、実際にログインAPIを実行してみます。`users`テーブルに任意のレコードを追加しておいてください。
ログイン画面に遷移し、メールアドレスとパスワードを入力し、「Login」ボタンを押下してください。
![Screen Shot 2023-07-19 at 10.17.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/a1b509de-0d22-f5e6-cee7-bbfb45eb706b.png)
認証情報が誤っていない場合は、コンソールに以下のようなログが出力されます。
~~~bash
GENERATED TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImVtYWlsIjoidGVzdEVtYWlsQGdtYWlsLmNvbSIsImlhdCI6MTY4OTcyOTYxMSwiZXhwIjoxNjg5NzI5NjEzfQ.vliqXAR7YRpGga2PXaH3nQyFhanaVsFxEcDBx-jGXc8
~~~
ローカルストレージを確認すると、生成されたJWTが保存されていることが確認できます。
![Screen Shot 2023-07-20 at 9.18.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/4e7517be-78c2-25b3-53af-c6e6384c5f0e.png)


### ミドルウェアを実装
リクエストヘッダー「`Authorization`」で指定されたトークンが、有効な場合はAPIの実処理を実行し、無効な場合は401エラーを返すミドルウェアを作成します。
このミドルウェアを認証が必要なAPIに適用することで、API認証を適用します。
![Screen Shot 2023-07-18 at 10.13.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/7fca7854-d8c6-72bb-fb7e-d464d4956f09.png)

~~~javascript:src/middlewares/verifyAccessToken.js
const jwt = require("jsonwebtoken");

module.exports = async function verifyAccessToken(request, response, next) {
  const token = request.headers.authorization.split(" ")[1];
  if (!token) response.status(401).send("Token must be provided!");

  try {
    const decodedToken = jwt.verify(token, "SECRET_KEY");
    console.log("=============AUTH TOKEN=============");
    console.debug(decodedToken);
    next();
  } catch {
    response.status(401).send("Authorization failed!");
  }
};
~~~
作成したミドルウェアを、APIに適用していきます。
~~~javascript:src/router.js
router.get("/users", verifyAccessToken, UsersController.fetchAll);
~~~

該当のAPIを実行してみます。[こちらのリポジトリ](https://github.com/whopper1962/vue-express-base-js)を使用している場合は、URL`/`に遷移してください。「Get users」ボタンを押下すると、APIを実行することができます。
![Screen Shot 2023-07-19 at 10.33.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/ac072e86-6bff-32bf-7a67-589a59af9d38.png)

ミドルウェアが適用される為、未ログイン時またはトークンが無効（有効期限が切れているなど）の場合は、アラートが出力され、ログイン画面に遷移します。

試しにJWTの有効期限を短くしてみることで、有効期限が切れると401エラーが出力されることも確認できます。
~~~javascript
jwt.sign(
  { userId: 1, email: "whopper@example.com" },
  "SECRET_KEY",
  {
    expiresIn: "2000"
  }
);
~~~

これで認証機能の実装が完了しました👏 最後まで読んでいただき、ありがとうございました。

![giphy.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/dee45a12-809e-1587-ebed-a430b81ca25c.gif)