①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/me
でcurrent_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>×</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