14
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-02-09

環境

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トークン

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

ヘッダー.ペイロード(データ本体).署名情報

// encoded 例
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

ヘッダー
ヘッダーには、JWTに関する情報(署名アルゴリズムとtokenの種類)部分

// decoded 例

{
  "typ": "JWT",
  "alg": "HS256"
}

ペイロード
アプリケーションによって異なる部分。好きな情報を詰めることができる。

// decoded 例

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

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

// encoded 例

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

Vuex

アプリのデータを管理するための簡易的なDBで、
ログイン時に認証されたユーザーの情報を保存するために使用。
簡単な説明はこちら

Vuexstoreはリロードで情報が吹き飛ぶのでリロード対策は必要。

JWTの発行、検証について

トークンの発行と復元方法

こちらの記事がめちゃくちゃ参考になりました。

ペイロードの作成

ペイロードにはクレーム(予約語)を指定します。
クレームとはオブジェクトを識別するための値で、予約クレームが予め用意されています。
予約クレーム一覧 - JWT

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

今回は、トークンの有効期限(exp)とuser_idを指定します。

> payload = {user_id: 1, exp: (DateTime.current + 7.days).to_i }
=> {:user_id=>1, :exp=>1675566620}

トークン発行(エンコード)

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

鍵には非公開鍵であるRailsのシークレットキーを使用します。
また、トークン生成には JWT.encode メソッドを使用します。

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

トークン復元(デコード)

トークンの復元でも非公開鍵であるRailsのシークレットキーを使用します。
また、トークン生成には JWT.decode メソッドを使用します。

> 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登録するまで

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

図1. トークン発行と認証の流れ
image.png

ログイン時、サーバー側でのチェック事項

・リクエストのheaderにあるtokenを取り出す。
tokenを復元し、得られたIDからユーザー情報を取得。
・ユーザー情報が取得できない場合、権限のないリクエストであると見なす。

内部で例外が発生した場合はheadメソッドによって、
本文(body)のないヘッダーのみのレスポンスをブラウザに返却します。
Railsガイド headでヘッダのみのレスポンスを生成する

以下のような実装イメージ

  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情報に認証情報を埋め込む

以下のように書いておくと毎度設定する必要がないので便利
設定のデフォルト 公式ドキュメント

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でセットできる。
ルートメタフィールド 公式ドキュメント

vue.js
const router = new VueRouter({
  routes: [
    {
      path: '/foo',
      component: Foo,
      // メタフィールド
      meta: { tokenRequired: true }
    }
  ]
})

ではどうやって遷移先のメタ情報を確認できるのか。
公式ドキュメントによると

ルートにマッチした全てのルートレコードは route.matched 配列として $route オブジェクト上で (また、ナビゲーションガード上のルートオブジェクトでも) アクセス可能になります。

また、ルートオブジェクトは以下の3種類が存在します。
ナビゲーションガード 公式ドキュメント
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を削除する
・ログイン画面を表示させる

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

実装

初めに

jwtgemで用意されているので、Gemfileに以下を追加してbundle-install

Gemfile.rb
gem 'jwt'

Vuexは、vuex.esm-browser.jsから拝借
著者はimportmapを使用しているので、pinコマンドでダウンロードします。
その後、以下を追記して準備完了。

importmap.rb
pin "vuex", to: "vuex.esm-browser.js"

ログイン画面

VuexmapActionsで、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 図1.①
      const sessionsResponse = await Axios.post('login', {login_params: params});
      // ローカルストレージにtokenを埋める 図1.⑤
      localStorage.setItem('token', sessionsResponse.data.token);
      // リクエストのヘッダーにtokenを埋める 図1.⑤
      Axios.defaults.headers.common['Authorization'] = `Bearer ${sessionsResponse.data.token}`;
      // 認証済みユーザーを取得する 図1.⑥
      const AuthUserResponse = await Axios.get('fetch_auth_user');
      const AuthUser = AuthUserResponse.data;
      // mutationを呼び出す 図1.⑪
      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)

    # 図1.②
    # ユーザーが見つからない場合は処理を終了
    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 }
    # トークン生成 図1.③
    token = JWT.encode(payload, Rails.application.credentials.secret_key_base)

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

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

リロード対策

以下を追加しました。
リロードのタイミングでheadertokenを仕込む
これでログイン後にリロードしても、headertoken情報を維持することができます。

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

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

備考

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

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

最後に

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

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

参照

【Rails×Vue】ログイン機能で使うJWT(JSON Web Token)
トークンベースの認証とは?
SPAのログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち?〜
【認証】JWTについての説明書
JWTとは何か?(ruby-jwtのインストール)
【Rails】JWTを利用したログインAPIと認証付きAPIの実装

14
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?