JWTを用いてトークンベースの認証を実装

前回:ユーザー登録機能の実装 - Rails技術ブログ

ruby-jwtをインストール。
gem 'jwt'
$ bundle install


②ルーティングを設定。
Rails.application.routes.draw do
  root to: 'home#index'
  namespace :api, format: 'json' do
    resources :tasks
    resources :sessions
    resources :users
  end
  get '*path', to: 'home#index'
end


/api/sessionsにpostリクエストを送るとトークンを返すようにコントローラーを設定。

controllers/api/sessions_controller.rb

class Api::SessionsController < ApplicationController
  def create
    user = User.authenticate(params[:email], params[:password])

    if user
      token = user.create_tokens

      render json: { token: token }
    else
      head :unauthorized
    end
  end
end


models/concerns/jwt_token.rb

module JwtToken
  extend ActiveSupport::Concern

  def create_tokens
    payload = { user_id: id }
    issue_token(payload.merge(exp: Time.current.to_i + 1.month))
  end

  private

  def issue_token(payload)
    JWT.encode payload, Rails.application.secrets.secret_key_base
  end
end


models/user.rb

class User < ApplicationRecord
  include JwtToken
.
.
.
end


④ログインページを実装し、axiosでapi/sessionsにpostリクエストを送信して返ってきたトークンをローカルストレージに保存し、さらにリクエストヘッダーに含めるようにする。

javascript/pages/login/index.vue

<template>
  <div
    id="login-form"
    class="container w-50 text-center"
  >
    <div class="h3 mb-3">
      ログイン
    </div>
    <div class="form-group text-left">
      <label for="email">メールアドレス</label>
      <input
        id="email"
        v-model="user.email"
        name="メールアドレス"
        type="email"
        placeholder="test@example.com"
        class="form-control"
      >
    </div>
    <div class="form-group text-left">
      <label for="password">パスワード</label>
      <input
        id="password"
        v-model="user.password"
        name="パスワード"
        type="password"
        placeholder="password"
        class="form-control"
      >
    </div>
    <button
      type="submit"
      class="btn btn-primary"
      @click="login"
    >
      ログイン
    </button>
  </div>
</template>

<script>
import { mapActions } from 'vuex'

export default {
  name: 'LoginIndex',
  data() {
    return {
      user: {
        email: '',
        password: '',
      }
    }
  },
  methods: {
    ...mapActions('users', [
      "loginUser",
    ]),
    async login() {
      try {
        await this.loginUser(this.user);
        this.$router.push({ name: 'TaskIndex' })
      } catch (error) {
        console.log(error);
      }
    }
  }
}
</script>

<style scoped>
</style>


javascript/store/modules/users.js

import axios from '../../plugins/axios'

const state = {
}

const getters =  {
}

const mutations = {
}

const actions = {
  async loginUser({ commit }, user) {
    // ログイン
    const sessionsResponse = await axios.post('sessions', user)
    // 返ってきたトークンをローカルストレージに保存する。
    localStorage.auth_token = sessionsResponse.data.token
    axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.auth_token}`
  },
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}


javascript/plugins/axios.js

import axios from 'axios'

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

// リクエストの際にヘッダーにBearerトークンを含めるようにする。
if (localStorage.auth_token) {
  axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${localStorage.auth_token}`
}

export default axiosInstance


⑤リクエストヘッダーのトークンからユーザーを割り出してcurrent_userに代入する。

controllers/concerns/api/user_authenticator.rb

module Api::UserAuthenticator
  extend ActiveSupport::Concern

  def current_user
    return @current_user if @current_user
    return unless bearer_token

    // Bearerトークンをデコードしてuser_idを取り出し、
    // それをもとにユーザーを特定して@current_userに代入する。
    // ※変数の後にカンマを付けると配列の最初の要素を代入する。
    payload, = User.decode bearer_token
    @current_user ||= User.find_by(id: payload['user_id'])
  end

  // 認証トークンがない場合は401エラーを返す。
  def authenticate!
    return if current_user

    head :unauthorized
  end

  // これでリクエストヘッダーのBearerトークンを取得できる。
  def bearer_token
    pattern = /^Bearer /
    header = request.headers['Authorization']

    header.gsub(pattern, '') if header&.match(pattern)
  end
