2要素認証ログインの実装について

こんにちは、YOUTRUSTのやまでぃ(YOUTRUST/X)です。

最近のわたくしごとですが

きんぴらごぼうにハマっています。しらたきも入れちゃいます。 ごぼうとにんじんを切って炒め、醤油酒みりん大さじ2と砂糖と出汁を少々とで10分くらい煮込めば完成です。 食べる前に冷ますことを忘れずに。火が入ることと味が染みることは別で、冷める過程で染みていくようです。 直近1ヶ月くらいで今まで人生で食べたごぼうの量を超えたと思います。

ごまふりかけてみました。

今回はなんの話?

Railsアプリケーションにログイン時に2要素認証を導入する方法について、具体的なコードと共に紹介します。

今回紹介する2要素認証について

今回は、認証の三大要素「記憶」「所有」「生体」のうちの、メールアドレスとパスワードによる「記憶」と、スマホのAuthenticatorによる「所有」の2要素で認証を行う例を紹介します。

実際にYOUTRUSTの本番環境で動作しているコードではないですが、多少簡略化しつつも実際のコードに近い内容となっています。雰囲気を掴んでもらえたらうれしいです。

具体的には下記のような機能になります。

  • 2要素認証の設定(ログイン後の設定画面など)
    • 2要素認証が設定されていない場合、
      • 設定開始ボタンを押下すると、QRコードとコード入力フォームが出現する。
      • QRコードスマホのAuthenticatorで読み取る。
      • Authenticatorに表示されている正しいコードをフォームに入力する。
      • 複数個のバックアップコードが表示されて設定が完了する。
    • 2要素認証が設定されている場合、
      • 解除することができる。(バックアップコードも無効になる)
  • ログイン画面
    • 2要素認証が設定されていない場合、
      • メールアドレスとパスワードでログインできる。
    • 2要素認証が設定されている場合、
      • メールアドレスとパスワードの入力後、コード(6桁の数字)入力画面に遷移。
      • 正しいコードを入力するとログインできる。
    • 2要素認証が設定されているが、Authenticatorにアクセスできない場合、
      • メールアドレスとパスワードの入力後、バックアップコード入力画面に遷移できる。
      • 正しいバックアップコードを入力するとログインできる。(使用したコードは使えなくなる)

コード紹介

それではここから大量のコードを例示しつつ、上記の実装方法について紹介していきます!

※大事なことなので改めて、本記事で紹介するRailsのアプリケーションコードは記事用の擬似的なコードです。弊社の実際のコードとは異なり簡略化されています。実際に本番環境のアプリケーション上に実装する際は、リリース前に脆弱性診断を受けることをおすすめします。(or OPEN CODEでは実際のコードを公開しているので是非参加してみてください)

※「UseCaseやCommandって何?」と思われた方は、こちらを参照してください。

使用gem

  • Devise…言わずと知れたRackベースの認証ライブラリ
  • Devise-Two-Factor...2要素認証のためのDevise拡張ライブラリ
  • RQRCode...文字列であるURLをQRコードの画像に変換してくれるライブラリ

関連Model

create_table "users" do |t|
  t.string "email", null: false # 実際は暗号化して保存した方が安全
  t.string "encrypted_password", null: false
end

class User < ApplicationRecord
  has_one :user_two_factor_authentication

  devise :database_authenticatable # メールアドレスとパスワード認証用

  def otp_required_for_login?
    user_two_factor_authentication&.otp_required_for_login? || false
  end
end

create_table "user_two_factor_authentications" do |t|
  t.integer "user_id", null: false, unsigned: true

  # QRコード発行時点では、内部的に本レコードがfalse状態で作成される。(サービス上はまだ未設定状態)
  # その後、正しいコード入力によりtrueになることで設定が完了とみなされる。
  t.boolean "otp_required_for_login", default: false, null: false

  t.string "otp_secret", limit: 510, null: false
  t.integer "consumed_timestep", unsigned: true
  t.text "otp_backup_codes": null: false
