Help us understand the problem. What is going on with this article?

[Rails+Vue.js]に係るCRUD入門〜Part6: ユーザー登録&ログイン編〜

Rails+Vue.js+Webpackerによる「Create, Read, Update, Destroy」処理のチュートリアルを記述する。
なお,前提知識として,Railsチュートリアル終了程度を想定する。

<概要>

■ 記事共通

■ 本記事の内容

<本文>

□ Backend(Rails側)

■ jwt_sessionsの準備

  • Rails側において,トークンの作成及び管理するライブラリ

○1:Gemインストール

Gemfile
...
gem 'bcrypt'
gem 'jwt_sessions'
#[jwt_sessions]のデフォルトのメモリーストア
gem 'redis'
...
$ bundle install

○2:jwt_sessionsの設定

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include JWTSessions::RailsAuthorization
  rescue_from JWTSessions::Errors::Unauthorized, with: :not_authorized

  private

  # [current_user]メソッド追加(今後のBookデータとの関連付けに使用予定)
  def current_user
    @current_user ||= User.find(payload['user_id'])
  end

  def not_authorized
    render json: { error: 'Not Authorized' }, status: :unauthorized
  end
end
config/initializers/jwt_session.rb
JWTSessions.encryption_key = 'secret'

■ Redisの設定

○1:自分のPCにRedisを導入

$ brew install redis

○2:RedisをトークンストアとしてRailsに登録

config/environments/development.rb
Rails.application.configure do
...
  if Rails.root.join('tmp', 'caching-dev.txt').exist?
    config.action_controller.perform_caching = true

    # [:memory_store]から置き換え
    # config.cache_store = :memory_store
    config.cache_store = :redis_store, 'redis://localhost:6379/0/cache', { expires_in: 90.minutes }
    config.public_file_server.headers = {
      'Cache-Control' => "public, max-age=#{2.days.to_i}"
    }
  else
  ...
end

○3:Redisの起動設定

  • [foreman]による[$ bin/server]コマンド実行時に同時起動の設定とする。
Procfile.dev
redis: bundle exec redis-server /usr/local/etc/redis.conf
web: bundle exec rails s -p 5000
webpacker: ./bin/webpack-dev-server

■ Userテーブルを用意

○1:Userモデル生成

$ rails g model User name:string email:string password_digest:string
db/migrate/[time_stump]_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.string :password_digest, null: false

      t.timestamps
    end
  end
end
$ rails g migration AddColumnUsers
db/migrate/[time_stump]_add_columns_users.rb
class AddColumnUsers < ActiveRecord::Migration[5.2]
  def change
    add_index :users, :email, unique: true
  end
end
$ rails db:migrate

○2:Userモデルにバリデーションを追加

app/models/user.rb
class User < ApplicationRecord
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  before_save { email.downcase! }
  validates :name, presence: true
  validates :email, presence: true,
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
end

■ サンプルデータを追加

db/seeds.rb
User.create!(
  name: 'Admin User',
  email: 'example@example.com',
  password: 'foobar',
  password_confirmation: 'foobar'
)
...
$ rails db:migrate:reset
$ rails db:seed

■ ユーザー登録機能を実装

○1:Usersコントローラ生成

$ rails g controller api::users

○2:ユーザー登録機能を追加

app/controller/api/users_controller.rb
class Api::UsersController < ApplicationController
  # Commitでは[protect_from_forgery]を書き忘れたため注意
  protect_from_forgery except: [:create]

  def create
    user = User.new(user_params)
    if user.save
      # payloadは,トークン自体に内包させられるユーザー情報。ここではuser_idを内包させている。
      payload  = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login

      # Set-Cookieヘッダーに{jwt_access=アクセストークン; Secure; HttpOnly}をセットして送信
      response.set_cookie(JWTSessions.access_cookie,
                          value: tokens[:access],
                          httponly: true,
                          secure: Rails.env.production?)
      # LocalStorageに保存するためのCSRFトークンを返す。
      render json: { csrf: tokens[:csrf] }
    else
      render json: { error: user.errors.full_messages.join(' ') }, status: :unprocessable_entity
    end
  end

  private

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

■ ログイン,ログアウト機能を実装

○1:Sessionsコントローラを生成

