JWTを使ってトークンベースの認証機能をつくる

はじめに

JWTを使ってトークンベースの認証機能をつくる方法について

JWTをインストール

gem 'jwt'
bundle install

sessionsコントローラの設定

controllers/api/sessions_controller.rb

class Api::SessionsController < ApplicationController
  def create
    user = User.authenticate(params[:email], params[:password]) 
    # sorceryのメソッド

    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

  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

userモデルにモジュールをインクルードします。

class User < ApplicationRecord
  include JwtToken
.
.
end

ログインページを作成

javascript/pages/login/index.vue

<template>
  <div
    id="login-form"
    class="container w-50 text-center"
  >
    <div class="h3 mb-3">
      ログイン
    </div>
    <validation-observer v-slot="{ handleSubmit }">
      <div class="form-group text-left">
        <label for="email">メールアドレス</label>
        <validation-provider
          v-slot="ProviderProps"
          name="メールアドレス"
          rules="required|email"
        >
          <input
            id="email"
            v-model="user.email"
            type="email"
            class="form-control"
            placeholder="test@example.com"
          >
          <p class="error text-danger">
            {{ ProviderProps.errors[0] }}
          </p>
        </validation-provider>
      </div>
      <div class="form-group text-left">
        <label for="password">パスワード</label>
        <validation-provider
          v-slot="ProviderProps"
          name="パスワード"
          rules="required|min:3"
        >
          <input
            id="password"
            v-model="user.password"
            type="password"
            class="form-control"
            placeholder="password"
          >
          <p class="error text-danger">
            {{ ProviderProps.errors[0] }}
          </p>
        </validation-provider>
      </div>
      <button
        type="submit"
        class="btn btn-primary"
        @click="handleSubmit(login)"
      >
        ログイン
      </button>
    </validation-observer>
  </div>
</template>

<script>
import { mapActions } from "vuex"

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

<style scoped>
</style>

上記で使用するloginUserをstoreで定義する

javascript/plugins/axios.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) // /sessionsにpostリクエスト

// 返ってきたトークンをローカルストレージに保存
    localStorage.auth_token = sessionsResponse.data.token

// リクエストの認証ヘッダーにもトークンを保存
    axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.auth_token}`
  },
}

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

axiosのデフォルトヘッダーにトークンを追加 javascript/plugins/axios.js

import axios from 'axios'

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

// デフォルトヘッダーにいれることでapi通信の際にトークンを毎回設定しなくて済む
if (localStorage.auth_token) {
  axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${localStorage.auth_token}`
}

export default axiosInstance

トークンで認証を行う

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

  # トークンをデコードしてuser_idを取得する
    payload, = User.decode bearer_token
    @current_user ||= User.find_by(id: payload['user_id'])
  rescue JWT::ExpiredSignature
    nil
  end

  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

上記のモジュールをインクルードする

controllers/application_controller.rb

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

current_userを返すapiを定義する

class Api::UsersController < ApplicationController
...
...
  def me 
    render json: current_user
  end

  private
...
...
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

ヘッダーを修正する

ログイン前と後でヘッダーの表示項目を変更する。

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 avatar-image-wrapper">
            <img
              :src="authUser.avatar_url"
              class="rounded avatar-image"
            >
          </li>
          <li class="nav-item active">
            <router-link
              :to="{ name: 'ProfileIndex' }"
              class="nav-link"
            >
              プロフィール
            </router-link>
          </li>
          <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>
  .avatar-image-wrapper {
    line-height: 40px;
  }
  .avatar-image {
    width: 20px;
  }
</style>

storeでユーザーのログイン、ログアウトの処理を作成します。

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')
    axios.defaults.headers.common['Authorization'] = ''
    commit('setUser', null)
  },
}

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

タスクの編集・削除の権限を変更

自分の作成したタスクのみ編集・削除できるように修正します。

javascript/pages/task/index.vue

<template>
  <div class="container-fluid">
    <div
      id="search-form"
      class="form-row p-3"
    >
..........
..........    
..........
    <transition name="fade">
      <TaskDetailModal
        v-if="isVisibleTaskDetailModal" 
        :task="taskDetail"
        :auth-user="authUser"  // 追加
        @close-modal="handleCloseTaskDetailModal"
        @show-edit-modal="handleShowTaskEditModal"
        @delete-task="handleDeleteTask"
      />
    </transition>
...........
...........
  </div>
</template>

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

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

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">
            <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
      },
      status: {
        type: String
      },
      user_id: {
        type: Number,
        required: true
      },
    },
    // authUserを受け取る
    authUser: {
        type: Object,
        required: true,
      id: {
        type: Number,
        required: true
      }
    }
  },
  computed: {
    // 自分のタスクかどうかを判定
    isAuthUserTask() {
      return this.task.user_id === this.authUser.id
    }
  },
  methods: {
    handleCloseModal() {
      this.$emit('close-modal')
    },
    handleShowTaskEditModal() {
      this.$emit('show-edit-modal', this.task)
    },
    handleDeleteTask() {
      this.$emit('delete-task', this.task)
    }
  }
}
</script>

<style scoped>
 .modal {
  display: block;
}
</style>

ログイン状態の保持を設定

javascript/router/index.vue

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";
import ProfileIndex from "../pages/profile/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",
    },
  ],
})

// router.beforeEachを使ってページ遷移時、リロード時に毎回この処理を実行する
// storeの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

storeにfetchAuthUserを定義

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
    }
  },
  updateUser({ commit, state }, user) {
    return axios.patch(`profile/${state.authUser.id}`, user)
      .then(res => {
        commit('setUser', res.data)
      })
  }
}

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