こんにちは、YOUTRUSTのやまでぃ(YOUTRUST/X)です。
最近のわたくしごとですが
きんぴらごぼうにハマっています。しらたきも入れちゃいます。 ごぼうとにんじんを切って炒め、醤油酒みりん大さじ2と砂糖と出汁を少々とで10分くらい煮込めば完成です。 食べる前に冷ますことを忘れずに。火が入ることと味が染みることは別で、冷める過程で染みていくようです。 直近1ヶ月くらいで今まで人生で食べたごぼうの量を超えたと思います。
今回はなんの話?
Railsアプリケーションにログイン時に2要素認証を導入する方法について、具体的なコードと共に紹介します。
今回紹介する2要素認証について
今回は、認証の三大要素「記憶」「所有」「生体」のうちの、メールアドレスとパスワードによる「記憶」と、スマホのAuthenticatorによる「所有」の2要素で認証を行う例を紹介します。
実際にYOUTRUSTの本番環境で動作しているコードではないですが、多少簡略化しつつも実際のコードに近い内容となっています。雰囲気を掴んでもらえたらうれしいです。
具体的には下記のような機能になります。
- 2要素認証の設定(ログイン後の設定画面など)
- ログイン画面
- 2要素認証が設定されていない場合、
- メールアドレスとパスワードでログインできる。
- 2要素認証が設定されている場合、
- メールアドレスとパスワードの入力後、コード(6桁の数字)入力画面に遷移。
- 正しいコードを入力するとログインできる。
- 2要素認証が設定されているが、Authenticatorにアクセスできない場合、
- メールアドレスとパスワードの入力後、バックアップコード入力画面に遷移できる。
- 正しいバックアップコードを入力するとログインできる。(使用したコードは使えなくなる)
- 2要素認証が設定されていない場合、
コード紹介
それではここから大量のコードを例示しつつ、上記の実装方法について紹介していきます!
※大事なことなので改めて、本記事で紹介するRailsのアプリケーションコードは記事用の擬似的なコードです。弊社の実際のコードとは異なり簡略化されています。実際に本番環境のアプリケーション上に実装する際は、リリース前に脆弱性診断を受けることをおすすめします。(or OPEN CODEでは実際のコードを公開しているので是非参加してみてください)
※「UseCaseやCommandって何?」と思われた方は、こちらを参照してください。
- 使用gem
- 関連Models
- 関連ルーティング
- 関連Controllers(2要素認証の設定)
- 関連UseCases(2要素認証の設定)
- CreateTwoFactorAuthenticationUseCse
- (省略)ActivateTwoFactorAuthenticationUseCase
- (省略)DestroyTwoFactorAuthenticationUseCase
- (省略)GenerateTwoFactorAuthenticationBackupCodesUseCase
- 関連Commands(2要素認証の設定)
- CreateTwoFactorAuthenticationCommand
- ActivateTwoFactorAuthenticationCommand
- (省略)CreateTwoFactorAuthenticationCommand
- GenerateTwoFactorAuthenticationBackupCodesCommand
- 関連Controllers(ログイン)
使用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のイベントに参加してみてください!
もちろんエンジニア採用も行っています!
それではまた!