【Vuex4 + Vue3 + Rails7】JWTとVuexを使ってトークンベースのログイン機能を実装する


# 環境
ruby 3.1.0
Rails 7.0.4
vue@3.2.41
Vuex@4.1.0


# 初めに

個人開発でSPAのログイン機能を実装した時のメモです。
理解不足な面も多いので、指摘いただけると嬉しいです。


ログイン状態は、Railsであればセッションで管理することができます。
セッションによる認証はサーバー側で行うため、
JSなどフロント側ではあまり気にしなくて良かったのですが、
**Vue.jsのようなSPAの場合はフロント側でも制御が必要になります。**

そのログイン状態の管理を**トークンベースの認証**で実装しようと思います。

**以下仕様**
・ログインはパスワードとメールアドレスで実行する
・ログイン後のページは「ログイン状態」でないと遷移できない
・ログイン画面は、「ログイン状態」では遷移できない
・ログイン後、リロードしてもログイン状態が保持される
・ログアウトすると、ログイン画面に遷移する

# トークンベースの認証とは
セッションを使ったログイン（認証）との違いをざっくり整理したいと思います。

## セッションを使ったログイン
セッションを使ったログインでは、**SessionのデータをRedisなどのサーバーに保持**し、
サーバーが発行した`SessionId（Cookie）`をHTTPレスポンスのヘッダーに埋め込み
クライアントに送信します。次回以降、クライアントがWebサーバーにアクセスした際に、
リクエストヘッダに含まれる`SessionId`を利用して**サーバーからデータを参照して**認証を行います。

## トークンベースのログイン
トークンベースの認証では、ログイン時にWEBサーバーがクライアントに`token`を返します。
セッションと違うのは、**サーバにその情報を保存しないこと**です。

次回以降のクラインアントからのアクセスでは、**「認証に成功した」tokenを
リクエストヘッダに含めてサーバー側に送信**します。

その後、サーバー側で**受け取ったトークンを解析**して、認証を行います。

Web開発において「Webトークン」は、 **JSON Web Token (JWT)** のことが多いそうです。

### トークンのメリット
・`token`情報を保持する**専用のサーバーが不要**。
・存在そのものが情報なので、**DBに対しリクエストを投げる必要がない**。

### トークンのデメリット
・`Base64Url`エンコードされた情報なので、**復元が容易**。
・データ本体をもつ`token`は**データサイズが大きい**こと。

