RailsとVue.js CSRF対策を意識してシングルページアプリケーションを作ってみる

以前、Ruby on Rails の API モードでアプリを作成しました。

Ruby on Rails で API サーバー

確認の際に REST クライアントで確認していましたが、データ削除の API を使えたりと SPA として使うとき本来意図していないことが起きてしまうそうです。

この対策のために、リクエストの中に、CSRF トークンを忍ばせて認証してあげるらしい。

いろいろ調べたので、やってみる。

目次

参考

Rails でのバックエンド作業

とりあえず Rails アプリ作る。

1
2
3
4
5
6
7
# アプリケーション作成
rails new rails_vue_csrf
cd rails_vue_csrf
# コントローラ、モデル、マイグレーションファイルをscaffoldで作成
rails g scaffold user name:string
# データベースにマイグレーション
rails db:migrate

ひな形ができたので、一旦起動確認します。

1
rails server

よく見るYay! You’re on Rails!と書かれたページが出ます。
scaffold を使用したので、user の登録追加、更新、削除他の画面もできているはずです。
が、SPA から問い合わせする形をとるので、後で書き換えします。

SPA 用のコントローラとビューの作成

CSRF トークンを忍ばせるビューと呼び出すコントローラが欲しいので、コマンドで生成。

1
rails g controller spa index

以下の 2 ファイルが作成されているはずです。

  • [アプリケーションーションルート]\app\controllers\spa_controller.rb
  • [アプリケーションーションルート]\app\views\spa\index.html.erb

[アプリケーションーションルート]\app\views\spa\index.html.erbは、以下のように div だけにしておきます。

[アプリケーションーションルート]\app\views\spa\index.html.erb
1
<div id="app"></div>

レイアウトの作成

[アプリケーションーションルート]\app\views\layouts\application.html.erb をコピー。
[アプリケーションーションルート]\app\views\layouts\spa.html.erb を作成します。
作成した spa.html.erb を以下のようにします。

[アプリケーションーションルート]\app\views\layouts\spa.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<title>Rails SPA APP</title>
<%= csrf_meta_tags %> <%= csp_meta_tag %> <link href=/css/app.css
rel=stylesheet>
<link href="/css/chunk-vendors.css" rel="stylesheet" />
</head>

<body>
<%= yield %>
</body>
<script src="/js/chunk-vendors.js"></script>
<script src="/js/app.js"></script>
</html>

後に説明する vue-cli で作成された css と js が展開される場所の定義を与えるのがポイントです。

1
2
3
4
5
<link href="/css/app.css" rel="stylesheet" />
<link href="/css/chunk-vendors.css" rel="stylesheet" />
<!--省略-->
<script src="/js/chunk-vendors.js"></script>
<script src="/js/app.js"></script>

(レイアウトを使わで、[アプリケーションーションルート]\app\views\spa\index.html.erbにまとめて書きたいです。
しかし、<%= csrf_meta_tags %><%= csp_meta_tag %>が呼び出しできなかったので、悔しいですが今回はこの通り。)

user モデル修正

users テーブルのカラム name に値の入っていないレコードを作ってほしくないので、
[アプリケーションーションルート]\app\models\user.rbにバリデーションを追加します。
バリデーション要件は 2 件です。

  • 空にしない
  • 最低 1 文字

変更後は以下の通りです。

[アプリケーションーションルート]\app\models\user.rb
1
2
3
class User < ApplicationRecord
validates :name, presence: true,length: { minimum: 1 }
end

users コントローラの修正

[アプリケーションーションルート]\app\controllers\users_controller.rbを書き換えます。
ここで行うことは 2 つです。

  • users_controller.rbから不要なものを取り除きます。
    必要なのは、index、create、update,destroy のみです。
    とりあえず、不要な show、new、edit はコメントアウトしておけばいいでしょう。
  • レスポンスを json にします。
[アプリケーションーションルート]\app\controllers\users_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class UsersController < ApplicationController
before_action :set_user, only: [:show, :edit, :update, :destroy]

# GET /users
# GET /users.json
def index
@users = User.all
render json: @users
end

# GET /users/1
# GET /users/1.json
#def show
#end

# GET /users/new
#def new
# @user = User.new
#end

# GET /users/1/edit
#def edit
#end

# POST /users
# POST /users.json
def create
@user = User.new(user_params)

respond_to do |format|
if @user.save
#format.html { redirect_to @user, notice: 'User was successfully created.' }
format.json { render :show, status: :created, location: @user }
else
#format.html { render :new }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end
end

# PATCH/PUT /users/1
# PATCH/PUT /users/1.json
def update
respond_to do |format|
if @user.update(user_params)
#format.html { redirect_to @user, notice: 'User was successfully updated.' }
format.json { render :show, status: :ok, location: @user }
else
#format.html { render :edit }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end
end

