🗝️

【RSpecテスト事件簿】二要素認証テストで迷宮入りした話。犯人は...

に公開

はじめに

こんにちは、ラブグラフでエンジニアインターンをしているうらっしゅです!
今回は、私が二要素認証の実装中に遭遇した、ある不可解な「事件」の顛末をお話ししようと思います。

それは、「認証コードは完璧に合っているはずなのに、なぜか認証に失敗し続ける」という謎の事件。
思わず、PCの前でこう叫びました。

いや、なんでやねん!! (魂の叫び)

事件のタイムライン

順風満帆に見えた開発は、ある日を境に暗転します。

1. 二要素認証を実装。(この時点では任意設定)

2. テストも完璧。

登録からログインまで、全てのテストが気持ちよくPASS。

3. 無事リリース。

ユーザーの皆様にも問題なく使っていただけている。順調そのもの。

4. 認証必須化へ。

セキュリティ向上のため、未設定ユーザーに設定を促すロジックを追加。

5. 悪夢の始まり。

必須化に伴うテストを走らせると、昨日まで動いていたはずの二要素認証テストが、何度やっても失敗する事件が発生。

迷宮入り:当時のSlack

あまりのわからなさに、私の思考は完全に迷宮入り。
当時のSlackには、限界を迎えた私の悲痛な叫びが残されています。
「認証コードが合っているのに、間違っていると判定されるなんて…あんまりじゃないか😢」
追い詰められた私は、認証コードそのものを文字列として比較するという、禁じ手中の禁じ手(劇薬)に手を出しそうになるほどでした。

現場検証:容疑者たちのコード

それでは、事件が起きた「現場」を見ていきましょう。

手がかり① :自動サインイン処理

二要素認証が必須になったことで、テスト用の自動ログイン処理も変更を余儀なくされました。
メールアドレスとパスワードでのログイン後、続けてワンタイムパスワード(OTP)を入力してログインを完了させる、という流れです。

signin.rb
def sign_in(user)
    visit "/login"

    # まずはメールアドレスとパスワードでログイン
    within("form") do
      fill_in "email", with: user.email
      fill_in "password", with: "password"
    end

    find("button[type=submit]").click

    # 続いて二要素認証のワンタイムパスワードを入力
    within("form") do
      fill_in "input_code", with: user.current_otp
    end

    click_on "確認"
end

手がかり② :犯行現場:どうしても成功しないテスト

そして、こちらが問題の犯行現場です。
ログイン後に、二要素認証を「無効化」できるかを試すテスト。ここでも本人確認のため、パスワードとOTPの入力が求められます。

two_factor_spec.rb
RSpec.describe "二要素認証設定", :js do
  let!(:user) { create(:user) }

  before do
    # まずは①の処理で自動ログインを行う
    sign_in user
  end

  context "二要素認証設定画面で無効化操作を正常に行う場合 do
    it "二要素認証が有効になり、マイページにリダイレクトされる" do
      # ...(パスワード入力などの処理)...

      # 認証コード入力画面で、OTPを入力
      fill_in "two_factor_code", with: user.current_otp
      click_on "確認"

      # ここでテストは落ち、無情にも赤く染まる
      expect(page).to have_current_path(mypage_two_factor_path)
    end
  end
end

③ だが残念!! このテストは100%落ちるんだなぁ🤪

ええ、何度やってもです。

ログを見ても、入力しているコードは正しい。なのに、システムは「NO」と突き返してくる。
まさに、完 全 犯 罪…?

事件の真相:犯人は「時間」だった

これらのテストが落ちしまって原因。
それはズバリ、主に二要素認証の以下のルールが考慮されていないことにありました。

  1. 一度使った認証コード(OTP)は使えない。
  2. 過去の認証コード(OTP)は使えない。

「え、認証コードは2度も使った...?」という声が聞こえてきそうです。

そうです。
私たちのテストコードは、知らず知らずのうちに「一度しか使えない鍵」を、二度使おうとしていたのです。

一度目は、beforeブロックのsign_in user

ここでuser.current_otpはログインのために消費されます。

# 続いて二要素認証のワンタイムパスワードを入力
within("form") do
    fill_in "input_code", with: user.current_otp
end

そして二度目は、その後の無効化テスト。

ここで再びuser.current_otpを呼び出しても、それは既に使用済みの「古い鍵」。
扉が開くはずもありませんでした。

# 認証コード入力画面で、OTPを入力
fill_in "two_factor_code", with: user.current_otp
click_on "確認"

無罪放免:時を操り、事件を解決へ

犯人が「時間(=OTPの有効期限)」だとわかれば、解決策は一つ。
我々はテストの世界で「時を操る」必要がありました。
travel_toというタイムマシンのようなメソッドを使い、ログイン時と設定変更時で時間の流れをずらし、それぞれの場面で「新品の鍵」を用意することで、ついにテストをパスさせることに成功したのです。

正解コード (自動ログインと、認証コード検証時で分けよう!)

RSpec.describe "マイページ内での二要素認証設定", :js do
    let!(:user) { create(:user) }

    before do
      # 新しいOTPの検証時間が、ログイン時のOTP検証時間よりも過去になると使えなくなる仕様があるため、
      # 2010年1月1日を基準にして、各OTPの検証時間を設定する。
      travel_to "2010-01-01 00:00:00".in_time_zone
      sign_in user
    end

    context "認証コードが正しい場合" do
        before do
          # ログイン時のOTP検証時間より1分だけ未来の時間に設定
          # これにより、使用可能なOTPが生成される
          travel_to "2010-01-01 00:01:00".in_time_zone
        end

        it "二要素認証が有効になり、マイページにリダイレクトされる" do
          # パスワード入力画面を経由する
          visit input_password_mypage_two_factor_path
          fill_in "パスワード", with: "password"
          click_on "確認"

          # QRコード画面の次へボタンを押す
          click_on "次へ"

          # 認証コード入力画面で認証コードを入力
          fill_in "two_factor_code", with: user.current_otp
          click_on "確認"

          # 正常にマイページ遷移するか
          expect(page).to have_current_path(mypage_two_factor_path)
        end
      end

まとめ

「合っているはずなのに、なぜ…?」と頭を抱えた二要素認証のテスト失敗事件。
その真相は、あまりにも基本的で、だからこそ見落としてしまっていた 「ワンタイムパスワードは、一度使ったらお役御免」というルールでした。

必死にデバッグログを追いかけていたあの時の私に教えてあげたいです。
犯人はコードのロジックではなく、テストの世界における「時間の流れ」だったのだと。
ログイン処理で使った認証コードは、その役目を終えて静かに消えていたのです。それに気づかず、次のテストでも同じコードを使おうとしていたのが、すべての原因でした。

travel_toを使ってテストの世界の時間を未来に進め、新しい認証コードを発行することで、この事件は無事解決しました。
今回の失敗から、目に見えるコードだけでなく、その裏にある機能の「お作法」を正しく理解することが、いかに大切かを学びました。
この失敗談が、皆さんのデバッグ作業のヒントになれば、嬉しいです。

ラブグラフのエンジニアブログ

Discussion