2要素認証を実現するGem「devise-two-factor」の内部挙動を追ってみた
こんにちは!サーバーサイドエンジニアの越川です!
今回は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までご連絡くださいませ。