$ rails g controller api::sessions

○2:ログイン,ログアウト機能を実装

api/contollers/api/sessions_controller.rb
class Api::SessionsController < ApplicationController
  # リクエストで送られてくるトークンを使用する認可機構(*ログインユーザーのみdestroy認可)
  before_action :authorize_access_request!, only: [:destroy]
  protect_from_forgery except: [:create, :destroy]

  def create
    user = User.find_by!(email: params[:email])
    if user.authenticate(params[:password])
      payload = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login
      response.set_cookie(JWTSessions.access_cookie,
                        value: tokens[:access],
                        httponly: true,
                        secure: Rails.env.production?)
      render json: { csrf: tokens[:csrf] }
    else
      not_authorized
    end
  end

  def destroy
    session = JWTSessions::Session.new(payload: payload)
    session.flush_by_access_payload
    render json: :ok
  end

  private

  def not_found
    render json: { error: "Cannot find email/password combination" }, status: :not_found
  end
end

■ リフレッシュトークンの設定

$ rails g controller api::refresh
app/controllers/api/refresh_controller.rb
class Api::RefreshController < ApplicationController
  before_action :authorize_refresh_by_access_request!

  def create
    session = JWTSessions::Session.new(payload: claimless_payload, refresh_by_access_allowed: true)
    tokens = session.refresh_by_access_payload do
      raise JWTSessions::Errors::Unauthorized, "Somethings not right here!"
    end
    response.set_cookie(JWTSessions.access_cookie,
                        value: tokens[:access],
                        httponly: true,
                        secure: Rails.env.production?)
    render json: { csrf: tokens[:csrf] }
  end
end

■ ルーティングの設定

config/routes.rb
Rails.application.routes.draw do
  root to: 'home#index'

  namespace :api do
    resources :books, only: [:index, :show, :create, :update, :destroy]
    post   'signup',  controller: :users,    action: :create
    post   'signin',  controller: :sessions, action: :create
    delete 'signin', controller: :sessions, action: :destroy
    post   'refresh', controller: :refresh,  action: :create
  end
end

□ Frontend(Vue.js側)

・Vue側におけるログインユーザ情報は,WebStorage(LocalStorage)とVuexによるステートで保持する。
・WebStorageでは,Railsから受け取ったCSRFトークンとログイン有無の情報を保存し,Vuexでは,WebStorageのログイン有無の情報を移してステートで保存する。
・Railsとの通信時は,認証情報を含むCSRFトークンと一緒にデータを送信し,Redisに保存したトークンと突き合わせることでユーザー情報を保持する。

■ Axiosの追加設定

○1:Vue axiosを導入

$ yarn add vue-axios

○2:[axios.js]を作成

  • Axiosは,デフォルトでRailsのCSRF対策に対応していないため,Axiosに対し2つのラッパーメソッドを作成して対処する。(...とのことだが,本記事では前回のCSRF対策と同様に[protect_from_forgery]によりCSRF対策を無効化しないと動作しなかったため,今後の課題です。)
app/javascript/backend/axios/axios.js
import axios from 'axios'

const API_URL = 'http://localhost:5000'

const securedAxiosInstance = axios.create({
  baseURL: API_URL,
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json'
  }
})

const plainAxiosInstance = axios.create({
  baseURL: API_URL,
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json'
  }
})

securedAxiosInstance.interceptors.request.use(config => {
  const method = config.method.toUpperCase()
  if (method !== 'OPTIONS' && method !== 'GET') {
    config.headers = {
      ...config.headers,
      'X-CSRF-TOKEN': localStorage.csrf
    }
  }
  return config
})

securedAxiosInstance.interceptors.response.use(null, error => {
  if (error.response && error.response.config && error.response.status === 401) {
    // If 401 by expired access cookie, we do a refresh request
    return plainAxiosInstance.post('/api/refresh', {}, { headers: { 'X-CSRF-TOKEN': localStorage.csrf } })
      .then(response => {
        localStorage.csrf = response.data.csrf
        localStorage.signedIn = true
        // After another successfull refresh - repeat original request
        let retryConfig = error.response.config
        retryConfig.headers['X-CSRF-TOKEN'] = localStorage.csrf
        return plainAxiosInstance.request(retryConfig)
      }).catch(error => {
        delete localStorage.csrf
        delete localStorage.signedIn
        // redirect to signin if refresh fails
        location.replace('/')
        return Promise.reject(error)
      })
  } else {
    return Promise.reject(error)
  }
})