end

class UserTwoFactorAuthentication < ApplicationRecord
  belongs_to :user

  devise :two_factor_authenticatable, :two_factor_backupable # 2要素認証とバックアップコード用
  serialize :otp_backup_codes, JSON # 文字列の配列を、文字列として保存するため
end

関連ルーティング

  • 設定画面用
    • POST /api/two_factor_authentication
      • QRコードの発行
    • PUT /api/two_factor_authentication
      • 2要素認証の有効化
    • DELETE /api/two_factor_authentication
      • 2要素認証の解除
    • POST /api/two_factor_authentication_backup_codes
      • 有効化された2要素認証のバックアップコードを発行
  • ログイン画面用
    • POST /sign_in
      • メールアドレスとパスワードによるログイン
      • メールアドレスとパスワードが正しく、2要素認証が有効の場合は、2要素認証画面にリダイレクト
        • まだログイン状態にはなっていない
    • POST /sign_in_two_factor_authentication
      • 2要素認証のコードによるログイン
    • POST /sign_in_two_factor_authentication_backup_code
      • 2要素認証のバックアップコードによるログイン

関連Controllers(2要素認証の設定)

class Api::TwoFactorAuthentication < Api::ApplicationController
  before_action :authenticate_user!

  def create
    use_case = CreateTwoFactorAuthenticationUseCase.run(operation_user: current_user)

    if use_case.success?
      @qr_code = build_qr_code(user_two_factor_authentication: use_case.user_two_factor_authentication)
    else
      head :bad_request
    end
  end

  def update
    use_case = ActivateTwoFactorAuthenticationUseCase.run(
      operation_user: current_user,
      code: params[:code],
    )

    if use_case.success?
      head :ok
    else
      head :bad_request
    end
  end

  def destroy
    use_case = DestroyTwoFactorAuthenticationUseCase.run(operation_user: current_user)

    if use_case.success?
      head :ok
    else
      head :bad_request
    end
  end

  private

  def build_qr_code(user_two_factor_authentication)
    # see: https://github.com/devise-two-factor/devise-two-factor/blob/v5.0.0/lib/devise_two_factor/models/two_factor_authenticatable.rb#L67
    uri = user_database_authentication.otp_provisioning_uri('管理画面', issuer: 'YOUTRUST')

    # see: https://github.com/whomwah/rqrcode
    qr_code = RQRCode::QRCode.new(uri)
    qr_code.as_svg(use_path: true, viewbox: true)
  end
end

class Api::TwoFactorAuthenticationBackupCodesController < Api::ApplicationController
  before_action :authenticate_user!

  def create
    use_case = GenerateTwoFactorAuthenticationBackupCodesUseCase.run(operation_user: current_user)

    if use_case.success?
      @backup_codes = use_case.backup_codes
    else
      head :bad_request
    end
  end
end

関連UseCases(2要素認証の設定)

class CreateTwoFactorAuthenticationUseCase
  include UseCase::Base

  attr_reader :user_two_factor_authentication

  def initialize(operation_user:)
    @operation_user = operation_user
  end

  def run
    @operation_user.with_lock do
      command = CreateTwoFactorAuthenticationCommand.run(user: @operation_user)

      if command.success?
        @user_two_factor_authentication = command.user_two_factor_authentication
      else
        errors.add(:operation_user, :invalid)
        raise ActiveRecord::Rollback
      end
    end
  end
end

# 以下、同様なので省略
# ActivateTwoFactorAuthenticationUseCase
# DestroyTwoFactorAuthenticationUseCase
# GenerateTwoFactorAuthenticationBackupCodesUseCase

関連Commands(2要素認証の設定)

