【Rails API + SPA】ログイン方法とCSRF対策例

# 記事の趣旨

モダンなWebアプリ開発の学習として、Vue.jsによるフロント、Rails APIによるバックエンドで実装するSPAを作りました。

その中で、SPAの様々なログイン実装方法や、それに必要な **CSRF** 対策を学びました。

最終的に `session + cookie` を利用するログインを実装した過程を記録したいと思います。

# ログイン実装方法の検討

## SPAのログイン実装方法

下記の記事等を読んで、主に `JWT` を使う方法と、 `session` を使う方法があることが分かりました。

- [SPAのログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち？〜](https://qiita.com/Hiro-mi/items/18e00060a0f8654f49d6)

上の記事では、様々な方法の良し悪しが網羅的にまとめられていました。

- [JWT・Cookieそれぞれの認証方式のメリデメ比較](https://qiita.com/doyaaaaaken/items/02357c2ebca994160804)

上の記事では、JWTとCookieが端的に比較されていました。

- [HTML5のLocal Storageを使ってはいけない](https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851)

上の記事では、 `JWT` を `Local Storage` に保存することの危険性が説かれています。

> 重要なデータを保存する必要があるなら、常にサーバーサイドセッションを使うべきです。

## Cookie + session認証

いずれの記事でも、 `JWT` を単純に `Local Storage` に保存する方法は避けるべきだと言われています。
そこで、下記の記事の実装例を参考にさせていただき、 `session` を `Cookie` に保存する方法を採用することにしました。

[SPA + Rails API 構成におけるcookie + session認証](https://qiita.com/hxrxchang/items/d51a27f486fcd1612dbf)

この方法は、普通のRailsアプリと同じように、 `session` や `cookies` を使うことができるシンプルさが良いと思いました。

この場合、 **CSRF** 対策を自分で実装する必要があり、それについては後半に扱います。

# ログイン機能実装
## 環境
`ruby 2.7.1` `rails 6.0.3`

## Rails APIモードでの立ち上げ
RailsをAPIモードで立ち上げると、不要なViewファイルやGemが省かれた状態になり、Docker運用の場合のイメージサイズ削減などの効果があります。

```
bundle exec rails new アプリ名 --api -TC -d mysql
```

不要なファイルを省くためのオプションを色々付けていますが、 `--api` が重要です。

その他は、 `-T` テストなし（RSpecを使う場合等）、 `-C` ActionCableなし、 `-d mysql` DBを指定、というオプションです。

## バックエンド側のログイン実装

バックエンド側のログイン機構は、Railsチュートリアルにあるような基本的な方法を使いました。


```Ruby:auth_controller.rb
# フロントから { email: 'メールアドレス', password: 'パスワード' }のようなparamsが送られるものとする

def create
  user = User.find_by(email: params[:email])
  if user&.authenticate(params[:password])
    session[:user_id] = user.id
    payload = { message: 'ログインしました。', name: user.name }
  else
    payload = { errors: ['メールアドレスまたはパスワードが正しくありません。'] }
  end
  render json: payload
end
```

認証に成功したら、 `session` に `user_id` を保存しています。（フロントのページに表示するためにユーザー名も返しています。）

ただし、この `session[:user_id] =` というメソッドは、APIモードのRailsでは無効になっているので、下記の設定が必要です。

`config/application.rb` に下記の3行を追記

参考: [Rails の API モードでセッションを有効にする](https://daido.hatenablog.jp/entry/2020/05/06/143145)

```Ruby:application.rb
module Myapp
  class Application < Rails::Application
    …
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use ActionDispatch::Session::CookieStore
    config.middleware.use ActionDispatch::ContentSecurityPolicy::Middleware
  end
end
```

また、「ログイン状態を保持する」がチェックされた際に、以下のように署名付きCookieに保存するようにしました。

```Ruby
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
```

`cookies` を使う時には、下記のように `ActionController::Cookies` をコントローラーにincludeする必要がありました。

参考: [rails-apiでcookieを使う](https://pocke.hatenablog.com/entry/2016/06/23/193232)

```Ruby:application_controller.rb
class ApplicationController < ActionController::API
  include ActionController::Cookies # 追加
  …
end
```

これで、普通のRailsアプリと同じように、安全なCookieに情報が保存され、リクエストで自動的にCookie情報が送られるようになります。

ログインが必要なアクションでは、下記のような `before_action` で、 `@current_user` をセットするようにしました。

```Ruby:application_controller.rb
class ApplicationController < ActionController::API
  before_action :require_login

  private

  def require_login
    @current_user = User.find_by(id: session[:user_id])
    return if @current_user

    render json: { error: 'unauthorized' }, status: :unauthorized
  end
end
```

## CORSの設定
バックエンドとフロントエンドのオリジンが異なる場合は、axiosなどによるフロントからのリクエストを受け取るために、 `CORS` の設定が必要です。

例えば開発環境で、バックエンドを `localhost:3000` 、フロントを `localhost:8080` で動かしている場合などです。

### バックエンド側
`gem 'rack-cors'` を導入して、 `config/initializers/cors.rb`に以下のように記述します。

```Ruby:cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:3000'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      credentials: true
  end
end
```

最後の `credentials: true` という部分は、Cookieを使えるようにするために必要です。

（2020/09/13 編集）
編集前まで、上の `origins` の部分を `'*'` に指定していました。その場合、全てのオリジンからのXMLHttpRequestなどが素通りになり、本末転倒な実装になってしまっていました。申し訳ありません。

### フロントエンド側

Cookieを使えるようにするため、axiosなどでリクエストする際に、 `withCredentials: true` というオプションを含めます。

```js
axios({
  method: 'HTTPメソッド',
  url: '送信先URL',
  withCredentials: true, // ここが必要
  headers: { ヘッダー },
  data: { 送信データ },
})
```

ログイン認証に必要な設定等は以上ですが、このままでは **CSRF** の攻撃が筒抜けになってしまうので、以降で対策します。

# CSRF対策
## CSRF（クロスサイトリクエストフォージェリ）

CSRFについては、こちらの記事内の説明が特に分かりやすかったです。

[Cookie の性質を利用した攻撃と Same Site Cookie の効果](https://blog.jxck.io/entries/2018-10-26/same-site-cookie.html)

- ユーザーが、あるサイト（ターゲット）にログインしたままにしている
- 別の罠サイト内に、そのターゲットサイトへのリクエスト（何かを変更するなど）を偽装して送る仕掛けがある
- ユーザーが罠サイトを訪れ、偽装リクエストが送られる
- ユーザーはターゲットサイトにログインしているため、そのサイトは、偽装リクエストを正常なリクエストとして受け取ってしまう

### CSRFとCookie

CSRFは、ターゲットサイトに紐づくCookieにログイン状態が保存されていて、 **リクエストがどこから送られたかに関係なく、Cookieが自動的に送信されてしまう** ことが利用されています。

なぜ、自サイト内からのリクエストでのみ、Cookieが送信されるようになっていないのでしょうか？

もしそうだった場合、別のサイトからのリンクでターゲットサイトに訪れた時、ログインしていても、Cookieが送られないため、ログインしていないことになってしまいます。そしてリロードすると、自サイト内からのリクエストとしてCookieが送られ、ログインした状態になるという不自然な挙動になります。

### RailsのCSRF対策

サーバーからHTMLを返すRailsアプリでは、デフォルトでフォームにトークンが仕込まれ、送られてきたリクエストのトークンをチェックすることでCSRF対策をしています。

参考: [Rails セキュリティガイド](https://railsguides.jp/security.html#%E3%82%AF%E3%83%AD%E3%82%B9%E3%82%B5%E3%82%A4%E3%83%88%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%83%95%E3%82%A9%E3%83%BC%E3%82%B8%E3%82%A7%E3%83%AA-csrf)

APIモードでは、フォームにトークンを仕込むことはできないので、別の方法で対策する必要があります。

## 実験

実際に、上で紹介したログイン機構を持つタスク管理アプリをサーバーに配置し、CSRFの実験をしてみました。

- 下記のような偽装フォームを持つhtmlを用意し、ローカルサーバーを立ち上げます。

```html:偽装フォーム
<form action="アプリURL" method="post">
  <input type="hidden" name="task[content]" value="偽装タスク">
  <input type="submit" value="送る">
</form>
```

- 実際のアプリにログインします。
- 同じブラウザで、偽装フォームのページを開き、「送る」を押します。
- アプリは `params[:task][:content]` を受け取って、「偽装タスク」という内容のタスクを作成してしまいました！

axiosなどを使ってJSでリクエストを送る場合は、CORS設定で許可したオリジン以外は拒否されるのですが、この例のように、formからリクエストを送ると、簡単にCSRFができてしまうことが分かりました。

## 対策方法

[これで完璧！今さら振り返る CSRF 対策と同一オリジンポリシーの基礎](https://qiita.com/mpyw/items/0595f07736cfa5b1f50c)

こちらの記事で、主に3つの対策方法が紹介されていて、大変参考になりました。

今回はその中から、 **固有の HTTP ヘッダを検証する方法** を用いて対策することにしました。

## 固有の HTTP ヘッダでCSRF対策

リクエストに `X-Requested-With: XMLHttpRequest` というヘッダを持たせて、バックエンド側では、そのヘッダを含まないリクエストを拒否するという方法です。

この `X-Requested-With: XMLHttpRequest` は一般的に使われているというだけで、ヘッダ自体には意味がなく、 **固有のHTTPヘッダ** をつけることに意味があります。それは、

> **HTML フォーム送信に関しては，一切の余分な HTTP ヘッダの付与が許可されていない。**

という制約があるため、上で実験したようにフォームを偽装しても、 `X-Requested-With` ヘッダをリクエストに含めることができないからです。

したがって、このヘッダを持たない偽装リクエストはバックエンドで弾くことができるようになります。

（フォームではなく、Javascriptにより、このヘッダを持つ偽装リクエストが送られた場合は、同一オリジンポリシーによりブラウザが弾いてくれます。）

### バックエンド側の実装

`application_controller` に、ヘッダをチェックする `before_action` を追加します。 `X-Requested-With` ヘッダの有無をチェックする `request.xhr?` というメソッドを利用します。

```Ruby:application_controller.rb
class ApplicationController < ActionController::API
  before_action :check_xhr_header

  private

  def check_xhr_header
    return if request.xhr?

    render json: { error: 'forbidden' }, status: :forbidden
  end
end
```

### フロントエンド側の実装

axiosなどでリクエストする際に、 `'X-Requested-With': 'XMLHttpRequest'` のヘッダを含めます。

```js
axios({
  method: 'HTTPメソッド',
  url: '送信先URL',
  withCredentials: true,
  headers: { 'X-Requested-With': 'XMLHttpRequest' }, // 追加
  data: { 送信データ },
})
```

以上の実装で、上で行ったCSRF実験で、リクエストを送っても `forbidden` が返ってくるようになりました。

# まとめ

今回実装したログイン方法は、普通のRailsアプリと同じようにsessionを使えるので、初めてのWeb API開発としては、とっつきやすい方法だと思いました。

CSRFの対策は、必要最低限のレベルだと思います。実際のアプリ開発で、もっとセキュリティを高めるために、Cookieの `SameSite` 属性を組み合わせるなどの方法を学びたいです。

# 参考記事

[SPAのログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち？〜](https://qiita.com/Hiro-mi/items/18e00060a0f8654f49d6)


[JWT・Cookieそれぞれの認証方式のメリデメ比較](https://qiita.com/doyaaaaaken/items/02357c2ebca994160804)


[HTML5のLocal Storageを使ってはいけない](https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851)

[SPA + Rails API 構成におけるcookie + session認証](https://qiita.com/hxrxchang/items/d51a27f486fcd1612dbf)

[Rails の API モードでセッションを有効にする](https://daido.hatenablog.jp/entry/2020/05/06/143145)

[rails-apiでcookieを使う](https://pocke.hatenablog.com/entry/2016/06/23/193232)

[Cookie の性質を利用した攻撃と Same Site Cookie の効果](https://blog.jxck.io/entries/2018-10-26/same-site-cookie.html)

[Rails セキュリティガイド](https://railsguides.jp/security.html#%E3%82%AF%E3%83%AD%E3%82%B9%E3%82%B5%E3%82%A4%E3%83%88%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%83%95%E3%82%A9%E3%83%BC%E3%82%B8%E3%82%A7%E3%83%AA-csrf)

[これで完璧！今さら振り返る CSRF 対策と同一オリジンポリシーの基礎](https://qiita.com/mpyw/items/0595f07736cfa5b1f50c)

[このWeb APIってCSRF対策出来てますか？って質問にこたえよう](https://qiita.com/maruloop/items/e14d02299bd136f4b1fc)