end


models/concerns/jwt_token.rb

module JwtToken
  extend ActiveSupport::Concern

  class_methods do    // 追加
    def decode(token)
      JWT.decode token, Rails.application.secrets.secret_key_base
    end
  end

  def create_tokens
    payload = { user_id: id }
    issue_token(payload.merge(exp: Time.current.to_i + 1.month))
  end

  private

  def issue_token(payload)
    JWT.encode payload, Rails.application.secrets.secret_key_base
  end
end


controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include Api::UserAuthenticator
  protect_from_forgery with: :null_session
end


/api/users/mecurrent_userを返すAPIを設定する。

routes.rb

Rails.application.routes.draw do
  root to: 'home#index'
  namespace :api, format: 'json' do
    resources :tasks
    resources :sessions
    resources :users do
      collection do
        get 'me'
      end
    end
  end
  get '*path', to: 'home#index'
end


controllers/api/users_controller.rb

class Api::UsersController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    user = User.new(user_params)

    if user.save
      render json: user
    else
      render json: user.errors, status: :bad_request
    end
  end

  def me  # 追加
    render json: current_user
  end

  private

  def user_params
    params.permit(:email, :password, :password_confirmation, :name)
  end
end


⑦ログイン前はヘッダーにユーザー登録ページとログインページのリンクを表示し、ログイン後はログアウトボタンを表示する。

javascript/components/TheHeader.vue

<template>
  <header>
    <nav class="navbar navbar-expand navbar-dark bg-dark justify-content-between">
      <span class="navbar-brand mb-0 h1">タスク管理アプリ</span>
      <ul class="navbar-nav">
        <template v-if="!authUser">
          <li class="nav-item active">
            <router-link
              :to="{ name: 'RegisterIndex' }"
              class="nav-link"
            >
              ユーザー登録
            </router-link>
          </li>
          <li class="nav-item active">
            <router-link
              :to="{ name: 'LoginIndex' }"
              class="nav-link"
            >
              ログイン
            </router-link>
          </li>
        </template>
        <template v-else>
          <li class="nav-item active">
            <router-link
              to="#"
              class="nav-link"
              @click.native="handleLogout"
            >
              ログアウト
            </router-link>
          </li>
        </template>
      </ul>
    </nav>
  </header>
</template>

<script>
import { mapGetters, mapActions } from "vuex"

export default {
  name: "TheHeader",
  computed: {
    ...mapGetters("users", ["authUser"])
  },
  methods: {
    ...mapActions("users", ["logoutUser"]),
    async handleLogout() {
      try {
        await this.logoutUser()
        this.$router.push({name: 'TopIndex'})
      } catch (error) {
        console.log(error)
      }
    }
  }
}
</script>

<style scoped>
</style>


javascript/store/modules/users.js

import axios from '../../plugins/axios'

const state = {
  authUser: null
}

const getters =  {
  authUser: state => state.authUser
}

const mutations = {
  setUser: (state, user) => {
    state.authUser = user
  }
}