class CreateTwoFactorAuthenticationCommand
  include Command::Base

  attr_reader :user_two_factor_authentication

  validate :validate_user

  def initialize(user:)
    @user = user
  end

  def run
    @user_two_factor_authentication = find_or_initialize_two_factor_authentication
    @user_two_factor_authentication.otp_secret = generate_otp_secret
    @user_two_factor_authentication.otp_required_for_login = false
    @user_two_factor_authentication.otp_backup_codes = []
    @user_two_factor_authentication.save!
  end

  private

  def find_or_initialize_two_factor_authentication
    user.user_two_factor_authentication || user.build_user_two_factor_authentication
  end

  def generate_otp_secret
    # see: https://github.com/devise-two-factor/devise-two-factor/blob/v5.0.0/lib/devise_two_factor/models/two_factor_authenticatable.rb#L96
    UserTwoFactorAuthentication.generate_otp_secret
  end

  def validate_user
    # 2要素認証が有効化されていないこと
    errors.add(:user_two_factor_authentication, :invalid) if @user.otp_required_for_login?
  end
end

class ActivateTwoFactorAuthenticationCommand
  include Command::Base

  attr_reader :user

  validate :validate_user

  def initialize(user:, code:)
    @user = user
    @code = code
  end

  def run
    # see: https://github.com/devise-two-factor/devise-two-factor/blob/v5.0.0/lib/devise_two_factor/models/two_factor_authenticatable.rb#L36
    if user.user_two_factor_authentication.validate_and_consume_otp!(@code)
      user.user_two_factor_authentication.otp_required_for_login = true
      user.user_two_factor_authentication.save!
    else
      errors.add(:code, :invalid)
    end
  end

  private

  def validate_user
    # 無効状態の2要素認証の設定があること
    if user.user_two_factor_authentication.nil? || user.user_two_factor_authentication.otp_required_for_login?
      errors.add(:user, :invalid)
    end
  end
end
# DestroyTwoFactorAuthenticationCommandは省略

class GenerateTwoFactorAuthenticationBackupCodesCommand
  include Command::Base

  attr_reader :backup_codes

  validate :validate_user

  def initialize(user:)
    @user = user
  end

  def run
    # see: https://github.com/devise-two-factor/devise-two-factor/blob/v5.0.0/lib/devise_two_factor/models/two_factor_backupable.rb#L17
    @backup_codes = user.user_two_factor_authentication.generate_otp_backup_codes!
    user.user_two_factor_authentication.save!
  end

  private

  def validate_user
    # 2要素認証が有効化されていること
    errors.add(:user, :invalid) if !user.otp_required_for_login?
  end
end

関連Controllers(ログイン)

class SignInController < ApplicationController
  include OtpSessionMethods

  prepend_before_action :require_no_authentication, only: :create
  prepend_before_action :allow_params_authentication!, only: :create

  def create
    # see: https://github.com/heartcombo/devise/blob/main/lib/devise/controllers/helpers.rb#L113
    user = warden.authenticate(:database_authenticatable, scope: :user)

    if !user
      # ログイン失敗(不正なメールアドレス or パスワード)
      flash[:alert] = t('devise.failure.invalid')
      redirect_to sign_in_path
    elsif user.otp_required_for_login?
      # 2要素認証のコード入力画面へ
      sign_out :user

      # [大事] メールアドレスとパスワードの認証は完了していることをセッションで保持
      update_session(user: user)

      redirect_to sign_in_two_factor_authentication_path(state: session_otp_state)
    else
      # ログイン成功
      sign_in user
      redirect_to after_sign_in_path_for
    end
  end
end

