Deviseを使っているRailsアプリに2段階認証を入れる機会があった。
調べてみると、devise-two-factorがよく使われているようだ。
GitLabもこのgemを組み込んでいる。
このgem、嬉しいことにdemoアプリまで用意してくれている。
ただし、ちょっと一般的な挙動とは違う…。
おそらく機能を紹介するために一番シンプルな構成にしているのだとは思うけど、
このまま導入するのはちょっと厳しい。
- ID/PASS/認証コードを一度に入力するようになっていたり、
- 2段階認証有効化ボタンを押すと問答無用で有効化されたり。
そこで、Deviseを使ったRailsアプリに一般的な挙動の2段階認証を導入する過程を書いていく。
実装にあたっては、GitLabのコードを参考にした。
目指すところ
ID/PASSと認証コードの入力を2段階に
「ID/PASSを入力 -> 認証コードを入力」の2段階にしたい
(画像はGitLabの例)
認証コードを確認した上で2段階認証を有効に
正しい認証コードが入力されたのを確認した上で2段階認証を有効にするようにしたい
リカバリコードの機能を入れる
認証コードがわからなくなった時のために、「一度だけ使えるリカバリ用のコード」x10個を発行する。
認証コードの代わりにこのリカバリコードを入れることでログインできる。
ただし、一度使用したリカバリコードは以後使用できなくなる。
実装
説明を簡単にするために、複数のメソッドをまとめて一つの大きなメソッドにしているところがある。
GitHubに上げたサンプルとはちょっと違っていたりするのでご注意。
Deviseの設定
Deviseの導入については、既に情報がたくさんあるので詳細は割愛。
ViewとControllerをカスタマイズできるようにしていない場合は、以下の処理を行なっておく。
view
$ bundle exec rails g devise:views
controller
$ bundle exec rails g devise:controllers users
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
(認証コード)も許可されるようにしている。
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段階認証をスキップしてログインできてしまうらしいので、無効にする。
それから、今回は簡単のために以下のように暗号化キーを直書きしている。
実際に導入する際はちゃんと環境変数を使ってね。
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作成
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を作成
<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段階認証無効化
resource :two_factor_auth, only: [:new, :create, :destroy]
Controller作成
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(
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コードが読めなかったとき用のキーも表示してみる。
<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を作成
有効化/無効化ボタンを設置する箇所に。
<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テーブルにリカバリコード保存用のカラムを追加
class AddOtpBackupCodesToUsers < ActiveRecord::Migration
def change
add_column :users, :otp_backup_codes, :text
end
end
Userモデルにリカバリコード有効設定
リカバリコードを10個作成。
devise :two_factor_backupable, otp_number_of_backup_codes: 10
serialize :otp_backup_codes, JSON
2段階認証有効化時にリカバリコード作成 / リカバリコード表示
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を作成
<h1>Recovery Codes</h1>
<ul>
<% @codes.each do |code| %>
<li><%= code %></li>
<% end %>
</ul>
<%= link_to 'Proceed', root_path %>
ログイン時に認証コードの代わりにリカバリコードを入れても通るように
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