以前、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_csrfrails g scaffold user name:string rails db:migrate
ひな形ができたので、一旦起動確認します。
よく見る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
レイアウトの作成 [アプリケーションーションルート]\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 件です。
変更後は以下の通りです。
[アプリケーションーションルート]\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 ] def index @users = User .all render json: @users end def create @user = User .new(user_params) respond_to do |format | if @user .save format.json { render :show , status: :created , location: @user } else format.json { render json: @user .errors, status: :unprocessable_entity } end end end def update respond_to do |format | if @user .update(user_params) format.json { render :show , status: :ok , location: @user } else format.json { render json: @user .errors, status: :unprocessable_entity } end end end def destroy @user .destroy respond_to do |format | format.json { head :no_content } end end private def set_user @user = User .find(params[:id ]) end 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 end
変更後 [アプリケーションーションルート]\config\routes.rb(書き換え後) 1 2 3 4 5 Rails .application.routes.draw do root 'spa#index' resources :users ,only: [:index , :create , :update , :destroy ] end
一旦ここでrails server
を実行して、作成した spa コントローラの view が表示されることを確認しておきます。
ここまでで土台は整ったので、フロント側の作成に移ります。
フロントエンドのひな形の作成 インストールとファイル展開先の設定 [アプリケーションーションルート]で vue-cli を使ってフロントエンドのひな形を作ってゆきます。 今回使用している vue-cli は 3 です。 vue-cli 実行時のプリセットは、default (babel, eslint)
とします。
[アプリケーションーションルート]/front が作成されています。 以下の案内が出ていますが、front に移動して、npm run serve
は 1 回踏みとどまる。
1 2 3 $ cd front $ 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 3 cd frontnpx 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 [アプリケーションーションルート]/frontnpx vue-cli-service build --watch
localhost:3000 にブラウザでアクセスすると、 ビルドしたアプリケーションが<div id=app></div>
に展開されて以下の様な画面が出ます。
動作しているところは、以下の通り。
REST クライアントで、データの追加をしても今回は応答しません。 とりあえず完成です。
今回は CSRF 対策を踏まえて Rails と Vue.js でシングルページアプリケーションを作ってみました。 CSRF 対策が Rails ではもともと取り入れられていますが、思い返せば Node.js(Express)だとどうなっているんでしょう。 次の調査事項はこれになりそうです。
ではでは。
(.vue の hexo 用のシンタックスハイライトのツール探しておこう。)
追記:github にアップしました。