🎄

devise-two-factor によるワンタイムパスワードを使った2要素認証とそのハマりどころ

2024/12/13に公開

■ この記事のざっくりまとめ

この記事では、devise-two-factor のハマりどころ を見ていきながら、

  • devise-two-factor を使って2ステップの2要素認証を導入する際は...
    • Strategies::TwoFactorAuthenticatable は、OTP を使った「認証」には利用しない
    • その代わり Models::TwoFactorAuthenticatable を使って自力で OTP を検証する
  • devise-two-factor を使うときは...
    • 2要素認証用にモデルを分けない方が安全
    • Strategies::TwoFactorAuthenticatableStrategies::DatabaseAuthenticatable は併用しない方がよい

という調査結果・結論についてお話します。

以降、ステップを踏みながらなぜそう考えるのかについて述べていきます。
お付き合い頂けると幸いです。

■ はじめに: devise-two-factor を使った2要素認証の導入方法、みんなちょっと不思議な実装してる..?

  • Rails のシステムに対してワンタイムパスワード(以下 OTP) による2要素認証ってどうやって実装すると良いだろうか?
  • 2024年末の現在、デファクト・スタンダードと言える Gem は何だろうか?

と調べた結果、何年も前から変わらず devise-two-factor Gem が最も幅広く用いられているようです。

今回の記事ではこの devise-two-factor Gem について調べた結果、以下のような ハマりどころ が見つかったので紹介しようと思います。

【ハマりどころ一覧】

  • ① devise-two-factor は 2要素認証を 2 Step に分ける方式に正式対応していない
  • ② devise-two-factor は 2要素認証(OTP)専用の ActiveRecord Model を作成する事に対応していない
  • ③ devise-two-factor の TwoFactorAuthenticatable は、たとえ ActiveRecord Model を分けたとしも devise の DatabaseAuthenticatable と併用しない方が安全

なお、この記事では特筆しない限り User モデルを認証に使うものとします。必要に応じて置き換えてください。

■ 【ハマりどころ①】 devise-two-factor は 2要素認証を 2 Step に分ける方式に正式対応していない

devise-two-factor によるワンタイムパスワード(以下 OTP) を利用するサンプルは検索すると沢山見つかります。
その実装は概ね以下の2種類に分かれています。

  1. devise-two-factor のサンプルのような、Form に含まれる ID/PASS/OTP を同時に検証する方法
  2. ID/PASS を入力してから OTP を入力する方法

です。

ユーザーが2要素認証を ON にしているかどうか分かりませんから、2024年末現在であれば後者の「ID/PASS を入力してから OTP を入力する方法」を採用したいです。

ところがこの後者の実装サンプルはどれも、devise-two-factor の提供する認証機能をそのまま使っていないのです。
その結果、パッと見では回りくどい実装になっていてとても不思議です。

なぜだろう? とコードを調査した結果、この節のタイトルである「devise-two-factor は 2要素認証を 2 Step に分ける方式に正式対応していない」という結果に行き着きました(Issueもありました)。

この後 devise-two-factor のコードを見ながら説明していきますが、まずはその前提知識について紹介させて下さい。

▶ 前提知識: Devise はどうやって「サインイン成功」とするかを判断しているか?

Devise には「何をどのように検証してサインイン成功とする?」という戦略を詰め込んだ Strategy という考え方(名前空間)と、その為の知識・処理を詰め込んだ Module があります。

例えば Devise (非devise-two-factor)の提供する Strategies::DatabaseAuthenticatable であれば「受け取った ID/PASS が DB に保存されたデータと一致すれば認証 OK とする」といった実装となっています。

また Models::DatabaseAuthenticatable は、DB を使った認証に必要な機能を提供します。

これらを組み合わせることで、Devise は簡単に認証の方式を追加できるような仕組みとなっています。

# User モデルに ID/PASS による認証機能を追加するならこんな感じ
class User < ApplicationRecord
  devise :database_authenticatable