# DELETE /users/1
# DELETE /users/1.json
def destroy
@user.destroy
respond_to do |format|
#format.html { redirect_to users_url, notice: 'User was successfully destroyed.' }
format.json { head :no_content }
end
end

private
# Use callbacks to share common setup or constraints between actions.
def set_user
@user = User.find(params[:id])
end

# Never trust parameters from the scary internet, only allow the white list through.
def user_params
params.require(:user).permit(:name)
end
end

ルーティングの変更

[アプリケーションーションルート]\config\routes.rbを書き換えます。
ここでの設定事項は 2 件です。

  • /にアクセスしたとき、’spa#index’が処理されるように設定します。
  • resources でアクセスするメソッドを index、create、update,destroy の 4 つに限定。

変更前

[アプリケーションーションルート]\config\routes.rb(書き換え前)
1
2
3
4
5
Rails.application.routes.draw do
get 'spa/index'
resources :users
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

変更後

[アプリケーションーションルート]\config\routes.rb(書き換え後)
1
2
3
4
5
Rails.application.routes.draw do
root 'spa#index'
resources :users,only: [:index, :create, :update, :destroy]
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

一旦ここでrails serverを実行して、作成した spa コントローラの view が表示されることを確認しておきます。

ここまでで土台は整ったので、フロント側の作成に移ります。

フロントエンドのひな形の作成

インストールとファイル展開先の設定

[アプリケーションーションルート]で vue-cli を使ってフロントエンドのひな形を作ってゆきます。
今回使用している vue-cli は 3 です。
vue-cli 実行時のプリセットは、default (babel, eslint)とします。

1
vue create front

[アプリケーションーションルート]/front が作成されています。
以下の案内が出ていますが、front に移動して、npm run serveは 1 回踏みとどまる。

1
2
3
 $ cd front
$ npm run serve
# $ npm run serve は一回踏みとどまる。

[アプリケーションーションルート]/front/vue.config.jsを作成します。

[アプリケーションーションルート]/vue.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
configureWebpack: {
output: {
filename: "../../public/js/[name].js",
chunkFilename: "../../public/js/[name].js",
},
},
css: {
extract: {
filename: "../../public/css/[name].css",
},
},
};

.js と.css のファイルだけ、rails の public フォルダ以下に配置します。

実行

それでは、実行です。
コンソールを 2 つ開いて準備します。

1
2
# [アプリケーションーションルート]で実行
rails server
1
2
3
# [アプリケーションーションルート]/frontで実行
cd front
npx vue-cli-service build --watch

おそらく、V のアイコンが書かれたよく見るWelcome to Your Vue.js Appと書かれたページが見れるはず。
アイコンは、[アプリケーションーションルート]/img を作ってその下に保存します。
.vue で指定している画像のパスは、相対パスから、/img/[ファイル名]のように変更しそちらに保存します。

フロントエンドの作りこみ

rails で作成した API にアクセスしたいので、http クライアントとして、axios。
axios に渡すパラメータのシリアライザーに qs。
rest クライアントで、モデル名[パラメータ名]="値"という形で、書いていた記法に必要だそうです。
css は skelton を使用しますが、好みの問題なので好きな CSS ライブラリ使ってください。

1
npm install --save axios qs skeleton-css

それでは、
[アプリケーションーションルート]/front/src以下に下記の 3 つのコンポーネントの作成をします。

  • [アプリケーションーションルート]/front/src/App.vue:書き換え
  • [アプリケーションーションルート]/front/src/component/Userlist.vue:新規
  • [アプリケーションーションルート]/front/src/component/User.vue:新規

詳細は以下の通りです。

App.vue

[アプリケーションーションルート]/front/src/App.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<template>
<div id="app" class="container">
<UsersList />
</div>
</template>

<script>
import UsersList from "./components/UsersList.vue";

export default {
name: "app",
components: {
UsersList,
},
};
</script>

<style>
#app {
font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>

Userlist.vue

[アプリケーションーションルート]/front/src/component/Userlist.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
<template>
<div class="UsersList">
<h2>USER LIST</h2>
<div class="row">
<div class="">Users</div>
</div>
<div
class="row"
v-for="user of users"
:key="user.id + user.name + new Date().getTime()"
>
<User v-bind:user="user" @requestIndex="index" />
</div>
<div class="row">
<div class="">ADD User</div>
</div>
<div class="row">
<div class="six columns">
<input
v-model="addusername"
v-on:keyup.enter="create"
class="u-full-width"
placeholder="UserName (1 character minimum)"
/>
</div>
<div class="six columns">
<button v-on:click="create">ADD</button>
</div>
</div>
</div>
</template>

<script>
const axios = require("axios");
const qs = require("qs");
import "skeleton-css/css/normalize.css";
import "skeleton-css/css/skeleton.css";
import User from "./User.vue";

