記事の趣旨
モダンなWebアプリ開発の学習として、Vue.jsによるフロント、Rails APIによるバックエンドで実装するSPAを作りました。
その中で、SPAの様々なログイン実装方法や、それに必要な CSRF 対策を学びました。
最終的に session + cookie
を利用するログインを実装した過程を記録したいと思います。
ログイン実装方法の検討
SPAのログイン実装方法
下記の記事等を読んで、主に JWT
を使う方法と、 session
を使う方法があることが分かりました。
上の記事では、様々な方法の良し悪しが網羅的にまとめられていました。
上の記事では、JWTとCookieが端的に比較されていました。
上の記事では、 JWT
を Local Storage
に保存することの危険性が説かれています。
重要なデータを保存する必要があるなら、常にサーバーサイドセッションを使うべきです。
Cookie + session認証
いずれの記事でも、 JWT
を単純に Local Storage
に保存する方法は避けるべきだと言われています。
そこで、下記の記事の実装例を参考にさせていただき、 session
を Cookie
に保存する方法を採用することにしました。
SPA + Rails API 構成におけるcookie + session認証
この方法は、普通の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チュートリアルにあるような基本的な方法を使いました。
# フロントから { 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 モードでセッションを有効にする
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する必要がありました。
class ApplicationController < ActionController::API
include ActionController::Cookies # 追加
…
end
これで、普通のRailsアプリと同じように、安全なCookieに情報が保存され、リクエストで自動的にCookie情報が送られるようになります。
ログインが必要なアクションでは、下記のような before_action
で、 @current_user
をセットするようにしました。
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
に以下のように記述します。
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
というオプションを含めます。
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
ヘッダをリクエストに含めることができないからです。
したがって、このヘッダを持たない偽装リクエストはバックエンドで弾くことができるようになります。
(フォームではなく、Javascriptにより、このヘッダを持つ偽装リクエストが送られた場合は、同一オリジンポリシーによりブラウザが弾いてくれます。)
バックエンド側の実装
application_controller
に、ヘッダをチェックする before_action
を追加します。 X-Requested-With
ヘッダの有無をチェックする request.xhr?
というメソッドを利用します。
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 どっち?〜
SPA + Rails API 構成におけるcookie + session認証
Cookie の性質を利用した攻撃と Same Site Cookie の効果