# JWTトークン
[公式サイト](https://jwt.io/)
**JWT(ジョット / JSON Web Token）** とは、
・クラインアントサーバー間で通信するため、`JSON`オブジェクトとしてエンコードされた情報。
・改竄の防止のため暗号化（ハッシュ）を用いて電子著名がされている。
・以下の三種類の情報を`.`で繋いで`Base64Url`でエンコードしたもの。
・`decode`することで元の内容の`JSON`を取得することができる。


ヘッダー.ペイロード（データ本体）.署名情報
```js
// encoded 例
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
```

**ヘッダー**
ヘッダーには、`JWT`に関する情報（署名アルゴリズムと`token`の種類）部分
```js
// decoded 例

{
  "typ": "JWT",
  "alg": "HS256"
}
```
**ペイロード**
アプリケーションによって異なる部分。好きな情報を詰めることができる。

```js
// decoded 例

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}
```

**著名情報**
暗号化アルゴリズムによって生成される文字列。
途中でデータが改竄されたどうかを検証するために用いる。
```js
// encoded 例

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret
) secret base64 encoded
```



# Vuex
**アプリのデータを管理するための簡易的なDB**で、
ログイン時に認証されたユーザーの情報を保存するために使用。
[簡単な説明はこちら](https://qiita.com/kobayashimakoto/items/edf9ffff0c5d695e4fe6)

※`Vuex`の`store`はリロードで情報が吹き飛ぶのでリロード対策は必要。


# JWTの発行、検証について
## トークンの発行と復元方法
[こちらの記事](https://nishinatoshiharu.com/jwt-gem-usage/)がめちゃくちゃ参考になりました。

### ペイロードの作成
ペイロードにはクレーム（予約語）を指定します。
クレームとはオブジェクトを識別するための値で、予約クレームが予め用意されています。
[予約クレーム一覧 - JWT](https://openid-foundation-japan.github.io/draft-ietf-oauth-json-web-token-11.ja.html#ReservedClaimName)

ただ、`user_id: xxx`とオリジナルで織り込むことも可能ですが、
他アプリケーションとの衝突を避けるために予約クレームを使用することが推奨されています。

今回は、トークンの有効期限(`exp`)と`user_id`を指定します。
```ruby
> payload = {user_id: 1, exp: (DateTime.current + 7.days).to_i }
=> {:user_id=>1, :exp=>1675566620}
```

### トークン発行（エンコード）

トークン発行には、署名時に使用する鍵が必要です。
鍵がないと、トークンが盗まれた後、簡単に復元されてしまうからです。

鍵には非公開鍵である`Rails`のシークレットキーを使用します。
また、トークン生成には [JWT.encode](https://www.rubydoc.info/gems/jwt/1.5.6/JWT.encode) メソッドを使用します。

```ruby
> token = JWT.encode(payload, Rails.application.credentials.secret_key_base)
=> "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NzU1NjY2MjB9.bnYKsb-siG45XWe3nKsqLHo0b3WpeSTzY8BWhNuZHwQ"
```

### トークン復元（デコード）
トークンの復元でも非公開鍵である`Rails`のシークレットキーを使用します。
また、トークン生成には `JWT.decode` メソッドを使用します。
```ruby
> decoded_info = JWT.decode(token, Rails.application.credentials.secret_key_base)
=> [{"user_id"=>1, "exp"=>1676170634}, {"alg"=>"HS256"}]

> decoded_info[0]['user_id']
=> 1
```
`alg` ... `algorithm` 署名アルゴリズム
デフォルトで`HS256`が指定されています。

## ログイン時のトークン取得と認証済みユーザーをstore登録するまで

①ログイン画面からからメールアドレス、パスワードを`controller`に`POST`
②パスワードの認証を行う
③認証が通れば、`user_id`と有効期限をペイロードとして`JWTトークン`を作成する。
④取得したトークンを返却する
⑤受け取ったトークンは　`LocalStrage`にも保存する
⑥トークンを`header`に埋めて、`contoller`に`POST`
⑦リクエストの中のトークンを取得
⑧トークンを`decode`することでユーザー`ID`を取得
⑨`ID`からユーザー情報を取得
⑩ユーザー情報をクライアント側に返却
⑪受け取ったユーザー情報は`AuthUser（認証済みユーザー）`として情報を保持する

**図1. トークン発行と認証の流れ**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2508607/6a3d7192-aa72-c800-e4b0-17f5efde5f62.png)



## ログイン時、サーバー側でのチェック事項
・リクエストの`header`にある`token`を取り出す。
・`token`を復元し、得られた`ID`からユーザー情報を取得。
・ユーザー情報が取得できない場合、権限のないリクエストであると見なす。

内部で例外が発生した場合は`head`メソッドによって、
本文（body）のないヘッダーのみのレスポンスをブラウザに返却します。
[Railsガイド　headでヘッダのみのレスポンスを生成する](https://railsguides.jp/layouts_and_rendering.html#head%E3%81%A7%E3%83%98%E3%83%83%E3%83%80%E3%81%AE%E3%81%BF%E3%81%AE%E3%83%AC%E3%82%B9%E3%83%9D%E3%83%B3%E3%82%B9%E3%82%92%E7%94%9F%E6%88%90%E3%81%99%E3%82%8B)

以下のような実装イメージ
``` ruby
  def xxx
    # ヘッダーからトークンを取得
    header = request.headers['Authorization']
    # トリミング
    token = header.gsub('Bearer ', '')
    # トークンが存在しない場合は、権限のないリクエスト
    return head :unauthorized unless token
    # トークンを解析し、ユーザー情報を取得
    payload = JWT.decode(token, Rails.application.credentials.secret_key_base)[0]
    user = User.find_by(id: payload['user_id'])
    # ユーザーが見つからない場合は、権限のないリクエスト
    return head :unauthorized unless user
    # ユーザー返却
    render json: user
  end
```


# ログイン後のトークン管理

## デフォルト設定
・`token`取得後に、`header`情報に認証情報を埋め込む

以下のように書いておくと毎度設定する必要がないので便利
[設定のデフォルト 公式ドキュメント](https://axios-http.com/docs/config_defaults)
```vue.js
axios.defaults.headers.common['Authorization'] = `Bearer ${ token }`;
```
リロード対策も工夫が必要です。
リロード時に、ローカルストレージから`token`を取り出して、`header`に詰めるようにします。
```vue.js
if (localStorage.token){
    axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${localStorage.token}`;
}
```

## ページ遷移/リロード時もログイン状態を保持
・ローカルストレージに`token`がなければ、ログインページに遷移（ログインしていないと見なす）。
・`store`に認証済みユーザーがあるかチェック。
・`store`に認証済みユーザーがない場合、サーバに問い合わせ、ユーザー情報を`store`にセット。
・**遷移先がトークンを必要としている** かつ 認証済みユーザーがない場合、ログインページに遷移。
・**遷移先がトークンを必要としていない** かつ 認証済みユーザーがある場合、ルートパスに遷移。
・そうでない場合、ページを正常に遷移。

**遷移先がトークンを必要としているかどうか** の`meta`情報は`vue-router`でセットできる。
[ルートメタフィールド 公式ドキュメント](https://v3.router.vuejs.org/ja/guide/advanced/meta.html#%E3%83%AB%E3%83%BC%E3%83%88%E3%83%A1%E3%82%BF%E3%83%95%E3%82%A3%E3%83%BC%E3%83%AB%E3%83%88%E3%82%99)
```vue.js
const router = new VueRouter({
  routes: [
    {
      path: '/foo',
      component: Foo,
      // メタフィールド
      meta: { tokenRequired: true }
    }
  ]
})
```

**ではどうやって遷移先のメタ情報を確認できるのか。**
公式ドキュメントによると
>ルートにマッチした全てのルートレコードは route.matched 配列として $route オブジェクト上で (また、ナビゲーションガード上のルートオブジェクトでも) アクセス可能になります。

また、ルートオブジェクトは以下の３種類が存在します。
[ナビゲーションガード 公式ドキュメント](https://v3.router.vuejs.org/ja/guide/advanced/navigation-guards.html#%E3%82%AF%E3%82%99%E3%83%AD%E3%83%BC%E3%83%8F%E3%82%99%E3%83%AB%E3%83%92%E3%82%99%E3%83%95%E3%82%A9%E3%83%BC%E3%82%AB%E3%82%99%E3%83%BC%E3%83%88%E3%82%99)
`to` : 遷移先のページ
`from` : 今いるページ
`next();` : 処理を続行する

これらの情報を画面遷移の前に確認して、ログインページに飛ばすかの判定をすれば良さそう。
以下のようなイメージ
```vue.js
const router = new VueRouter({ ... })

router.beforeEach((to, from, next) => {
  // 認証済みユーザー取得
  const AuthUser = fetchAuthUser();
  // 遷移先がトークンを必要としている かつ 認証済みユーザーがない場合はログイン画面へ
  if (to.matched.some(record => record.meta.tokenRequired) && !AuthUser) {
    next({ name: 'login' });
    // 遷移先がトークンを必要としていない かつ 認証済みユーザーがある
  } else if (to.matched.some(record => !record.meta.tokenRequired) && AuthUser) {
    next({ name: 'main' });
  } else {
    // 正常に遷移
    next();
  }
})
```


# ログアウト処理
ログインボタンのクリックイベントで以下の処理をすれば良さそう。

・ローカルストレージの`token`を削除する
・ヘッダーの`token`を削除する
・ログイン画面を表示させる

ログイン状態はローカルストレージの`token`と`header`にある`token`の２重管理になっており、これらの整合性を担保するロジックが必要です。つまり、ログイン時にはローカルストレージと`header`の両方に`token`をセットして、反対にログアウト時にはセットした両者の`token`を削除しなければなりません。
「片方にはあるけど、片方にない」みたいな実装はNGです。

# 実装

## 初めに
`jwt` は`gem`で用意されているので、`Gemfile`に以下を追加して`bundle-install`
```Gemfile.rb
gem 'jwt'
```

`Vuex`は、[vuex.esm-browser.js](https://unpkg.com/browse/vuex@4.1.0/dist/vuex.esm-browser.js)から拝借
著者は`importmap`を使用しているので、`pinコマンド`でダウンロードします。
その後、以下を追記して準備完了。
```importmap.rb
pin "vuex", to: "vuex.esm-browser.js"
```

## ログイン画面
`Vuex`の`mapActions`で、`store`で定義した`action`の呼び出しを簡易的にしています。
入力したパスワードとメールアドレスを引数に渡しています。

```vue.js
import { mapActions } from 'vuex'

const loginMain = {
  template:
    `
        <div class="login">
          <p class="middle-title">ログイン</p>
          <!--メールアドレス-->
          <div class="form">
            <input type="email"
                   class="form-item"
                   placeholder="メールアドレス"
                   v-model="form_attributes.email.value">
          </div>
          
          <!--パスワード-->
          <div class="form">
            <input  type="password"
                    class="form-item"
                    placeholder="パスワード"
                    v-model="form_attributes.password.value">
          </div>
          
          <!--ログインボタン-->
          <div class="btn">
            <a class="blue-btn login-btn"
               @click="login">
              ログイン</a>
          </div>
        </div>
    `,
  data() {
    return {
      form_attributes: {
        email: {
          value: '',
        },
        password: {
          value: '',
        },
      }
    }
  },

  methods: {
    ...mapActions([
      'loginUser',
    ]),
    // ログイン
    async login() {
      // storeのactionからログインする
      await this.loginUser(this.form_attributes);
      // 画面遷移
      this.$router.push({ name: 'main' });
    },
  }
}

export default loginMain
```
## storeの準備
トークンの認証には`Bearer`認証を使用します。

`Bearer`認証はHTTPの`Authorization`ヘッダーにスキームとして指定でき, 
`Authorization: Bearer <token> `のようにして指定します.
```javascript/store/index.js
import { createStore } from 'vuex'
import Axios from "../plugins/axios";

const store = createStore({
  state () {
    authUser: null
  },
  getters: {
    authUser: state => state.authUser
  },
  mutations: {
    // 認証済みユーザー情報をセット
    setAuthUser (state, AuthUser) {
      state.authUser = AuthUser
    }
  },
  actions: {
    // ログイン処理
    async loginUser (context, params) {
      // ログイン情報をPOST 図１.①
      const sessionsResponse = await Axios.post('login', {login_params: params});
      // ローカルストレージにtokenを埋める 図１.⑤
      localStorage.setItem('token', sessionsResponse.data.token);
      // リクエストのヘッダーにtokenを埋める 図１.⑤
      Axios.defaults.headers.common['Authorization'] = `Bearer ${sessionsResponse.data.token}`;
      // 認証済みユーザーを取得する 図１.⑥
      const AuthUserResponse = await Axios.get('fetch_auth_user');
      const AuthUser = AuthUserResponse.data;
      // mutationを呼び出す 図１.⑪
      context.commit('setAuthUser', AuthUser);
    },

    // 認証済みユーザー情報を取得する
    async fetchAuthUser({ commit, state }) {
      // ローカルストレージにトークンがない場合は処理終了
      if (!localStorage.token) return null;

      // storeに認証済みユーザーがある場合はそれを返却
      if (state.authUser) return state.authUser;

      // ない場合は取得する（リロード対策）
      const AuthUserResponse = await Axios.get('fetch_auth_user');

      // 取得できない場合は処理終了
      if (!AuthUserResponse.data) return null;

      // 取得できたらstoreに保存する
      const AuthUser = AuthUserResponse.data;
      if (AuthUser) {
        commit('setAuthUser', AuthUser)
        return AuthUser;
      } else {
        commit('setAuthUser', null)
        return null;
      }
    },

    // ログアウト処理
    logoutUser(context) {
      // ローカルストレージのトークンを削除する
      localStorage.removeItem('token');
      // ヘッダーのトークンを削除する
      Axios.defaults.headers.common['Authorization'] = '';
      // storeの認証済みユーザーを削除する
      context.commit('setAuthUser', null);
    },

  }
})

export default store
```

## routesの追加
```routes.rb
Rails.application.routes.draw do
  root to: 'home#index'
  namespace :api do
    # 略

    # 以下を追加
    # ログイン
    post 'login' => 'sessions#create'
    post 'logout' => 'sessions#delete'

    # 認証済みユーザー取得
    get 'fetch_auth_user' => 'sessions#fetch_auth_user'
  end
end

```

## サーバー側の処理諸々
以下のメソッドを追加しました。
・**ログイン時にtokenを生成し、tokenを返却するメソッド**
・**認証済みユーザーを取得して返却するメソッド**
```sessionss_controller.rb
class Api::SessionsController < ApplicationController
  protect_from_forgery with: :null_session
  wrap_parameters format: []

  # ログイン時にtokenを生成し、tokenを返却
  def create
    email = login_params['email']['value']
    password = login_params['password']['value']
    user = User.find_by(email: email)

    # 図１.②
    # ユーザーが見つからない場合は処理を終了
    return head :unauthorized unless user.present?

    # パスワードが一致しない場合も処理終了
    return head :unauthorized unless is_authenticated(user, password)

    # ペイロード指定
    payload = {user_id: user.id, exp: (DateTime.current + 7.days).to_i }
    # トークン生成 図１.③
    token = JWT.encode(payload, Rails.application.credentials.secret_key_base)

    # トークン返却 図１.④
    render json: { token: token }
  end

  # 認証済みユーザーを取得して返却する
  def fetch_auth_user
    # ヘッダー情報を取得 図１.⑦
    header = request.headers['Authorization']
    # トリミング
    token = header.gsub('Bearer ', '')
    # デコード 図１.⑧
    payload = JWT.decode(token, Rails.application.credentials.secret_key_base)[0]
    # ユーザー情報を取得 図１.⑨
    user = User.find_by(id: payload['user_id'])
    # 認証済みユーザーを返却 図１.⑩
    render json: user
  end

  private

  # ストロングパラメーター
  def login_params
    params.require(:login_params).permit(email: {}, password: {})
  end

  # パスワードが正しいか認証する
  def is_authenticated(user, raw_password)
    user.present? &&
      user.hashed_password.present? &&
      raw_password.present? &&
      BCrypt::Password.new(user.hashed_password) == raw_password
  end
end
```
## ルーティングにメタ情報を持たせる + 設定諸々
以下の内容を追加しました。
・**ルーティングにメタ情報を持たせる**
・**画面遷移前にtoken存在チェック**
```vue.js
import * as VueRouter from "vue-router";
import store from '../store/index'
import MainIndex from "./../controllers/pages/main/index" //ルート先のview
import LoginMain from "./../controllers/pages/login/index"

// ルーティングにメタ情報を持たせる
const routes = [{
  path: '/',
  name: 'main',
  component: MainIndex,
  meta: { tokenRequired: true }
}, {
  path: '/login',
  name: 'login',
  component: LoginMain,
  meta: { tokenRequired: false }
},

];

const router = VueRouter.createRouter({
  history: VueRouter.createWebHashHistory(), routes, // short for `routes: routes`
});

// 画面遷移前にtoken存在チェック
router.beforeEach((to, from, next) => {
    store.dispatch('fetchAuthUser').then((AuthUser) => {

      // 遷移先がトークンを必要としている かつ 認証済みユーザーがない場合はログイン画面へ
      if (to.matched.some(record => record.meta.tokenRequired) && !AuthUser) {
        next({ name: 'login' });
        // 遷移先がトークンを必要としていない かつ 認証済みユーザーがある
      } else if (to.matched.some(record => !record.meta.tokenRequired) && AuthUser) {
        next({ name: 'main' });
      } else {
        // 正常に遷移
        next();
      }
    })
})

export default router
```

## リロード対策
以下を追加しました。
・**リロードのタイミングで`header`に`token`を仕込む**
これでログイン後にリロードしても、`header`の`token`情報を維持することができます。
```javascript/plugins/axios.js
import Axios from "axios";

const axiosInstance = Axios.create({
    baseURL: 'api'
})

// 以下を追加
// リロード対策
if (localStorage.token){
    axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${localStorage.token}`;
}

export default axiosInstance
```

## ログアウト画面
**ヘッダーの「ログアウトリンク」をクリックすると、ログアウトイベントを発火**
```header_comp.js
import { mapActions } from "vuex";

const headerComp = {
  template:
    `
        <div class="header">
          // 略
          <div class="header-right">
            <ul>
              // 略
              <a><li class="link-item" @click="logout">ログアウト</li></a>
            </ul>
          </div>
        </div>
    `,

  methods: {
    ...mapActions([
      `logoutUser`
    ]),

    logout(){
      // ログアウト
      this.logoutUser();
      // 画面遷移
      this.$router.push({ name: 'login' });
    },
  }
}

export default headerComp
```

## 動作確認
![画面収録-2023-01-31-12.00.16.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2508607/00fc2488-cc48-16ac-4c76-e030f97afc54.gif)



ログイン後にheaderの要素の出しわけるやギミックや、バリデーション、
データ取得にログインユーザーを結びつけるなどの工程は残っていますが、
無事トークンベースでログイン機能を作ることができました。


# 備考
今回は生成したトークンをローカルストレージに保存しているので、
XSS対策の脆弱なサイトではトークンが抜き取られ、
第三者にからリクエストされるリスクもあります。
（その対策にシークレットキーを使ったトークン生成を使用）

特にトークンの有効期限が長ければXSSのリスクも高くなります。
トークンの有効期限を短くすれば比較的、リスクは下がるかもしれない。
でも有効期限を短くするとUX面でよろしくない。
ベスプラがまだまだわかっていません。

# 最後に
ここまで見ていただいてありがとうございます。
セッションしか経験したことがなかったのでトークンでログイン管理するのが新鮮で、
メチャむずいなと感じました。

特にローカルストレージとヘッダーの２重管理になっている分
管理が大変そうな印象です。

# 参照

・[【Rails×Vue】ログイン機能で使うJWT（JSON Web Token）](https://naka-no-mura.hateblo.jp/entry/jwt#token%E3%82%92%E7%99%BA%E8%A1%8C%E3%81%99%E3%82%8B)
・[トークンベースの認証とは？](https://www.cloudflare.com/ja-jp/learning/access-management/token-based-authentication/)
・[SPAのログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち？〜](https://qiita.com/Hiro-mi/items/18e00060a0f8654f49d6#jwt%E3%82%92%E7%94%A8%E3%81%84localstorage%E3%81%AB%E4%BF%9D%E5%AD%98)
・[【認証】JWTについての説明書](https://zenn.dev/nameless_sn/articles/the_article_of_jwt#jwt%E3%81%A8%E3%81%AF)
・[JWTとは何か?(ruby-jwtのインストール)](https://blog.cloud-acct.com/posts/u-rails-whats-jwt/)
・[【Rails】JWTを利用したログインAPIと認証付きAPIの実装](https://nishinatoshiharu.com/rails-jwt-auth-api/#JWT)