はじめまして!
プログラミング初学者のおかりなです。
RUNTEQにて学習しています。
入学して7ヶ月が経ちました。
今は卒業制作の本リリースに向けて実装頑張っています。
なにかあればぜひコメントください!
こんな方へ読んでほしい
- 外部APIを用いた認証のテストを書きたい人
導入
Dockerを使用していたにも関わらず、コンテナ外でテストを通していたことに気付きました。
「テストは通っていたし、まあ大丈夫でしょう」と思っていたら、あら大変。
ArgumentError at /
==================
invalid base64
app/views/layouts/application.html.erb, line 9
----------------------------------------------
8 <meta name="turbo-prefetch" content="false">
> 9 <%= csrf_meta_tags %>
10 <%= csp_meta_tag %>
App backtrace
-------------
- app/views/layouts/application.html.erb:9
- app/controllers/home_controller.rb:6
- spec/system/session_spec.rb:14
Full backtrace (抜粋)
--------------------
- base64 (0.3.0) lib/base64.rb:310:in `strict_decode64'
- base64 (0.3.0) lib/base64.rb:379:in `urlsafe_decode64'
- actionpack (8.0.3) lib/action_controller/metal/request_forgery_protection.rb:666:in `decode_csrf_token'
- actionpack (8.0.3) lib/action_controller/metal/request_forgery_protection.rb:482:in `form_authenticity_token'
- actionview (8.0.3) lib/action_view/helpers/csrf_helper.rb:26:in `csrf_meta_tags'
- app/views/layouts/application.html.erb:9
どうやらログイン周りで失敗してまくってるのと、CSRFエラーが起きていたことは分かりました。
が、突然こんなに長文で怒られてびっくりしてしまいました。
原因
書いたときは関係ないと思ってたけど、SecureRandomのstubが原因だったみたいです。
なぜSecureRandomを使っていたか
このアプリではLINEログインを使っているのですが、そのテストを安定させたかったためでした。
# LINEログインをスタブするモジュール
module LineAuthStub
def stub_line_auth(line_user_id: "U1234567890abcdef")
# LINEログインフローをモック化し、実際のLINE APIを呼ばずにテストを実行可能にする
# LineLoginApiController#login で生成されるトークンと同じ値をモックしていく
# 本番では SecureRandom.urlsafe_base64 でランダム値を生成するが、テストでは固定値を返す
allow(SecureRandom).to receive(:urlsafe_base64).and_return("dummy_state")
# 認証URLをモックする
allow_any_instance_of(LineAuth::AuthorizationService)
.to receive(:authorization_url)
.and_return("/line_login_api/callback?code=dummy&state=dummy_state")
# 認証コードをアクセストークンに交換する処理をモックする
allow_any_instance_of(LineAuth::TokenService)
.to receive(:exchange_code_for_token)
.and_return("dummy_id_token")
# IDトークンを検証してLINEユーザーIDを取得する処理をモックする
allow_any_instance_of(LineAuth::IdTokenVerifier)
.to receive(:verify_and_get_user_id)
.with("dummy_id_token")
.and_return(line_user_id)
end
end
このようにSecureRandomを用いてstubすることで、LINEログイン用のstateを固定させていたつもりでした。
ところが、実際はグローバルに(つまり、アプリ全体の)SecureRandomを書き換えていたのです。
CSRFトークンの仕組み
LINEログインとは直接関係ないはずのCSRFトークン検証でエラーが発生するようになったのには、CSRFトークンの仕組みが関係しています。
CSRF = 認証済みのユーザーに不正なリクエストを実行させる攻撃
CSRFとはクロスサイトリクエストフォージェリのことです。
(フォージェリ = Forgery, 偽装)
例えば、偽サイトに誘導させて、ログイン済みのユーザーが操作することで不正なリクエストを送信させるような攻撃のことです。
CSRFトークンはこの攻撃を防止する方法の1つです。
しかし、単純に「保存された値」を照合しているわけではありません。
Railsは、セッションやsecretなどの情報を元に
同じ条件なら同じ結果になることを前提にトークンを再計算し、
その結果が一致するかを検証しています。
なぜCSRFトークンが壊れたのか
SecureRandomをstubすると、トークン生成時と検証時で前提条件がズレます。
上記コードでは、LINEログイン用のstateを固定するために
SecureRandom.urlsafe_base64 をstubしていました。
しかしSecureRandomはCSRFトークン生成にも使われているため、
トークン生成時と検証時でRailsが想定している前提条件が壊れてしまいました。
Railsは同じ計算をしているつもりでも、結果が一致しません。
なので、一番最初に貼ったエラーでInvalidAuthenticityTokenと表示されているんですね。
解決法
上記のコードを直した結果がこれです。
module LineAuthStub
def stub_line_auth(line_user_id: "U1234567890abcdef")
state = "dummy_state"
# 認可URLにテスト用のstate入れてモックする
allow_any_instance_of(LineAuth::AuthorizationService)
.to receive(:authorization_url)
.and_return("/line_login_api/callback?code=dummy&state=#{state}")
allow_any_instance_of(LineAuth::TokenService)
.to receive(:exchange_code_for_token)
.and_return("dummy_id_token")
allow_any_instance_of(LineAuth::IdTokenVerifier)
.to receive(:verify_and_get_user_id)
.with("dummy_id_token")
.and_return(line_user_id)
# SecureRandomのstub化の代わりに、コントローラーのアクションをスタブ化する
# セッションを設定してコールバックURLに直接リダイレクトさせる
allow_any_instance_of(LineLoginApiController).to receive(:login) do |controller|
controller.session[:state] = state
controller.redirect_to "/line_login_api/callback?code=dummy&state=#{state}", allow_other_host: true
end
end
end
さきほどわかったように、SecureRandomをstubすると、Rails全体の前提条件を壊してしまうため使用しないことにしました。
このテストではLINEログインのフロー全体ではなく、
認証後の状態を安定して再現することを目的とします。
ここではloginアクションをstubし、stateをセッションに保存した状態で
コールバックURLへ直接リダイレクトさせています。
ここで、認証済み状態をテスト用に再現しています。
まとめ
今回のエラーは一見するとCSRFとは無関係に見えましたが、
原因を辿っていくことで、Railsの見えない動きを意識するきっかけになりました。
SecureRandomはCSRFトークン生成など、
Railsの内部処理でも利用されているため、
グローバルにstubしてしまうと
Railsが前提としている仕組みそのものを壊してしまいます。
テストを安定させたい場合でも、
乱数生成のような土台となる部分を直接stubするのではなく、
それよりも、「テストしたい状態」をどう再現するかを考えるほうが大切でした。
特に外部APIを使った認証テストでは、フロー全体をなぞっていくと難しかったです。
それよりも認証後の状態に目を向けてテストを書いたほうが、
安定したテストになるんだと学びました。
参考記事