end

もちろん devise-two-factor も同じように、ワンタイムパスワードを使った認証を実現する TwoFactorAuthenticatable という Strategy と Module を提供しています。
まずは Strategy という「どう認証するかを決める仕組みがあるんだな」という事だけ覚えておいてください。

▶ devise-two-factor の提供する認証用 Strategy: Strategies::TwoFactorAuthenticatable は ID/PASS/OTP が3つセットで来ることを期待する

では早速 Strategies::TwoFactorAuthenticatable について見ていきましょう。
2要素認証(OTP) を使って「どう認証するか?」という戦略を提供するためのクラス です。

節のタイトルでいきなりネタバレしてしまいましたが、この Strategies::TwoFactorAuthenticatableID/PASS/OTP が3つセットで提供される事を前提 としています。「3つセットで来たら認証 OK」となるのです。

・・・やや辛い感じがしてきましたね。
2024年末現在に一般的な「ID/PASS を入力して OK だったら OTP を入力する」という UI/UX が組みづらい事がほぼ確定してしまいました。

 
気を取り直して Strategies::TwoFactorAuthenticatable の 実装をよく見てみましょう。
2024/12/13 時点で最新の Ver.6.1.0 では以下の実装となっています。

module Devise
  module Strategies
    class TwoFactorAuthenticatable < Devise::Strategies::DatabaseAuthenticatable

      def authenticate!
        resource = mapping.to.find_for_database_authentication(authentication_hash)
        # We authenticate in two cases:
        # 1. The password and the OTP are correct
        # 2. The password is correct, and OTP is not required for login
        # We check the OTP, then defer to DatabaseAuthenticatable
        if validate(resource) { validate_otp(resource) }
          super
        end

        fail(Devise.paranoid ? :invalid : :not_found_in_database) unless resource

        # We want to cascade to the next strategy if this one fails,
        # but database authenticatable automatically halts on a bad password
        @halted = false if @result == :failure
      end

      def validate_otp(resource)
        return true unless resource.otp_required_for_login
        return if params[scope].nil? || params[scope]['otp_attempt'].nil?
        resource.validate_and_consume_otp!(params[scope]['otp_attempt'])
      end
    end
  end
end

Warden::Strategies.add(:two_factor_authenticatable, Devise::Strategies::TwoFactorAuthenticatable)

参照: GitHub

ここでまず注目したいのは以下のコメントです。

# We authenticate in two cases:
# 1. The password and the OTP are correct
# 2. The password is correct, and OTP is not required for login
# We check the OTP, then defer to DatabaseAuthenticatable

日本語に訳すと、

# 認証方法は2通りあるよ。
# 1. PASS と OTP が正しい
# 2. PASS が正しい。OTP はログインに必要ではない(= 2要素認証がOFFである)
# 
# この Strategy では OTP を確認して、その後は `DatabaseAuthenticatable` に処理を委譲するよ!

という感じです。

コードについても流れを見てみましょう。
ざっくり言うと以下の通りです。

  • resource = mapping.to.find_for_database_authentication(authentication_hash) にて User などの認証に使うリソースを取得する。
  • OTP の入力が必須となっている(2要素認証がON) の場合、validate_otp メソッドで OTP を検証する。必須でなければ(2要素認証が OFF) ならスキップする。
  • DatabaseAuthenticatable に super で処理を委譲する = パスワードを検証する。

もうちょっとゆるく言語化しましょう。

  • 渡ってきた ID を使って User を取得する
  • 2要素認証が ON なら OTP が正しいかを検証する(OFF ならスキップ)
  • パスワードが正しければサインインする

...

そう、devise-two-factor の Strategies::TwoFactorAuthenticatableパスワードよりも先に OTP を検証する という仕組みなのです。

なんということでしょう・・
Strategies::TwoFactorAuthenticatable を使うだけでは「ID/PASS を検証して OK だったら OTP を入力する」という、昨今一般的となっている UX を 提供することが出来ない のです。