class SignInTwoFactorAuthenticationController < ApplicationController
  include OtpSessionMethods

  prepend_before_action :require_no_authentication, only: :create
  prepend_before_action :allow_params_authentication!, only: :create
  before_action :redirect_if_invalid_attempt, only: :create
  before_action :redirect_if_invalid_otp_session, only: :create

  def create
    otp_user_id = delete_session_otp_user_id
    delete_session_otp_user_id_expires_at
    delete_session_otp_state

    user = User.find(otp_user_id)

    # see: https://github.com/devise-two-factor/devise-two-factor/blob/v5.0.0/lib/devise_two_factor/models/two_factor_authenticatable.rb#L36
    if user.user_two_factor_authentication.validate_and_consume_otp!(params[:code])
      # コードによるログイン成功
      sign_in user
      redirect_to after_sign_in_path_for
    else
      flash[:alert] = t('devise.failure.invalid')
      redirect_to sign_in_path
    end
  end

  private

  def redirect_if_invalid_attempt
    return if params[:code].present?

    flash[:alert] = t('devise.failure.invalid_otp_attempt')
    redirect_to sign_in_path
  end
end

class SignInTwoFactorAuthenticationBackupCodeController < ApplicationController
  include OtpSessionMethods

  prepend_before_action :require_no_authentication, only: :create
  prepend_before_action :allow_params_authentication!, only: :create
  before_action :redirect_if_invalid_attempt, only: :create
  before_action :redirect_if_invalid_otp_session, only: :create

  def create
    otp_user_id = delete_session_otp_user_id
    delete_session_otp_user_id_expires_at
    delete_session_otp_state

    user = User.find(otp_user_id)

    # see: https://github.com/devise-two-factor/devise-two-factor/blob/v5.0.0/lib/devise_two_factor/models/two_factor_backupable.rb#L34
    if user.user_two_factor_authentication.invalidate_otp_backup_code!(params[:backup_code])
      user.save!

      # コードによるログイン成功
      sign_in user
      redirect_to after_sign_in_path_for
    else
      flash[:alert] = t('devise.failure.invalid')
      redirect_to sign_in_path
    end
  end

  private

  def redirect_if_invalid_attempt
    return if params[:backup_code].present?

    flash[:alert] = t('devise.failure.invalid_otp_attempt')
    redirect_to sign_in_path
  end 
end
module OtpSessionMethods
  extend ActiveSupport::Concern

  included do
    def redirect_if_invalid_otp_session
      # 必要なセッション値が存在しない場合は、ログイン画面へリダイレクト
      if session_otp_user_id.nil? || session_otp_user_id_expires_at.nil? || session_otp_state.nil?
        flash[:alert] = t('devise.failure.invalid_otp_attempt')
        return redirect_to sign_in_path
      end

      # セッションのstateが不正な場合は、ログイン画面へリダイレクト
      if session_otp_state != params[:state]
        flash[:alert] = t('devise.failure.invalid_otp_attempt')
        return redirect_to sign_in_path
      end

      # セッションが期限切れになっていた場合は、ログイン画面へリダイレクト
      if Time.zone.at(session_otp_user_id_expires_at.to_i <= current_time
        flash[:alert] = t('devise.failure.timeout_otp_session')
        return redirect_to sign_in_path
      end
    end

    def update_session(user:)
      session[:otp_user_id] = user.id
      session[:otp_user_id_expires_at] = 3.minutes.since.to_i # 3分間
      session[:otp_state] = SecureRandom.hex(32)
    end

    def delete_session_otp_user_id
      session.delete(:otp_user_id)
    end

    def delete_session_otp_user_id_expires_at
      session.delete(:otp_user_id_expires_at]
    end

    def delete_session_otp_state
      session.delete(:otp_state)
    end

    def session_otp_user_id
      session[:otp_user_id]
    end

    def session_otp_user_id_expires_at
      session[:otp_user_id_expires_at]
    end

    def session_otp_state
      session[:otp_state]
    end
  end
end

終わりだよ

今回の記事はいかがだったでしょうか? 結構な量のコードを貼り付ける結果となってしまい、文字数が15,000+である大長編となってしまいました。

繰り返しになりますが、本記事内のコードは擬似コードとなっており、実際にYOUTRUSTで使用されているコードではありません。

もし実際のコードが見たい場合は、是非OPEN CODEのイベントに参加してみてください!

もちろんエンジニア採用も行っています

それではまた!