Rails+Vue.js+Webpackerによる「Create, Read, Update, Destroy」処理のチュートリアルを記述する。
なお,前提知識として,Railsチュートリアル終了程度を想定する。
<概要>
■ 本記事の内容
- JsonWebToken(JWT)によるユーザー認証機能を実装する。
- 今回のコードは,GitHubのコミット履歴で確認可能である。
- 本記事の参考URL
■ 記事共通
-
目次
-
実装機能
- お気に入りの本に係る登録,参照,編集,削除処理
- 非同期通信(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
<本文>
□ Backend(Rails側)
■ jwt_sessionsの準備
- Rails側において,トークンの作成及び管理するライブラリ
○1:Gemインストール
...
gem 'bcrypt'
gem 'jwt_sessions'
#[jwt_sessions]のデフォルトのメモリーストア
gem 'redis'
...
$ bundle install
○2:jwt_sessionsの設定
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
JWTSessions.encryption_key = 'secret'
■ Redisの設定
- Redisは,NoSQLと呼ばれるKVS型(KeyValueStore)の「鍵と値」からなるシンプルなデータベースである。
- 本記事では,RailsとRedisを連携して,認証トークンをRedisとVue側(LocalStorage)に保存し,通信時に双方を確認することでユーザ情報を保持する。
- NoSQLは,リレーショナルデータベース(MySQL,PostgreSQL等)とは運用方針が異なるため,附則学習URLで詳細を確認願う。
- 参考URL
- 附則学習URL
○1:自分のPCにRedisを導入
$ brew install redis
○2:RedisをトークンストアとしてRailsに登録
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]コマンド実行時に同時起動の設定とする。
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
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
class AddColumnUsers < ActiveRecord::Migration[5.2]
def change
add_index :users, :email, unique: true
end
end
$ rails db:migrate
○2:Userモデルにバリデーションを追加
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
■ サンプルデータを追加
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:ユーザー登録機能を追加
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:ログイン,ログアウト機能を実装
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
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
■ ルーティングの設定
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対策を無効化しないと動作しなかったため,今後の課題です。)
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
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)
})
■ ログイン状態を保持するストア設定
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]修正
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]修正
<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コンポーネント作成
<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コンポーネント作成
<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に保存することは避けるべき」という提言もありますので,ぜひご確認ください。
- 参考URL
〜Part6: ユーザー登録編終了〜
〜Part Last: Docker化編〜