ですから、ID/PASS を検証して OK であれば OTP を入力して... という2ステップ式の2要素認証を実現したいのであれば、その辺りの実装を自分でやる必要がある。
だから皆さんいろいろと工夫していたのですね。納得です。

▶ みんなどうやって 2要素認証を 2 Step に分けて入力する方法に対応している?

では、みなさんどんな風に2ステップの2要素認証を実現しているのでしょうか?

Controller を ID/PASS の認証と OTP の認証で分ける・分けない、2要素認証用のモデルを分ける・分けないという差はありますが、概ね以下のような実装に収束しているように見受けられます。

  • ID/PASS を検証する
  • ID/PASS が OK であればその情報をセッションに格納して OTP 入力画面へリダイレクト
  • ユーザーが OTP を入力する
  • セッションから ID/PASS が OK だったよ情報を取り出せた場合、OTP を検証する
  • OTP が OK だったらサインインする

なるほど、これなら確かに実現できそうです。

しかし注意してください!

この方式の一番のポイント は「OTP を使ったログイン時は Strategies::TwoFactorAuthenticatable をまともに利用していない(!)」という点にあります。

▶ OTP を使ったログイン時は Strategies::TwoFactorAuthenticatable をまともに利用していない?

・・・どういうことでしょうか?

先程までの話を思い出してください。
Strategies::TwoFactorAuthenticatableID/PASS/OTP が3つセットで来ることを期待する 仕組みでした。

ID/PASS の検証 → OTPの検証 という2ステップに分けるフローでは ID/PASS と OTP がバラバラに届きます(ブラウザに ID/PASS を保持しておきたくありませんしね)。
したがって Strategies::TwoFactorAuthenticatable を活用する事は難しいのです。

ところがいくつかのサンプルを見ると、User モデルに devise :two_factor_authenticatable と指定していますし、
config/initializers/devise.rb でも以下のように、Devise の設定で Warden に対して Strategy を追加しています。
※ Warden とは Devise が依存するライブラリで、認証基盤を提供してくれるミドルウェアです。

config.warden do |manager|
  manager.default_strategies(:scope => :user).unshift :two_factor_backupable
  manager.default_strategies(:scope => :user).unshift :two_factor_authenticatable
end

パッと見、いかにも Strategies::TwoFactorAuthenticatable で認証をしていそうに見えます。
しかし、ID/PASS/OTP がセットじゃないと認証出来ない。

ならどうするか。

...そうです。 ならば自力でやる のです。
OTP が必須(2要素認証が ON の場合) には Strategies::TwoFactorAuthenticatable に頼らず自力で検証します。

流れは以下のように変わります。

  • ID で User を取得する
  • User が OTP を必要とする(= 2要素認証がON) かどうかを確認する
  • OTP が不要(2要素認証 OFF) の場合...
    • Strategies::TwoFactorAuthenticatable を使ってサインインを試行する。
    • OTP の確認はスキップし、処理を Strategies::DatabaseAuthenticatable に移譲。そちらで ID/PASS の検証 & サインインが行われる。
  • OTP が必須(2要素認証が ON) の場合...
    • パスワードが正しいことを確認し、セッションに「ID/PASS が OK でした!情報」を詰め込む。
    • OTP 入力画面へリダイレクトする。
    • OTP が入力されたら Models::TwoFactorAuthenticatable の便利メソッドたちを呼んで OTP を検証する。
    • OTP が OK ならサインインする。

ここで登場するのが、先述した TwoFactorAuthenticatable です。
以下のように、2要素認証で OTP を使うために必要な様々なメソッド群が含まれています。

https://github.com/devise-two-factor/devise-two-factor/blob/10353b90cfcc0b2b1977ee230270b0761d086fb8/lib/devise_two_factor/models/two_factor_authenticatable.rb#L34-L52

このため、