export { securedAxiosInstance, plainAxiosInstance }

○3:作成した[axios.js]をImport

app/javascript/packs/application.js
import Vue    from 'vue'
import App    from './App.vue'
import Router from '../router/router.js'
import Store  from '../store/store.js'
import VueAxios from 'vue-axios'
import { securedAxiosInstance, plainAxiosInstance } from '../backend/axios/axios.js'

Vue.config.productionTip = false
Vue.use(VueAxios, {
  secured: securedAxiosInstance,
  plain: plainAxiosInstance
})

const app = new Vue({
   el: '#app',
   router: Router,
   store: Store,
   securedAxiosInstance,
   plainAxiosInstance,
   render: h => h(App)
})

■ ログイン状態を保持するストア設定

app/javascript/store/store.js
import Vue from 'vue'
import Vuex from 'vuex'
import router from '../router/router.js'
import axios from 'axios'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    books: [],
    bookInfo: {},
    bookInfoBool: false,
    signedIn: '', // このステートの[True/False]でログイン状態の表示如何を決定
  },
  mutations: {
    fetchSignedIn(state) {
      // ログイン時,BooleanがlocalStorageに保存される。
      state.signedIn = !!localStorage.signedIn
    },
    ...
  },
  actions: {
    // ログイン時等において,[$store.dispatch('doFetchSignedIn')]で次のメソッドを呼び出し,[signedIn]を更新する。
    doFetchSignedIn({ commit }) {
      commit('fetchSignedIn')
    }
  }
})

■ リンク準備

○1:[router.js]修正

app/javascript/router/router.js
import Vue          from 'vue'
import VueRouter    from 'vue-router'
import BookHome     from '../pages/BookHome.vue'
import BookCreate   from '../pages/BookCreate.vue'
import BookEdit     from '../pages/BookEdit.vue'
import Signup       from '../pages/Signup.vue'
import Signin       from '../pages/Signin.vue'

Vue.use(VueRouter)

const routes = [
  { path: '/',          name: 'BookHome',    component: BookHome },
  { path: '/create',    name: 'BookCreate',  component: BookCreate },
  { path: '/edit/:id',  name: 'BookEdit',    component: BookEdit },
  { path: '/signup',    name: 'Signup',      component: Signup },
  { path: '/signin',    name: 'Signin',      component: Signin }
];

export default new VueRouter({ routes });

○2:[Header.vue]修正

app/javascript/components/Header.vue
<template>
   <div>
     <nav>
       <div class="nav-wrapper">
         <router-link to="/" class="brand-logo">Bookshelf</router-link>
         <ul id="nav-mobile" class="right">
           <li><router-link to="/create">本の登録</router-link></li>
           <li><router-link to="/signup" v-if="!signedIn">Sign up</router-link></li>
           <li><router-link to="/signin" v-if="!signedIn">Sign in</router-link></li>
           <li><a href="/" v-if="signedIn" @click="signOut">Sign out</a></li>
         </ul>
       </div>
     </nav>
   </div>
</template>

<script>
  import { mapState } from 'vuex'

  export default {
    name: 'Header',
    computed: mapState([
      'signedIn'
    ]),
    mounted: function() {
      this.$store.dispatch('doFetchSignedIn')
    },
    methods: {
      setError(error, text) {
        this.error = (error.response && error.response.data && error.response.data.error) || text
      },
      signOut() {
        this.$http.secured.delete(`/api/signin`)
          .then(response => {
            delete localStorage.csrf
            delete localStorage.signedIn
          })
          .catch(error => this.setError(error, 'Cannot sign out'))
      }
    }
  }
</script>

■ 各認証コンポーネントを作成

○1:Signupコンポーネント作成

