Deviseを使っているRailsアプリに2段階認証を導入する

  • 30
    いいね
  • 1
    コメント
この記事は最終更新日から1年以上が経過しています。

Deviseを使っているRailsアプリに2段階認証を入れる機会があった。
調べてみると、devise-two-factorがよく使われているようだ。
GitLabもこのgemを組み込んでいる。

このgem、嬉しいことにdemoアプリまで用意してくれている。
ただし、ちょっと一般的な挙動とは違う…。
おそらく機能を紹介するために一番シンプルな構成にしているのだとは思うけど、
このまま導入するのはちょっと厳しい。

  • ID/PASS/認証コードを一度に入力するようになっていたり、
  • 2段階認証有効化ボタンを押すと問答無用で有効化されたり。

そこで、Deviseを使ったRailsアプリに一般的な挙動の2段階認証を導入する過程を書いていく。
実装にあたっては、GitLabのコードを参考にした。

目指すところ

ID/PASSと認証コードの入力を2段階に

「ID/PASSを入力 -> 認証コードを入力」の2段階にしたい
(画像はGitLabの例)
スクリーンショット 2015-12-15 11.42.22.png

認証コードを確認した上で2段階認証を有効に

正しい認証コードが入力されたのを確認した上で2段階認証を有効にするようにしたい
スクリーンショット_2015-12-15_11_37_53.png

リカバリコードの機能を入れる

認証コードがわからなくなった時のために、「一度だけ使えるリカバリ用のコード」x10個を発行する。
認証コードの代わりにこのリカバリコードを入れることでログインできる。
ただし、一度使用したリカバリコードは以後使用できなくなる。
スクリーンショット_2015-12-15_11_38_28.png

実装

説明を簡単にするために、複数のメソッドをまとめて一つの大きなメソッドにしているところがある。
GitHubに上げたサンプルとはちょっと違っていたりするのでご注意。

Deviseの設定

Deviseの導入については、既に情報がたくさんあるので詳細は割愛。
ViewとControllerをカスタマイズできるようにしていない場合は、以下の処理を行なっておく。

view

$ bundle exec rails g devise:views

controller

$ bundle exec rails g devise:controllers users
config/routes.rb
  devise_for :users, controllers: {
    sessions: 'users/sessions'
  }

devise-two-factorの導入

gemインストール

ついでにQRコード生成のgemも一緒に入れてしまう。

gem 'devise-two-factor'
gem 'rqrcode-rails3'

設定

まずは次のコマンドを実行する。

$ bundle exec rails generate devise_two_factor MODEL ENVIRONMENT_VARIABLE
  • MODEL: 認証対象のモデル(今回はuser)
  • ENVIRONMENT_VARIABLE: 暗号化キーを入れる環境変数名(今回はENCRYPTION_KEY)

実行すると自動で次の処理が行われる

  • app/models/MODEL.rbのdeviseディレクティブ調整
    • :database_authenticatableを削除しようとするが、書き方によっては削除されない場合があるので、実行後要確認。削除されていなかったら手動で削除。
  • deviseの設定ファイルを調整
    • wardenの設定
  • MODELに2段階認証関連のカラムを追加するためのマイグレーションファイルを生成

次に、application_controller.rbに以下を追加
sign_inアクションの際に、StrongParametersでotp_attempt(認証コード)も許可されるようにしている。

application_controller.rb
before_action :configure_permitted_parameters, if: :devise_controller?

...

protected

def configure_permitted_parameters
  devise_parameter_sanitizer.for(:sign_in) << :otp_attempt
end

また、config.sign_in_after_reset_passwordを有効にしている場合は、パスワードリセット時に2段階認証をスキップしてログインできてしまうらしいので、無効にする。

それから、今回は簡単のために以下のように暗号化キーを直書きしている。
実際に導入する際はちゃんと環境変数を使ってね。

app/models/user.rb
  devise :two_factor_authenticatable,
         # :otp_secret_encryption_key => ENV['ENCRYPTION_KEY']
         :otp_secret_encryption_key => "encryption_key"

マイグレーションも忘れずに行えば、ひとまず導入は完了。

ログインを2段階に

ID/PASS入力画面 -> 認証コード入力画面の2段階にする。

ID/PASS入力画面から認証コード入力画面に遷移する際に、
ログインしようとしているユーザーの情報を引き継ぐ必要がある。
そのため、ユーザーIDをセッションに保持しておく作戦をとる。

Controller作成

app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
  # ID/PASSの認証も、認証コードの認証も :create アクションでやってくる
  # Deviseの通常認証処理が行われる前に処理を挟む
  prepend_before_action :authenticate_with_two_factor, only: [:create]

  private
  def authenticate_with_two_factor
    # strong parameters
    user_params = params.require(:user).permit(:email, :password, :remember_me, :otp_attempt)

    # ID/PASSの認証時はemailからユーザーを取得
    if user_params[:email]
      user = User.find_by(email: user_params[:email])

    # 認証コードの認証時はセッションに保存されたIDからユーザーを取得
    elsif user_params[:otp_attempt].present? && session[:otp_user_id]
      user = User.find(session[:otp_user_id])
    end
    self.resource = user

    # 2段階認証が有効なユーザーでなければ、returnして通常のDeviseの認証処理に渡す
    return unless user && user.otp_required_for_login

    # ID/PASSの認証時
    if user_params[:email]
      # パスワードを確認
      if user.valid_password?(user_params[:password])
        # ユーザーIDをセッションに保存して、認証コード入力画面をレンダリング
        session[:otp_user_id] = user.id
        render 'devise/sessions/two_factor' and return
      end
      # パスワードが違っていた場合は通常のDevise認証処理に渡す

    # 認証コードの認証時
    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
  end