# User モデルに OTP による認証機能に使える便利メソッドが追加される
class User < ApplicationRecord
  devise :two_factor_authenticatable
end

として指定するだけで User モデルに、OTP を生成するメソッドや OTP を検証して期限切れにするメソッドなどなど様々な機能が追加されます。
これらの便利機能を駆使して OTP の検証とサインインを実現しているのです。

これが「OTP を使ったログイン時は Strategies::TwoFactorAuthenticatable をまともに利用していない」という理由となります。

OTP が不要(= 2要素認証が OFF) の場合は Strategies::TwoFactorAuthenticatable を利用しますが、OTP の確認処理はスキップされて Strategies::DatabaseAuthenticatable へ処理が移譲されパスワードの検証が走ります。

こうして見てみると、Strategies::TwoFactorAuthenticatable そのものはほぼ利用されていないように見えます。
が、しかし Strategies::TwoFactorAuthenticatable にはひとつだけ とても重要な役割 があります。

それは「2要素認証(OTP) が ON の場合に、ID/PASS だけではサインインさせない」という役割です。

2要素認証の入力を2ステップに分けたい場合、ID/PASS が渡ってきただけでサインインされてしまっては困ります。
そこで Strategies::TwoFactorAuthenticatable を使って、2要素認証(OTP) が ON の場合、ID/PASS だけではサインイン出来ない ように制限を掛けています。

なおこの性質が、次の章で紹介するハマりどころと関連してきます。
2重3重のトラップです。

■ 【ハマりどころ②】devise-two-factor は、2要素認証(OTP)専用の ActiveRecord Model を作成する事に対応していない

devise-two-factor を採用するにあたって、DB に OTP 関連の列を追加する必要があります。

add_column :users, :otp_secret, :string
add_column :users, :consumed_timestep, :integer
add_column :users, :otp_required_for_login, :boolean
add_column :users, :otp_backup_codes, :text # two_factor_backupable でバックアップコードによるログインを有効化する場合のみ

これらの列、User モデルに追加したくないな... モデルを分割しようか... みたいに考える事もあるでしょう。
不可能ではなさそうですが、個人的にはあまりお勧めしません。

理由は、devise-two-factor や Strategies::TwoFactorAuthenticatable はその実装上、OTP に関する列を別テーブルに分割する前提で作られていないためです。

Strategies::TwoFactorAuthenticatable は、OTP が必須でない(2要素認証が OFF) の場合には Strategies::DatabaseAuthenticatable を使う仕様でした。
そしてこの Strategies::DatabaseAuthenticatablepassword などの列を必要としますから、password 列などがモデルにないのは不自然になってしまいます。

もちろん先述の通り、Strategies::TwoFactorAuthenticatable は認証には利用しません。
ですからコメントなどで補ったうえで「不自然な状態でも OK とする!」と割り切るのも方法のひとつだとは思います。

また勿論、

  • 自作の Strategy を作る
  • OTP 関連で devise-two-factor が使う全ての列を delegate する

などの方法で対応することも不可能ではないでしょう。

組織的にそうしたオリジナル実装を安全にメンテし続けられるのであれば止めはしませんが、
素直に User モデルに(涙をのんで)追加してしまうのが中長期的に見ると安定かなと思います。

そして分けてしまうと、次の章で紹介するハマりどころにスポっとハマってしまう可能性が出てきますので注意が必要です。

■ 【ハマりどころ③】devise-two-factor の TwoFactorAuthenticatable は、たとえ ActiveRecord Model を分けたとしも devise の DatabaseAuthenticatable と併用しない方が安全

▶ 【前提】そもそも TwoFactorAuthenticatableDatabaseAuthenticatable は併用してはいけない

README の Getting Started の末尾、Generator の動作に関する部分(コマンドラインで自動的に設定してくれる手順)の最後にしれっと書かれているのですが、

Loading both :database_authenticatable and :two_factor_authenticatable in a model is a security issue It will allow users to bypass two-factor authenticatable due to the way Warden handles cascading strategies!