export default {
name: "UsersList",
components: {
User,
},
data: function () {
return {
users: [],
addusername: "",
sendstate: false,
};
},
methods: {
//railsの/userへGET
index: async function () {
console.log("Get:/users");
self = this;
await axios.get("/users.json").then(function (result) {
console.log("[result]", result);
self.users = result.data;
});
},
//railsの/userへPOST
create: async function () {
//多重送信防止
if (this.sendstate) {
console.error("Post Sending Now");
return;
}
if (this.addusername.length == 0) {
console.error("Addusername is empty");
return;
}

this.sendstate = true;
console.log("Post:/users");
self = this;
await axios
.post("/users", {
user: { name: this.addusername },
paramsSerializer: function (params) {
return qs.stringify(params, { arrayFormat: "brackets" });
},
})
.then(function (result) {
console.log(result);
self.addusername = "";
self.sendstate = false;
})
.catch(function () {
self.addusername = "";
self.sendstate = false;
});
this.index();
},
},
mounted: function () {
axios.defaults.headers.common = {
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
};
this.index();
},
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
input {
text-align: center;
}
</style>

User.vue

[アプリケーションーションルート]/front/src/component/User.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
<template>
<div class="User">
<div class="six columns">
<div v-if="state">
<input
ref="update"
v-model="updatename"
v-on:keyup.enter="deactivate"
v-on:blur="deactivate"
class="u-full-width"
/>
</div>
<div v-else>
<p v-on:click="activate" class="u-full-width">{{ updatename }}</p>
</div>
</div>
<div class="six columns">
<button v-on:click="destroy()" key="">DELETE</button>
</div>
</div>
</template>

<script>
const axios = require("axios");
const qs = require("qs");
import "skeleton-css/css/normalize.css";
import "skeleton-css/css/skeleton.css";

export default {
name: "User",
data: function () {
return { id: this.user.id, updatename: this.user.name, state: false };
},
props: ["user"],
methods: {
//input要素有効化
activate: function () {
this.state = true;
this.$nextTick(() => {
this.$refs["update"].focus();
});
},
//input要素無効化
deactivate: function () {
if (this.updatename != this.user.name && this.state) {
this.update();
}
this.state = false;
},
//親コンポーネントへの更新要求
requestIndex: function () {
this.$emit("requestIndex");
},
//railsの/user/:idへPATCH
update: async function () {
console.log(`Update:/users/${this.id}`);

if (this.updatename.length == 0) {
this.updatename = this.user.name;
console.error("updatename is empty");
return;
}
self = this;

await axios
.patch(`/users/${this.id}`, {
user: { name: this.updatename },
paramsSerializer: function (params) {
return qs.stringify(params, { arrayFormat: "brackets" });
},
})
.then(function (result) {
console.log(result);
self.requestIndex();
})
.catch(function () {
self.requestIndex();
});
},
//railsの/user/:idへDELETE
destroy: async function () {
console.log(`Destroy:/users/${this.id}`);
self = this;

await axios
.delete(`/users/${this.id}`)
.catch(() => {})
.then(function (result) {
console.log(result);
self.requestIndex();
})
.catch(function () {
self.requestIndex();
});
},
},
mounted: function () {
axios.defaults.headers.common = {
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
};
},
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
input {
text-align: center;
border-top: none;
border-right: none;
border-left: none;
outline: none;
}
</style>

ポイントは、以下の部分です。

CSRFトークンを取得する。
1
2
3
4
5
6
axios.defaults.headers.common = {
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
};

Rails によって作成された index.html には以下の記述が<head>内に含まれます。

1
2
3
4
5
<meta name="csrf-param" content="authenticity_token" />
<meta
name="csrf-token"
content="dFc6z9p29CET5hVrvd8IGWjHm6MKxwcxBRoRNWy24p7OfsDNESthsFHaMxXcnu1b4T0u8Bvb3hz8B3nnKkHcbg=="
/>

この CSRF トークンを持たせて、リクエストを送ることで、rails が適正なアクセス元と判断してくれます。

ここまでできたら、以下のコマンドでビルドしましょう。
rails のコンソールとは別に開いて実行します。

1
2
cd [アプリケーションーションルート]/front
npx vue-cli-service build --watch

localhost:3000 にブラウザでアクセスすると、
ビルドしたアプリケーションが<div id=app></div>に展開されて以下の様な画面が出ます。

動作しているところは、以下の通り。

REST クライアントで、データの追加をしても今回は応答しません。
とりあえず完成です。


今回は CSRF 対策を踏まえて Rails と Vue.js でシングルページアプリケーションを作ってみました。
CSRF 対策が Rails ではもともと取り入れられていますが、思い返せば Node.js(Express)だとどうなっているんでしょう。
次の調査事項はこれになりそうです。

ではでは。

(.vue の hexo 用のシンタックスハイライトのツール探しておこう。)

追記:github にアップしました。