isomorphic-fetch(jQuery以外)を使用してRailsのCSRFトークン検証への対応方法

  • 8
    いいね
  • 0
    コメント

ReactのテストツールであるJestの使い方を調べようとこちらの記事のソースに非同期処理を入れました。
その過程で(isomorphic)fetchを使用してのRailsのCSRF検証のパス方法を調べたのを記事にしておきます。

前提条件

reduxのexampleであるTodosのサーバAPIバージョンを元にしています。
てかこれでのAPI作成の記事ってGET処理はあるんですけど、POSTといった登録更新系の記事はほとんどなくないですか?
なのでReduxのTodosのAPIバージョンとしてソースを見てもいいと思います。

使用環境

Ruby:2.3.1
Rails:5.0.0.1
Reactjs:15.3.2
isomorphic-fetch:2.2.1

本記事の全体ソース

https://github.com/chimame/react_on_rails_template

Rails側のAPIおよび解説

ソース

詳しくはgithubのソースを見てもらえればいいと思いますが、特に難しいことはしていません。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
end
app/controllers/todos_controller.rb
class TodosController < ApplicationController
  ・・・
  # POST /todos
  # POST /todos.json
  def create
    @todo = Todo.new(todo_params)

    if @todo.save
      render json: @todo, status: :created
    else
      render json: @todo.errors, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /todos/1/toggle
  # PATCH/PUT /todos/1/toggle.json
  def toggle
    if @todo.update({completed: !@todo.completed})
      render json: @todo, status: :ok
    else
      render json: @todo.errors, status: :unprocessable_entity
    end
  end
  ・・・
end

ここでのポイントはAPI(Ajax)処理をするにも関わらず、CSRFチェックを行うことです。jQuery(ujs)使うならあまり気にしない人もいるのではないしょうか。

解説

注目してほしい点はprotect_from_forgery with: :exceptionです。この指定をすることによってCSRFトークンのチェックが行われるようになります。(デフォルト)
こちらの記事にも書かれていますが、RailsのCSRFトークンのチェックは以下の判定がすべてfalseの場合にエラーとなります。

設定ファイルで config.application_controller.allow_forgery_protection を false に設定しているか
リクエストのHTTPメソッドがGETであるか
リクエストのHTTPメソッドがHEADであるか
セッション変数 csrf_token の値とリクエストボディの authenticity_token の値を比較した結果、正しいと判断されるか
セッション変数 _csrf
token の値とリクエストヘッダーの X-CSRF-Token の値を比較した結果、正しいと判断されるか

要は私のソースではCSRFのチェックが行われるということです。これは意図的にこのようにしております。

ちなみにRails5.0.0.1でのソースはこれです。

request_forgery_protection.rb
# Returns true or false if a request is verified. Checks:
#
# * Is it a GET or HEAD request?  Gets should be safe and idempotent
# * Does the form_authenticity_token match the given token value from the params?
# * Does the X-CSRF-Token header match the form_authenticity_token
def verified_request?
  !protect_against_forgery? || request.get? || request.head? ||
  (valid_request_origin? && any_authenticity_token_valid?)
end

Reactjs側のソースと解析

ソース

frontend/javascripts/containers/Sample/Todos.js
import {addTodo} from '../../utils/WebApi'

class Todos extends Component {
  ・・・
  render() {
    const { data, actions } = this.props
    return (
      <div>
        <TodoList todos={data} onTodoClick={actions.toggle} />
        <AddTodo onRegist={(text) => {actions.add(addTodo(text))}} />
      </div>
    )
  }
}
frontend/javascripts/components/TodoList/index.js
import {toggle} from '../../utils/WebApi'

const TodoList = ({ todos, onTodoClick }) => (
  <ul>
    {todos.map(todo =>
      <Todo
        key={todo.id}
        todo={todo}
        onClick={() => onTodoClick(toggle(todo.id))}
      />
    )}
  </ul>
)
frontend/javascripts/utils/WebAPI.js
import fetch from 'isomorphic-fetch'

const csrfToken = document.getElementsByName('csrf-token').item(0).content
const params = {
  method: 'POST',
  credentials: 'same-origin',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken
  }
}
・・・
export const addTodo = (text) => {
  return fetch(`/todos`, {...params, ...{body: JSON.stringify({todo: {text: text}})}})
    .then(res => {
      if (res.status >= 400) {
        throw new Error("Bad response from server")
      }
      return res.json()
    })
    .then(payload => { return payload })
    .catch(error => { return undefined })
}

export const toggle = (id) => {
  return fetch(`/todos/${id}/toggle`, {...params, ...{method: 'PATCH'}})
    .then(res => {
      if (res.status >= 400) {
        throw new Error("Bad response from server")
      }
      return res.json()
    })
    .then(payload => { return payload })
    .catch(error => { return undefined })
}

解説

大事なのはWebAPI.jsのcredentials: 'same-origin''X-CSRF-Token': csrfToken部分です。
まず'X-CSRF-Token': csrfTokenはRailsから出力されたcsrf-tokenをリクエストヘッダに入れております。もしjQueryを使っている人ならば同時にjQuery-ujsをほぼ使用しているでしょう。そのjQuery-ujsがこのリクエストヘッダにトークン入れる処理を行っているので普段は気にすることはないと思います。
しかし今回はjQueryのAjaxで通信するのではisomorphic-fetchで通信するため、自身で設定してやる必要があります。そこで上記のようにHTMLのmetaのcsrf_tokenを取得し、設定しています。
ただ、csrf-tokenを送るだけではRails側のチェックが通りません。
それは

セッション変数 _csrf_token の値とリクエストボディの authenticity_token の値を比較した結果、正しいと判断されるか

と書いているようにサーバ側にてセッションを参照できる状態にしないといけません。Railsのセッションストアの設定によりますが、デフォルトではcookie_storeとなっています。もしRedis等を使う場合でもcookieにはセッションIDを持っています。
サーバ側ではその受け取ったcookieでセッション変数を参照します。なので、クライアントからはcookieを送るのが必須なのです。なのでcredentials: 'same-origin'を記載して、同一ホストならばcookieを送るようにしています。

これでサーバ側のCSRFトークン検証をパスすることができるのです。
仮にReactjs側の通信をjQueryのAjaxを使用して通信すると検証をパスできることも確認できます。なぜならばRails側にてjQuery-ujsを出力しているからです。必要ならば確認してみてください。

次の記事はJestの予定でしたが少し足踏みをしてしまった感じです。しかもRailsがAPIしか存在しないとなった場合はこの記事の内容は無用の長物となります。
次はJestを…