のように、:database_authenticatable:two_factor_authenticatable を同時に設定してはいけないと書かれています。
Devise が複数の Strategy を併用できるように設計されているため、2要素認証をバイパス出来る脆弱性に繋がる可能性があるためです。

まずは Strategies::TwoFactorAuthenticatable を使って確認して、ダメなら Strategies::DatabaseAuthenticatable を使って・・・ という設定も出来てしまうので、
2要素認証(OTP) が ON の場合に ID/PASS だけが Strategies::DatabaseAuthenticatable に渡って認証に成功してしまう事故が発生する可能性があります。
Strategies::DatabaseAuthenticatable にとっては ID/PASS だけで認証出来るのが正しいのですから。

こうした危険性を秘めてしまわないよう TwoFactorAuthenticatableDatabaseAuthenticatable併用しない 方が安全です。

▶ 2要素認証(OTP) 用にテーブルを分けたら併用してもいいのでは?!

では以下のようなイメージでテーブルを分けて併用するのはどうでしょうか?

# 出来れば避けたほうがよい例なので注意!
class User
  devise :database_authenticatable
end

# OTP 用の設定テーブルを作ってみた
#
# otp_secret, :string
# consumed_timestep, :integer
# otp_required_for_login, :boolean
# otp_backup_codes, :text
class TwoFactorOtpSetting
  belongs_to :user

  devise :two_factor_authenticatable
end

個人的には「長期的な事を考えると避けたほうが無難」だと考えます。

Devise の説明文には、

Loading both :database_authenticatable and :two_factor_authenticatable in a model is a security issue It will allow users to bypass two-factor authenticatable due to the way Warden handles cascading strategies!

とあります。この "in a model" というのがミソで、2要素認証用にモデルを分ければ大丈夫かな...? なんて思ってしまいます。

実際の所、Model と Strategy を分割しても Model/Controller を注意深く設計・実装すれば罠にハマることを回避できそうですが、
実装の理由を理解せずに触ってしまって2要素認証がバイパスされてしまう脆弱性を招いてしまう可能性があります。

先述のようにそもそも2要素認証を2ステップに分ける場合、Strategies::TwoFactorAuthenticatable は認証そのものには使いません。
ですから TwoFactorOtpSetting モデルには password などの Strategies::DatabaseAuthenticatable が必要とする列が無くても困ることはない筈です。
繰り返しとなりますが、仮に存在しない列にアクセスしたら落ちるからヨシ! のように考えることも可能でしょう。
コメントなどで補ったうえで「不自然な状態でも OK とする!」と割り切るのも方法のひとつだとは思います。

が、やはりこちらも個人的には「可能であれば避けた方が無難」かなと思います。

devise-two-factor を採用しているにも関わらず、Strategies::DatabaseAuthenticatable が必要とする列がモデルにないのは不自然になってしまいますし、
もしもミスやバグで OTP が必須(2要素認証が ON) なのに Strategies::DatabaseAuthenticatable が適用されてしまうと2要素認証がバイパスされてしまいます。

Strategies::TwoFactorAuthenticatableStrategies::DatabaseAuthenticatable を両方使っているのだから、Devise のデフォルト Strategy に両方とも追加しなきゃ!! ってついよかれとやってしまう未来が来るかもしれません。

テーブルを分割する利点と、設定などのミスで2要素認証をバイパスされてしまう可能性を天秤にかけたとき、自分なら安全側に振って分割を諦めるかなと思います。

▶ Devise は current_usersigned_in? を呼んだだけでサインインを試行する

Strategies::TwoFactorAuthenticatableStrategies::DatabaseAuthenticatable を併用しながらも、prepend_before_actionbefore_action などで危険を防いで上手く運用できているとします。

こうした場合でも Devise が提供する current_user を間違ったタイミングで実行してしまうと2要素認証をバイパスしてしまう危険性が生じます。

