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

BLOG

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

2021/08/31
0

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

今回は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

0