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

Rails+Vue.js+Webpackerによる「Create, Read, Update, Destroy」処理のチュートリアルを記述する。
なお，前提知識として，Railsチュートリアル終了程度を想定する。
# ＜概要＞
## ■ 本記事の内容
- JsonWebToken(JWT)によるユーザー認証機能を実装する。
- 今回のコードは，[GitHubのコミット履歴](https://github.com/masayuki-0319/rails-vue-bookshelf/commits/06_UserAuth)で確認可能である。
- 本記事の参考URL
    - [Gem[jwt_sessions]](https://github.com/tuwukee/jwt_sessions)
    - [Ruby on Rails API with Vue.js](https://web-crunch.com/ruby-on-rails-api-vue-js/)
    - [Rails API + JWT auth + VueJS SPA](https://blog.usejournal.com/rails-api-jwt-auth-vuejs-spa-eb4cf740a3ae)
    - [SPAでのログイン認証とCSRF対策の実装](https://fujiten3.hatenablog.com/entry/2019/07/13/130459)
    - [IDトークンが分かれば OpenID Connect が分かる（JWTの仕組み）](https://qiita.com/TakahikoKawasaki/items/8f0e422c7edd2d220e06)
    - [JWTについて](https://qiita.com/rs_/items/178f549c7a29c30fcbdb)

## ■ 記事共通
- 目次
    - [ソースコード(GitHubへリンク)](https://github.com/masayuki-0319/rails-vue-bookshelf)
    - [〜Part１: 環境構築編](https://qiita.com/Moo_Moo_Farm/items/afacfe4349af6a106253)
    - [〜Part２: Read編](https://qiita.com/Moo_Moo_Farm/items/fdf133d4c2bcf6d207b6)
    - [〜Part３: Vue Router設定編](https://qiita.com/Moo_Moo_Farm/items/075e970287cb1aaf52c1)
    - [〜Part４: Create, Update, Delete編](https://qiita.com/Moo_Moo_Farm/items/8cf46e228d59417550b1)
    - [〜Part５: Vuex設定編](https://qiita.com/Moo_Moo_Farm/items/fbc2e8c8f971dc5fe4bb)
    - [▲ Part６: ユーザー登録&ログイン編](https://qiita.com/Moo_Moo_Farm/items/65aaa2ac7f8cef50fca6)：本記事
    - [〜Part Last: Docker化編](https://qiita.com/Moo_Moo_Farm/items/5975cd9d2d48ee35d6d6)

- 実装機能
    - お気に入りの本に係る登録，参照，編集，削除処理
    - 非同期通信（Ajax）による[Rails+Vue.js]のCRUD処理
    - SinglePageApplication（SPA）
    - ユーザー登録機能（JWTによるトークン認証）

- 開発環境
    - MacOS Mojave
    - Ruby(2.5.1)
    - Ruby on Rails(5.2.1)
    - Vue.js(2.6.10)
    - Yarn(1.17.0)
    - Webpack(4.39.2)

- 学習情報URL
    - [Vue.js公式](https://jp.vuejs.org/)
    - [Vue Mastery(The Vue Instance)](https://www.vuemastery.com/)

# ＜本文＞

## □ Backend(Rails側)
### ■ jwt_sessionsの準備

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

**○１：Gemインストール**

```sh:Gemfile
...
gem 'bcrypt'
gem 'jwt_sessions'
#[jwt_sessions]のデフォルトのメモリーストア
gem 'redis'
...
```

```.bash
$ bundle install
```

**○２：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の設定

- Redisは，NoSQLと呼ばれるKVS型(KeyValueStore)の「鍵と値」からなるシンプルなデータベースである。
- 本記事では，RailsとRedisを連携して，認証トークンをRedisとVue側（LocalStorage）に保存し，通信時に双方を確認することでユーザ情報を保持する。
- NoSQLは，リレーショナルデータベース（MySQL，PostgreSQL等）とは運用方針が異なるため，附則学習URLで詳細を確認願う。
- 参考URL
    - [Redisとは？RailsにRedisを導入](https://qiita.com/hirotakasasaki/items/9819a4e6e1f33f99213c)
    - [RailsでRedisを使う](https://qiita.com/gestalt/items/3c03626d7a1d0edb9904)
    - [Railsで開発するときの環境](http://spin13131.hatenablog.com/entry/classiadv201714)
- 附則学習URL
    - [RDBとNoSQLにみるDB近現代史 データベースに破壊的イノベーションは二度起きるか？](https://employment.en-japan.com/engineerhub/entry/2017/11/22/110000)
    - [NoSQLについて勉強する。](https://qiita.com/t_nakayama0714/items/0ff7644666f0122cfba1)
    - [RDB技術者のためのNoSQLガイド　NoSQLの必要性と位置づけ](https://www.slideshare.net/recruitcojp/rdbnosqlnosql)

**○１：自分のPCにRedisを導入**

```.bash
$ brew install redis
```

**○２：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
```

**○３：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テーブルを用意
**○１：Userモデル生成**

```.bash
$ 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
```

```.bash
$ 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
```

```.bash
$ rails db:migrate
```

**○２：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'
)
...
```

```.bash
$ rails db:migrate:reset
$ rails db:seed
```


### ■ ユーザー登録機能を実装
**○１：Usersコントローラ生成**

```.bash
$ rails g controller api::users
```

**○２：ユーザー登録機能を追加**

```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
```

### ■ ログイン，ログアウト機能を実装
**○１：Sessionsコントローラを生成**

```.bash
$ rails g controller api::sessions
```

**○２：ログイン，ログアウト機能を実装**

```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
```

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

```.bash
$ 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の追加設定
**○１：Vue axiosを導入**

```.bash
$ yarn add vue-axios
```

**○２：[axios.js]を作成**

- Axiosは，デフォルトでRailsのCSRF対策に対応していないため，Axiosに対し２つのラッパーメソッドを作成して対処する。（...とのことだが，本記事では前回の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 }
```

**○３：作成した[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')
    }
  }
})
```

### ■ リンク準備
**○１：[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 });
```

**○２：[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>
```

### ■ 各認証コンポーネントを作成
**○１：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>
```


**○２：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>
```

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

- 参考URL
    - [JWTをセッション管理に転用するのはあまり良いアイデアではない(認証だけならいいよ)](http://blog.anatoo.jp/entry/2018/10/03/000518)
    - [Please Stop Using Local Storage](https://www.rdegges.com/2018/please-stop-using-local-storage/)
    - [WebブラウザのLocal Storage使うのヤメロ](https://techracho.bpsinc.jp/hachi8833/2018_11_19/64653)

**〜Part６: ユーザー登録編終了〜**
**[〜Part Last: Docker化編](https://qiita.com/Moo_Moo_Farm/items/5975cd9d2d48ee35d6d6)〜**