end

認証コード入力のviewを作成

app/views/devise/sessions/two_factor.html.erb
<h2>Log in</h2>

<%= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| %>
  <div class="field">
    <%= f.label :otp_attempt %><br />
    <%= f.text_field :otp_attempt %>
  </div>

  <div class="actions">
    <%= f.submit "Verify" %>
  </div>
<% end %>

認証コードを確認した上で2段階認証を有効に

routes設定

今回はTwoFactorAuthsControllerで処理を行なう。

  • new : 2段階認証有効化確認
  • create : 2段階認証有効化
  • destroy : 2段階認証無効化
config/routes.rb
  resource :two_factor_auth, only: [:new, :create, :destroy]

Controller作成

app/controllers/two_factor_auths_controller.rb
class TwoFactorAuthsController < ApplicationController

  # 2段階認証有効化確認
  def new
    unless current_user.otp_secret
      # この時点で認証コードのシークレットキーを生成して保存しておく
      current_user.otp_secret = User.generate_otp_secret(32)
      current_user.save!
    end

    @qr_code = build_qr_code
  end

  # 2段階認証有効化
  def create
    # 認証コードが合っているか確認
    if current_user.validate_and_consume_otp!(params[:otp_attempt])
      # 2段階認証有効化フラグを立てる
      current_user.otp_required_for_login = true
      current_user.save!

      redirect_to root_path

    # 認証コードが合っていなければもう一度確認画面をレンダリング
    else
      @error = 'Invalid pin code'
      @qr_code = build_qr_code

      render 'new'
    end
  end

  # 2段階認証無効化
  def destroy
    current_user.update_attributes(
      otp_required_for_login:    false,
      encrypted_otp_secret:      nil,
      encrypted_otp_secret_iv:   nil,
      encrypted_otp_secret_salt: nil,
    )
    redirect_to root_path
  end

  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
end

確認用のViewを作成

QRコードを表示するついでに、
GitLabと同じようにQRコードが読めなかったとき用のキーも表示してみる。

app/views/two_factor_auths/new.html.erb
<h1>Enable 2FA</h1>

<% if @error %>
  <div><%= @error %></div>
<% end %>

<div><%= raw @qr_code %></div>

<div>
  <p>Account : <%= current_user.email %></p>
  <p>Key : <%= current_user.otp_secret.scan(/.{4}/).join(' ') %></p>
  <p>Time Based : Yes</p>
</div>

<%= form_tag two_factor_auth_path do %>
  <div class="field">
    <%= label_tag :otp_attempt %><br />
    <%= text_field_tag :otp_attempt %>
  </div>

  <%= submit_tag :submit %>
<% end %>

有効化/無効化のViewを作成

有効化/無効化ボタンを設置する箇所に。

app/views/home/index.html.erb
<div>
  <% if current_user.otp_required_for_login %>
    <%= button_to "Disable 2FA", two_factor_auth_path, method: :delete %>
  <% else %>
    <%= button_to "Enable 2FA", new_two_factor_auth_path, method: :get %>
  <% end %>
</div>

リカバリコードの機能を入れる

usersテーブルにリカバリコード保存用のカラムを追加

add_otp_backup_codes_to_users.rb
class AddOtpBackupCodesToUsers < ActiveRecord::Migration
  def change
    add_column :users, :otp_backup_codes, :text
  end
end

Userモデルにリカバリコード有効設定

リカバリコードを10個作成。

app/models/user.rb
  devise :two_factor_backupable, otp_number_of_backup_codes: 10
  serialize :otp_backup_codes, JSON

2段階認証有効化時にリカバリコード作成 / リカバリコード表示

app/controllers/two_factor_auths_controller.rb
  def create
    if current_user.validate_and_consume_otp!(params[:otp_attempt])
      current_user.otp_required_for_login = true
      @codes = current_user.generate_otp_backup_codes! # これを追加
      current_user.save!

      # redirect_to root_path  # これを消して、
      render 'codes'           # これを追加

      ...

リカバリコード表示用Viewを作成

app/views/two_factor_auths/code.html.erb
<h1>Recovery Codes</h1>

<ul>
  <% @codes.each do |code| %>
    <li><%= code %></li>
  <% end %>
</ul>

<%= link_to 'Proceed', root_path %>

ログイン時に認証コードの代わりにリカバリコードを入れても通るように

app/controllers/users/sessions_controller.rb
   def valid_otp_attempt?(user)
    user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
    user.invalidate_otp_backup_code!(user_params[:otp_attempt]) # この条件を追加
   end

invalidate_otp_backup_code!は、リカバリコードが合っていればtrueを返しつつ、
そのリカバリコードをotp_backup_codesカラムから削除する。

その他

QRコードではなく、SMSやメールで認証コードをお知らせする場合は、current_user.current_otpで認証コードを取得できる。
ただ、そのままだと30秒おきに認証コードが変わって厳しいので、User.otp_allowed_driftに認証コードの有効期間を長めに設定する(秒数を設定)。

ちなみに、User.otp_allowed_driftに180とか設定しても、認証コード自体は変わらず30秒おきに変化する。変わるのは、認証コードのチェック範囲。180だと3分前の認証コードまで1つずつチェックするようになる。

サンプル

今回作成したサンプルアプリはこちら。
https://github.com/Kta-M/mfa_sample