const actions = {
  async loginUser({ commit }, user) {
    // ログイン
    const sessionsResponse = await axios.post('sessions', user)
    localStorage.auth_token = sessionsResponse.data.token
    axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.auth_token}`

    // ログインユーザー情報の取得
    const userResponse = await axios.get('users/me')
    commit('setUser', userResponse.data)
  },
  logoutUser({ commit }) {
    // ログアウト
    // ローカルストレージのトークンを削除。
    localStorage.removeItem('auth_token')
   // リクエストヘッダーのBearerトークンも削除。
    axios.defaults.headers.common['Authorization'] = ''
    commit('setUser', null)
  },
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}


⑧他人のタスクを編集・削除できないようにする。

models/user.rb

class User < ApplicationRecord
  include JwtToken
  authenticates_with_sorcery!

  has_many :tasks, dependent: :destroy
.
.
.
end


models/task.rb

class Task < ApplicationRecord
  belongs_to :user
.
.
.
end


controllers/tasks_controller.rb

class Api::TasksController < ApplicationController
  before_action :authenticate!
  before_action :set_task, only: %i[show update destroy]
  skip_before_action :verify_authenticity_token

  def index
    @tasks = Task.all
    render json: @tasks
  end

  def create
    @task = current_user.tasks.build(task_params)

    if @task.save
      render json: @task
    else
      render json: @task.errors, status: :bad_request
    end
  end
.
.
.
end


javascript/pages/task/index.vue

<template>
.
.
.
    <transition name="fade">
      <TaskDetailModal
        v-if="isVisibleTaskDetailModal"
        :task="taskDetail"
        :auth-user="authUser"  // 追加
        @close-modal="handleCloseTaskDetailModal"
        @show-edit-modal="handleShowTaskEditModal"
        @delete-task="handleDeleteTask"
      />
    </transition>
.
.    
.
</template>

<script>
import TaskList from './components/TaskList'
import TaskDetailModal from './components/TaskDetailModal'
import TaskCreateModal from './components/TaskCreateModal'
import TaskEditModal from './components/TaskEditModal'
import { mapGetters, mapActions } from 'vuex'

export default {
  name: 'TaskIndex',
  components: {
    TaskList,
    TaskDetailModal,
    TaskCreateModal,
    TaskEditModal,
  },
  data() {
    return {
      taskDetail: {},
      isVisibleTaskDetailModal: false,
      isVisibleTaskCreateModal: false,
      isVisibleTaskEditModal: false,
      taskEdit: {},
    }
  },
  computed: {
    ...mapGetters('tasks', ["tasks"]),
    ...mapGetters("users", ["authUser"]),  // 追加
.
.
.


javascript/pages/task/components/TaskDetailModal.vue

<template>
  <div :id="'task-detail-modal-' + task.id">
    <div
      class="modal"
      @click.self="handleCloseModal"
    >
      <div class="modal-dialog">
        <div class="modal-content">
          <div class="modal-header">
            <h5 class="modal-title">
              {{ task.title }}
            </h5>
            <button
              type="button"
              class="close"
              @click="handleCloseModal"
            >
              <span>&times;</span>
            </button>
          </div>
          <div
            v-if="task.description"
            class="modal-body"
          >
            <p>{{ task.description }}</p>
          </div>
          <div class="modal-footer d-flex">
            <template v-if="isAuthUserTask">  // 追加
              <button
                type="button"
                class="btn btn-success"
                @click="handleShowTaskEditModal"
              >
                編集
              </button>
              <button
                type="button"
                class="btn btn-danger"
                @click="handleDeleteTask"
              >
                削除
              </button>
            </template>
            <button
              type="button"
              class="btn btn-secondary"
              @click="handleCloseModal"
            >
              閉じる
            </button>
          </div>
        </div>
      </div>
    </div>
    <div class="modal-backdrop show" />
  </div>
</template>

<script>
export default {
  name: 'TaskDetailModal',
  props: {
    task: {
      type: Object,
      required: true,
      id: {
        type: Number,
        required: true
      },
      title: {
        type: String,
        required: true
      },
      description: {
        type: String,
        required: true
      },
      user_id: {   // 追加
        type: Number,
        required: true
      }
    },
    authUser: {   // 追加
      type: Object,
      required: true,
      id: {
        type: Number,
        required: true
      }
    }
  },
  computed: {
    isAuthUserTask() {  // 追加
      return this.task.user_id === this.authUser.id
    }
  },
.
.
.


⑨タスクページはログイン済みでなければ表示しないようにする。また、ログイン状態を保持するようにする。
import Vue from 'vue';
import Router from 'vue-router';
import store from '../store';

import TopIndex from '../pages/top/index';
import TaskIndex from '../pages/task/index';
import RegisterIndex from '../pages/register/index';
import LoginIndex from '../pages/login/index';

Vue.use(Router)

const router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      component: TopIndex,
      name: 'TopIndex',
    },
    {
      path: '/tasks',
      component: TaskIndex,
      name: 'TaskIndex',
      meta: { requiredAuth: true },  // 追加
    },
    {
      path: '/register',
      component: RegisterIndex,
      name: 'RegisterIndex',
    },
    {
      path: '/login',
      component: LoginIndex,
      name: 'LoginIndex',
    },
  ]
})

// リロード・ページ遷移時に必ずfetchAuthUserメソッドが発動し、トークンが残っていれば
// ログイン状態を保持して指定のページを表示する。
// メタデータで要ログインが指定されていて、かつトークンが残っていない場合は
// ログインページを表示する。
router.beforeEach((to, from, next) => {
  store.dispatch('users/fetchAuthUser').then((authUser) => {
    if (to.matched.some(record => record.meta.requiredAuth) && !authUser) {
      next({ name: 'LoginIndex' });
    } else {
      next();
    }
  })
});

export default router


javascript/store/modules/users.rb

import axios from '../../plugins/axios'

const state = {
  authUser: null
}

const getters =  {
  authUser: state => state.authUser
}

const mutations = {
  setUser: (state, user) => {
    state.authUser = user
  }
}

const actions = {
  async loginUser({ commit }, user) {
    // ログイン
    const sessionsResponse = await axios.post('sessions', user)
    localStorage.auth_token = sessionsResponse.data.token
    axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.auth_token}`

    // ログインユーザー情報の取得
    const userResponse = await axios.get('users/me')
    commit('setUser', userResponse.data)
  },
  logoutUser({ commit }) {
    // ログアウト
    localStorage.removeItem('auth_token')
    axios.defaults.headers.common['Authorization'] = ''
    commit('setUser', null)
  },
  async fetchAuthUser({ commit, state }) {
    if (!localStorage.auth_token) return null
    if (state.authUser) return state.authUser

    const userResponse = await axios.get('users/me')
      .catch((err) => {
        return null
      })
    if (!userResponse) return null

    const authUser = userResponse.data
    if (authUser) {
      commit('setUser', authUser)
      return authUser
    } else {
      commit('setUser', null)
      return null
    }
  }
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}



参考記事:
JWT・Cookieそれぞれの認証方式のメリデメ比較 - Qiita
JSON Web Token(JWT)を用いたAPIの認証の実装(Rails) - Qiita
【Rails】 API開発で『Can't verify CSRF token authenticity』といわれたときの対応 - Qiita
【Nuxt.js】axiosでheaderにAuthorizationを常につけたい! - Qiita
vue-routerでログインしていない場合はログインページに戻す処理 - Qiita
JSON Web Token(JWT)の紹介とYahoo! JAPANにおけるJWTの活用 - Yahoo! JAPAN Tech Blog
JSON Web Tokens - jwt.io
ruby-on-rails — ベアラートークンをレールのヘッダーに渡す方法は?
JWTとは何か?(ruby-jwtのインストール) - 独学プログラマ
【Rails×JWT】トークン発行とデコードを行うAuthTokenクラスの作成 - 独学プログラマ
Ruby - 多重代入 - ざっくりん雑記
ルートメタフィールド | Vue Router
Rubyのmoduleの使い方とメリットを理解して脱初心者!|TECH PLAY Magazine [テックプレイマガジン]
Railsのclass_methodsがやっていること - Qiita