app/javascript/pages/Signup.vue
<template>
  <div class="container">
    <h1 class="#f3e5f5 purple lighten-5 center">Sign UP</h1>
    <form class="col" @submit.prevent="signup">
      <div class="text-red" v-if="error">{{ error }}</div>
      <div class="row">
        <div class="input-field">
          <input placeholder="Name" type="text" class="validate" v-model="name" required="required"></br>
        </div>
      </div>
      <div class="row">
        <div class="input-field">
          <input placeholder="Email" type="text" class="validate" v-model="email" required="required">
        </div>
      </div>
      <div class="row">
        <div class="input-field">
          <input placeholder="Password" type="text" class="validate" v-model="password" required="required">
        </div>
      </div>
      <div class="row">
        <div class="input-field">
          <input placeholder="Password_confirmation" type="text" class="validate" v-model="password_confirmation" required="required">
        </div>
      </div>

      <button type="submit" class="btn waves-effect waves-light">Sign Up</button>
      <div><router-link to="/signin" class="btn link-grey">Sign In</router-link></div>
    </form>
  </div>
</template>

<script>
  export default {
    name: 'Signup',
    data() {
      return {
        name: '',
        email: '',
        password: '',
        password_confirmation: '',
        error: ''
      }
    },
    created() {
      this.checkSignedIn()
    },
    updated() {
      this.checkSignedIn()
    },
    methods: {
      signup() {
        this.$http.plain.post('/api/signup', { name: this.name, email: this.email, password: this.password, password_confirmation: this.password_confirmation })
          .then(response => this.signupSuccessful(response))
          .catch(error => this.signupFailed(error))
      },
      signupSuccessful(response) {
        if (!response.data.csrf) {
          this.signupFailed(response)
          return
        }
        localStorage.csrf = response.data.csrf
        localStorage.signedIn = true
        this.$store.dispatch('doFetchSignedIn')
        this.error = ''
        this.$router.replace('/')
      },
      signupFailed(error) {
        this.error = (error.response && error.response.data && error.response.data.error) || 'Something went wrong'
        delete localStorage.csrf
        delete localStorage.signedIn
      },
      checkSignedIn() {
        if (localStorage.signedIn) {
          this.$router.replace('/')
        }
      }
    }
  }
</script>

○2:Signinコンポーネント作成

app/javascript/pages/Signin.vue
<template>
  <div class="container">
    <h1 class="#f3e5f5 purple lighten-5 center">Sign In</h1>
    <form class="col" @submit.prevent="signin">
      <div class="text-red" v-if="error">{{ error }}</div>

      <div class="row">
        <div class="input-field">
          <input placeholder="Email" type="text" class="validate" v-model="email" required="required">
        </div>
      </div>
      <div class="row">
        <div class="input-field">
          <input placeholder="Password" type="text" class="validate" v-model="password" required="required">
        </div>
      </div>

      <button type="submit" class="btn waves-effect waves-light">Sign In</button>
      <div><router-link to="/signup" class="btn link-grey">Sign Up</router-link></div>
    </form>
  </div>
</template>

<script>
  // 動作は,Signupコンポーネントと同じ。
  export default {
    name: 'Signin',
    data() {
      return {
        email: '',
        password: '',
        error: ''
      }
    },
    created() {
      this.checkSignedIn()
    },
    updated() {
      this.checkSignedIn()
    },
    methods: {
      signin() {
        this.$http.plain.post('/api/signin', { email: this.email, password: this.password })
          .then(response => this.signinSuccessful(response))
          .catch(error => this.signinFailed(error))
      },
      signinSuccessful(response) {
        if (!response.data.csrf) {
          this.signinFailed(response)
          return
        }
        localStorage.csrf = response.data.csrf
        localStorage.signedIn = true
        this.$store.dispatch('doFetchSignedIn')
        this.error = ''
        this.$router.replace('/')
      },
      signinFailed(error) {
        this.error = (error.response && error.response.data && error.response.data.error) || ''
        delete localStorage.csrf
        delete localStorage.signedIn
      },
      checkSignedIn() {
        if (localStorage.signedIn) {
          this.$router.replace('/')
        }
      }
    }
  }
</script>

□ 特記事項

○1:JWTをLocalstorageに保存する安全性
 本記事では,認証管理にJWTをLocalstorageに保存しておりますが,以下の記事のとおり「Localstorageに保存することは避けるべき」という提言もありますので,ぜひご確認ください。

〜Part6: ユーザー登録編終了〜
〜Part7: 作成待ち〜

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away