Devise のメソッドである current_useruser_signed_in?, signed_in? (Devise::Controllers::SignInOut) を呼ぶと、なんと Devise は サインインを試行 します。

これらのメソッドの名前から想像することは困難なのですが、Devise の実装は warden.authenticate を呼ぶ ようになっているのです。
つまり Devise で気軽に user_signed_in? と確認したつもりが、実はサインインをしようとしているなんてことに...
※ ちなみに authenticate? もサインインを試行する ので注意してください。current_user は仕様(10年以上この仕様なので今更変えられない)、user_signed_in? は次のメジャーバージョン(Ver.5 系)で直す予定 のようです(数年動きがないようですが...)😭

話を戻しましょう。

もしサインインを司る SessionsControllerbefore_actioncurrent_user を呼んでしまって、Devise がサインインを試行してしまうと何が起こるでしょうか?
まだ処理前だと言うのに、Form に入ってきた ID/PASS を使ってサインインを試行してしまうでしょう。

また ID/PASS を入力して OTP を入力するまでの間に current_user を呼んでいたら何が起こるでしょうか。
こちらもまだ OTP を検証していない = まだサインインしてはいけないタイミングなのにも関わらず、Devise は User モデルを使ってサインインを試行してしまいます。
そして User モデルは Strategies::DatabaseAuthenticatable です。Form に ID/PASS があれば認証できます。
つまり、2要素認証(OTP) がバイパスされてしまいます(!)

どちらの例も、準備が整っていないのに何らかのミスで curernt_usersigned_in? を呼び出すコードが混入している状態 です。
特に後者のように OTP が必要な場面の場合、2要素認証をバイパス出来てしまう可能性 が生じます。
もちろんブラウザバックや F5 リロードするとログイン状態となります。

こんなことしないよ(笑) と思う方もいらっしゃるかもしれません。
でも before_action でログなどを残すために current_user を使うことってありますよね。
名前的に安全そうな名前ですからついついやりがちですし、油断した頃に混入してくる可能性も捨てきれません。

prepend_before_action を使って抑制したり、デフォルトの Strategy の扱い方などによって発生を防ぐなど、様々な工夫で回避することも可能だとは思います。
しかしちょっとしたミスで2要素認証がバイパスされてしまう危険性を抱えてしまいますから、
やはりモデルを分けて Strategies::TwoFactorAuthenticatableStrategies::DatabaseAuthenticatable を併用するのは出来れば避けたほうが良いでしょう。

3年後、5年後、すっかり忘れた頃に爆発するかもしれませんから。

▶ なら OTP 入力画面にリダイレクトする前に念の為 sign_out しておけば・・!?

before_action などでサインインされてしまうなら、リダイレクト直前に sign_out すればいけるのでは?!! と思ってやってみました。

サインアウトした後、OTP を入力した際に再度サインインをするのでサインインのカウントが2回インクリメントされてしまいます。
なんらかのエラー発生のタイミング次第ではサインインしたままになることも想定できますし、素直に諦めるのが一番そうです😭

■ まとめ

長大な記事を最後まで読んでいただき、ありがとうございました。

この記事で書いたことをまとめると、

  • devise-two-factor を使って2ステップの2要素認証を導入する際は...
    • Strategies::TwoFactorAuthenticatable は、OTP を使った「認証」には利用しない
    • その代わり Models::TwoFactorAuthenticatable を使って自力で OTP を検証する
  • devise-two-factor を使うときは...
    • 2要素認証用にモデルを分けない方が安全
    • Strategies::TwoFactorAuthenticatableStrategies::DatabaseAuthenticatable は併用しない方がよい

となります。

Devise, devise-two-factor, Warden と異なる知識を幅広く求められるなかなかの難易度な調査となりました。
もし「こんな実装なら安全に出来るよ!」といった方法がありましたらぜひ共有頂ければと思います。

READYFORテックブログ

Discussion