Help us understand the problem. What is going on with this article?

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

記事の趣旨

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

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

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

ログイン実装方法の検討

SPAのログイン実装方法

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

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

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

こちらの記事では、 JWTLocal Storage に保存することの危険性が説かれています。

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

Cookie + session認証

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

SPA + Rails API 構成におけるcookie + session認証

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

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

ログイン機能実装

環境

ruby 2.7.1 rails 6.0.3

Rails APIモードでの立ち上げ

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

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

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

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

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

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

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

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

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

参考: Rails の API モードでセッションを有効にする

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に保存するようにしました。

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

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

参考: rails-apiでcookieを使う

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

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

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

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に以下のように記述します。

cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins '*'

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

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

フロントエンド側

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

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

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

CSRF対策

CSRF(クロスサイトリクエストフォージェリ)

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

Cookie の性質を利用した攻撃と Same Site Cookie の効果

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

CSRFとCookie

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

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

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

RailsのCSRF対策

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

参考: Rails セキュリティガイド

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

実験

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

  • 下記のような偽装フォームを持つ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 対策と同一オリジンポリシーの基礎

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

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

固有の HTTP ヘッダでCSRF対策

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

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

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

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

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

バックエンド側の実装

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

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' のヘッダを含めます。

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 どっち?〜

JWT・Cookieそれぞれの認証方式のメリデメ比較

HTML5のLocal Storageを使ってはいけない

SPA + Rails API 構成におけるcookie + session認証

Rails の API モードでセッションを有効にする

rails-apiでcookieを使う

Cookie の性質を利用した攻撃と Same Site Cookie の効果

Rails セキュリティガイド

これで完璧!今さら振り返る CSRF 対策と同一オリジンポリシーの基礎

このWeb APIってCSRF対策出来てますか?って質問にこたえよう

k_kind
Webサービスの開発職を目指して日々勉強中です! 関心のある技術 Ruby, Rails, Laravel, Vue.js, Docker, AWS, CircleCI
https://github.com/K-kind
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした