2要素認証を実現するGem「devise-two-factor」の内部挙動を追ってみた | Dev Driven 開発・デザインチーム 2要素認証を実現するGem「devise-two-factor」の内部挙動を追ってみた | 働くひとと組織の健康を創る iCARE

BLOG

2要素認証を実現するGem「devise-two-factor」の内部挙動を追ってみた

2021/08/31

こんにちは!サーバーサイドエンジニアの越川です!

今回は2要素認証の仕組みを提供してくれるGemである、devise-two-factorのソースコードを読んで実行して内部挙動を追ってみたので、ブログに残してみたいと思います。

偉大なる本家レポジトリ
https://github.com/tinfoil/devise-two-factor

環境

Ruby 2.6.6
Rails 6.0.3
MacOS catalina
devise-two-factor 4.0.0

validate_and_consume_otp!メソッド

https://qiita.com/Kta-M/items/e155f6e35e3e8274ff1e

こちらの記事の実装を参考に、コードを追ってみます。

elsif user_params[:otp_attempt].present? && session[:otp_user_id]
  # 認証コードが合っているか確認
  if user.validate_and_consume_otp!(user_params[:otp_attempt])
    # セッションのユーザーIDを削除して、サインイン
    session.delete(:otp_user_id)
    # 認証済みのユーザーのサインインをするDeviseのメソッド
    sign_in(user) and return
  else
    # 認証コード入力画面を再度レンダリング
    flash.now[:alert] = 'Invalid two-factor code.'
    render :two_factor and return
  end
end

こちらで使われているvalidate_and_consume_otp!メソッドを見てみます。

# This defaults to the model's otp_secret
# If this hasn't been generated yet, pass a secret as an option
def validate_and_consume_otp!(code, options = {})
  otp_secret = options[:otp_secret] || self.otp_secret
  return false unless code.present? && otp_secret.present?

  totp = otp(otp_secret)
  if totp.verify(code, drift_behind: self.class.otp_allowed_drift, drift_ahead: self.class.otp_allowed_drift)
    return consume_otp!
  end

  false
end

引数のoptionsで渡されたotp_secretか、または自身(= two_factor_authenticatableをincludeしているモデル)のotp_secretを変数として定義し、引数で渡されたcodeが存在しない場合かotp_secretが存在しない場合はfalseが返り値になります。

otpメソッドも見てみます。

def otp(otp_secret = self.otp_secret)
  ROTP::TOTP.new(otp_secret)
end

rotp gem のROTP::TOTPクラスのインスタンスを生成しています。このインスタンスが実際に認証をする処理を担ってくれています。

totp.verifyは実際にcodeやその他設定値を持って、認証を試みる処理です。認証に成功した場合は、consume_otp!メソッドが呼ばれているので、こちらも見てみます。

# An OTP cannot be used more than once in a given timestep
# Storing timestep of last valid OTP is sufficient to satisfy this requirement
def consume_otp!
  if self.consumed_timestep != current_otp_timestep
    self.consumed_timestep = current_otp_timestep
    return save(validate: false)
  end

  false
end

自身のconsumed_timestepと現在時刻とotpインスタンスに設定されたintervalを元に算出したtimestepを比較して、異なっていれば、自身のtimestepとして代入し、レコードの保存処理を行っています。

この辺りの挙動を一通りコンソールで試してみましょう。

事前準備として、userクラスにインスタンスメソッドとして以下を定義します。

  def validate_and_consume_otp!(code, options = {})
    binding.pry
    otp_secret = options[:otp_secret] || self.otp_secret
    return false unless code.present? && otp_secret.present?

    totp = otp(otp_secret)
    if totp.verify(code, drift_behind: self.class.otp_allowed_drift, drift_ahead: self.class.otp_allowed_drift)
      return consume_otp!
    end

    false
  end

この状態でコンソールで試します。

# 既に2要素認証が有効になっているユーザー
$ user = User.first

$ user.validate_and_consume_otp!(user.current_otp)

# ここからpryのREPL

$ otp_secret = options[:otp_secret] || self.otp_secret
=> "FUGAHOGEFUGAHOGEFUGAHOGE"

$  code.present? && otp_secret.present?
=> true

$ totp = otp(otp_secret)
=> #<ROTP::TOTP:0x0000560361a8fc18 @digest="sha1", @digits=6, @interval=30, @issuer=nil, @secret="FUGAHOGEFUGAHOGEFUGAHOGE">

# 30秒経過して認証コードの期限が切れていたのでnilが返っている
$ totp.verify(code, drift_behind: self.class.otp_allowed_drift, drift_ahead: self.class.otp_allowed_drift)
=> nil

# 認証に成功したパターン
$ totp.verify(self.current_otp, drift_behind: self.class.otp_allowed_drift, drift_ahead: self.class.otp_allowed_drift)
=> 1622552700

$ consume_otp!
   (0.5ms)  BEGIN
  User Update (8.6ms)  UPDATE "users"
  SET "consumed_timestep" = $1, "updated_at" = $2
  WHERE "users"."id" = $3  [["consumed_timestep", 54085095], ["updated_at", "2021-06-01 13:07:33.403507"], ["id", 1]]
   (3.0ms)  COMMIT
=> true

ちなみに、totpインスタンスのdigitsは認証コードの桁数で、intervalは何秒間で認証コードが変わるかを制御しています。

otp_provisioning_uriメソッド

private
# QRコードを作成
def build_qr_code
  RQRCode::render_qrcode(
    current_user.otp_provisioning_uri(current_user.email, issuer: "mfa-sample"),
    :svg,        # SVG形式
    level: :l,   # 誤り訂正レベル
    unit: 2      # 一つのマスの縦横ピクセル数
  )
end

認証用のQRコードを生成するコードで使われています。

定義元はこちら

def otp_provisioning_uri(account, options = {})
  otp_secret = options[:otp_secret] || self.otp_secret
  ROTP::TOTP.new(otp_secret, options).provisioning_uri(account)
end

ROTP::TOTPクラスのインスタンスメソッドであるprovisioning_uriを呼び出していました。

実行すると、以下のような返り値を得ることができます。

=> "otpauth://totp/mfa-sample:test-user%2B1%40example.com?secret=VLRNGJZQXVEFPDRXTGYCPL7S&issuer=mfa-sample"

このURLをQRコード化してGoogleAuthenticator等の2要素認証アプリに読ませると、6桁の認証コードをアプリ内に表示させることができます。

generate_otp_backup_codes!メソッド

バックアップコードを生成するメソッドです。

定義元はこちら

# 1) Invalidates all existing backup codes
# 2) Generates otp_number_of_backup_codes backup codes
# 3) Stores the hashed backup codes in the database
# 4) Returns a plaintext array of the generated backup codes
def generate_otp_backup_codes!
  codes           = []
  number_of_codes = self.class.otp_number_of_backup_codes
  code_length     = self.class.otp_backup_code_length

  number_of_codes.times do
    codes << SecureRandom.hex(code_length / 2) # Hexstring has length 2*n
  end

  hashed_codes = codes.map { |code| Devise::Encryptor.digest(self.class, code) }
  self.otp_backup_codes = hashed_codes

  codes
end

このメソッドはTwoFactorBackupableモジュールに定義されたメソッドのため、devise-two-factorを使うモデルで、includeする必要があります。
また、otp_backup_codesカラムをText型でテーブルに追加する必要があります。

# migration file

class AddOtpBackupCodesToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :otp_backup_codes, :text
  end
end

# app/models/user.rb

class User
  devise :two_factor_authenticatable, :otp_secret_encryption_key => "YOUR_ENCRYPTION_KEY"
  devise :two_factor_backupable, otp_number_of_backup_codes: 10 # 追加

  serialize :otp_backup_codes, JSON
end

この状態でマイグレーションを実行した後、コンソールで試してみます。例によってbinding.pryを入れるためにオーバーライドしてメソッドを定義します。

# app/models/user.rb

  def generate_otp_backup_codes!
    binding.pry
    codes           = []
    number_of_codes = self.class.otp_number_of_backup_codes
    code_length     = self.class.otp_backup_code_length

    number_of_codes.times do
      codes << SecureRandom.hex(code_length / 2) # Hexstring has length 2*n
    end

    hashed_codes = codes.map { |code| Devise::Encryptor.digest(self.class, code) }
    self.otp_backup_codes = hashed_codes

    codes
  end

# ここからコンソール
# 既に2要素認証が有効になっているユーザー

$ user = User.first

$ user.generate_otp_backup_codes!

# ここからpryで止めてREPL

$ codes = []
=> []

$ number_of_codes = self.class.otp_number_of_backup_codes
=> 10

$ code_length = self.class.otp_backup_code_length
=> 16

$  number_of_codes.times do
      codes << SecureRandom.hex(code_length / 2) # Hexstring has length 2*n
    end

$ code
=> ["d959aac4d77643f8",
 "5881fc6821685e40",
 "c431ed4384681da6",
 "bc4a228b2217239b",
 "dfa8122333fdc704",
 "42273b653a68e89c",
 "190da55833911a67",
 "63352de35833aa52",
 "8338c54343c270b1",
 "00031f3c5c70baec"]

$ hashed_codes = codes.map { |code| Devise::Encryptor.digest(self.class, code) }
=> ["$2a$12$noh38D6ZEdIsTBaiGyYVV.I5Mjz.9pguU1ThDumfgL8h0IUKM3IUe",
 "$2a$12$YSZpoLRTQDzuB.yNiZaEf.bt6FX0UAGAzVtmgeoeC4YOjCRKn6RfK",
 "$2a$12$BqM83eBJMQ3hKgQ.8WOnAeeN9j42GLeLeOnbmLQ.Lwb/K.QnwU9zK",
 "$2a$12$salUR3jAaXakKhM.2v04iuujK5lKl9a.6W2LCkNLoU7Fqg0c.PHci",
 "$2a$12$sM4o3IyB9UAoC4fDrey0d.t5YYEGmegrQAkIitKEy.3OkCDtPhF.O",
 "$2a$12$93jrNp6KjMb3Rnvd8FByjOAhrv8.uJdEpyk55sLJ4VHvkhozucsb2",
 "$2a$12$VH2cAMEtfoqFZp1L3Fy.nuJ0vREfmUcjD3NjQtKyliIr3qv1c.FQG",
 "$2a$12$ErqLtplE4AhyFFNTVkxA/.KYDGSI3vq2QxINhTUSiTJSO17JSHyaC",
 "$2a$12$T9hSGmQOdAZseI/3Np.YpuuvM44DKcVJzMg7uXRr8XuHnpEAAVZzC",
 "$2a$12$5Zda5LoBdej6sAnoZdu/K.7.WVmnD76EMA2e6/25ZwYb8/dttiwKW"]

Devise::Encryptor.digestは詳しく処理は追いませんが、バックアップコードをハッシュ化する関数ですね。このハッシュ化したバックアップコードを、先ほどマイグレーションファイルで追加したotp_backup_codesカラムに追加しています。

レコードをsaveする処理はメソッド内ではコールしてないため、generate_otp_backup_codes!を実行した後は、実装者自身でsaveメソッドをコールしないといけません。

まとめ

devise-two-factorはgitlabも使用しているということなので、2要素認証の実装の参考にできるかと思います。

https://gitlab.com/gitlab-org/gitlab

おわりに

弊社、株式会社iCAREでは、企業が従業員の健康管理をするSaaSの商談をしていただける企業さまをご紹介いただける企業さまを募っております。

健康管理にお困りの人事の方のお知り合いがいらっしゃいましたら是非株式会社iCAREまでご連絡くださいませ。