Deviseにおける認証ロジックの実装

TL;DR (概要)

こんにちは、スタメンエンジニアの井本です。普段はRuby on RailsやAWSなどサーバーサイド寄りの技術を用いて開発しています。最近はフィーチャーチーム体制に切り替わったこともあり、React入門中です。

さて今年の1月下旬、弊サービスTUNAGにて、新機能として「2要素認証」をリリースしました。 TUNAGバックエンドはRuby on Railsを用いて開発されていることもあり、認証機能はDeviseを用いて実装しています。 今回は元々のパスワード認証に加えて、OTP(One Time Password)認証をあわせて行うことで、2要素認証としました。 新しく追加するOTP認証をDeviseの設計に沿った形で実装しました。

本記事では、Deviseの実装の解説と、実装に則った新しい認証方法の追加について、その他Tips含めてお話しします。

※Deviseで認証するモデルがUserモデルである前提で進めます。

Deviseにおける認証ロジックの実装

DeviseはRackの認証ミドルウェアであるWarden上に実装されています。Wardenは認証ロジックをストラテジーパターンで置換可能なものとしており、Deviseはパスワード認証をWardenのストラテジーとして実装しています。またRememberableを有効化した場合のRememberトークン認証についてもストラテジーとして実装されてます。

2要素認証実装においては、新たなストラテジーとしてOTP認証を実装しました。

認証処理の流れ

まずはDevise認証処理の流れについて解説します。

コントローラにてwarden.authenticate!のように、Warden::Proxy#authenticate!を呼ぶことで認証処理が実行されます。deviseで用意されている認証用のアクションであるsessions_controller#createでも呼ばれていますね。

Warden::Proxy#authenticate!は次のような順番で処理を実行します。

1. 実行する認証ストラテジー名をシンボル配列で取得します。

2.それぞれのストラテジー名からストラテジークラスを取得し、取得したクラスに対してauthenticate!※メソッドを呼び出します  ※Warden::Proxy#authenticate!ではなく、ストラテジークラスのauthenticate!メソッドです。

  strategies.each do |name|
    strategy = _fetch_strategy(name)
    strategy.authenticate! 
  end

3. ストラテジーの認証が成功すると、対象のユーザーをsessionに格納し、ユーザーオブジェクトを返します。

4. ストラテジーの認証が成功しなければ、sessionの格納はされず、ユーザーも返しません。

ここで実行されるストラテジーは、あらじめdefault_strategiesとして設定されたものです。Deviseの場合は、パスワード認証用のdatabase_authenticatableストラテジーがdefault_strategiesとして、設定されています。加えてRememberableを有効にしている場合は、Rememberトークン認証用のrememberableストラテジーもdefault_strategiesに設定されます。

puts strategies # => [:rememberable, :database_authenticatable]

特定のストラテジーを呼び出したい場合は、引数にストラテジー名を渡します。

warden.authenticate!(:custom_strategy, :custom_strategy2, ...)

カスタムストラテジーの実装

カスタムストラテジーを実装はauthenticate!メソッドを定義する必要があります。定義したauthenticate!メソッドの中に認証ロジックを実装します。また先程の説明では省略しましたが、authenticate!を実行するか判定を行うためのvalid?メソッドを実装する必要があります。

strategies.each do |name|
  # ...
  next unless strategy.valid?
  strategy.authenticate!
end

valid?メソッドをうまく記述することで、「特定の条件だけ、ある認証ストラテジーを無効化する」といった実装ができるようになります。

さて、以上よりカスタムストラテジーは次のような実装となります。

module Devise
  module Strategies
    class OtpAuthenticatable < Authenticatable
      def valid?
        # ...
      end
      
      def authenticate!
        resource = TwoFactorAuthentication.get_user(params[:email])

        if validate(resource) { OtpAuthentication.valid?(user, params[:otp]) }
          remember_me(resource)
          success!(resource)
        else
          fail!(:invalid_otp)
        end
      end
    end
  end
end

OTP認証の実装例です。 実際にはパスワードとセットで認証しなければいけないので、もう少し複雑な実装ですが、今回は省略しています。

以下、補足です。 認証ロジック自体はvalidateメソッドに渡しているブロック内に記述します。validateはDeviseで用意されたメソッドで、すべての認証ストラテジーにおいて共通の処理が実行されます。例えば、DeviseのLockableモジュール(※)を有効化した場合の、ログイン試行回数のカウントやリセットなどの処理は、validateに実装されています。

※ログイン試行回数が一定数を超えると該当ユーザーの認証をロックする機能

success!fail!は認証結果をインスタンス変数に格納するメソッドで、認証ストラテジーの呼び出し元で結果を確認するために呼び出すメソッドです。fail!の引数にはエラー種別を渡します。エラー種別に基づいて、エラーメッセージ等の制御を行います。

次にカスタムストラテジーを呼び出す処理の2パターンについて、それぞれ補足します。

カスタムストラテジーの呼び出し

default_strategiesとして呼び出す

default_strategiesを追加するパターンです。設定はconfig/initializer配下のdevise.rbに書きます。

  config.warden do |manager|
    manager.strategies.add(:custom_database_authenticatable, Devise::Strategies::CustomDatabaseAuthenticatable)

    # deviseのパスワード認証strategyを上書き
    manager.default_strategies(scope: :user).delete :database_authenticatable
    manager.default_strategies(scope: :user).push :custom_database_authenticatable
  end

strategies.addでストラテジー名とクラスの関係をマッピングをします。default_strategiesを追加する場合はmanager.default_strategies(scope: :user).pushで追加します。default_strategiesのリストは配列で保持されており、manager.default_strategies(scope: :user)default_strategiesの配列の参照を返すため、shiftdeleteなどによる変更も可能です。

ストラテジー名を指定して呼び出す

warden.authenticate!(:custom_strategy)

これだけで呼び出すことができますが、default_startegiesと異なる情報を用いた認証を行う場合、呼び出し元のコントローラのどこかでdefault_strategiesが呼び出しされていないか気をつけてください。

具体的には、次のような処理が想定されます。

  1. before_action: :authenticate_user!をしている。
  2. 同じくbefore_actionで、current_userの返り値を元に判定処理を行っている。

1つ目のケース、authenticate_user!メソッド内ではWarden::Proxy#authenticate!が呼ばれます。この時、Deviseの標準だとdefault_strategiesとしてパスワード認証が実行されます。しかし、カスタムストラテジー用の認証情報しかないために認証が失敗します。authenticate!メソッドは認証が失敗するとカスタム認証が実行される前に、ログイン画面にリダイレクトされてしまいます。skip_before_actionや、デフォルトストラテジーのvalid?メソッドを上書きして実行を制御するなど、対策が必要です。

2つ目のケース、current_userにおいては内部でWarden::Proxy#authenticateが呼ばれます。Warden::Proxy#authenticate!と異なりリダイレクトこそ発生しませんが、返り値としてnilが返ることを念頭に実装する必要があります。

なお、認証の成功以降は、Wardenで認証ユーザーが保持されるため、デフォルトストラテジー認証周りのケアは特に必要ありません。また、普通のチーム開発だと上記のようなコードは割と気軽に書かれると思いますので、テストも書いた方が良いでしょう。

以上、カスタムストラテジーを定義し、呼び出すための実装を紹介しました。

その他 Tips

ここからは2要素認証の開発において、ストラテジーの追加以外に変更を加えたDeviseの機能について、Tipsをご紹介します。

FailureApp(エラーハンドリング用のクラス)

FailureAppクラスはエラー時のHTTPレスポンスを生成するクラスです。 カスタムFailureAppクラスを作成して、respondメソッドの返り値を変更することで、エラー時の表示画面やリダイレクト先を変更できます。また、カスタムFailureAppクラスを使用する場合はdevise.rbに設定が必要です。

レスポンスの形式によって、redirectしたりやjsonレスポンスを返すなどの挙動をします。

respondの分岐 レスポンス内容 相当するコントローラ上の処理の例
http_auth jsonやxml形式 render :jsonなど
recall htmlファイル render :newなど
redirect 302レスポンスする redirect_to xxx_path

recallwarden.authenticate!(recall: "CustomAuthController#new")のように引数を渡すことでrender :newのような動きを実装可能です。エラー種別による分岐をする場合は、warden_messageメソッドで、認証ストラテジーでfail!したときに引数として渡した値を取り出して利用することができます。

また、FailureAppは、throw(:warden)catchして呼び出されます。Warden::Proxy#authenticate!でも認証が失敗して、user変数がnilのときにthrow(:warden)しています。

def authenticate!(*args)
  user, opts = _perform_authentication(*args)
  throw(:warden, opts) unless user
  user
end

hook

Deviseではlib/devise/hooks配下に認証後・ユーザーセット後・ログイン後など、のコールバック処理が定義されています。こちらもstrategyと同様にカスタムhookを作成することができます。

config/initializer/devise.rbにて、Warden::Managerクラスのコールバックメソッドを呼び出し、ブロック内に実行したい処理を書きます。

Warden::Manager.after_set_user do |user, warden, options|
    # hook処理
end

要件に合うコールバックメソッドをお探しの場合はこちらを参照ください。

model

lib/devise/models配下にあるファイル群で、modelsと書いていますがユーザーなど認証アカウントクラスでincludeされるモジュールです。

上書きしたい場合はユーザークラスに同名のメソッドでオーバーライドする形が良いでしょう。deviseのメソッドのうちいくつかは、オーバーライドしてカスタマイズされる前提で処理がかかれています。ちょっとした追加実装であれば、わざわざ新しいストラテジーを定義するまでもなく、実装できることも多いです。

参考

Deviseはgithubのwikiが充実しているので、参考にご覧ください。

heartcombo/devise Wiki - Home

メソッドをオーバーライドする以外にも、devise.rbで設定変更できるもの、コントローラでrequest.env['hoge']に格納された設定値を書き換えられるもの、等があります。

まとめ

以上が、Deviseの実装の解説と、実装に則った新しい認証方法の追加でした。その他Tipsについてもご参考いただけると幸いです。

今回の実装は、お客様の大切な情報をお守りするため責任重大な機能ということで私自身、非常にやりがいを感じながら設計からテスト、リリースまで進めることができました。 弊サービスのTUNAGでは、お客様である企業の成長にエンゲージメントという側面から貢献し、企業様の社員の皆様がより充実した会社生活を送るために、まだまだ開発すべきことがたくさんあります。 エンジニアとして、やりがいのある仕事がしたい!わくわくする仕事をしたい!という方がいらっしゃいましたらぜひ、弊社採用窓口までご連絡ください。

TUNAGの開発体制、使用技術については下記ブログ記事を参照ください。

TUNAGの技術と開発体制のすべてを紹介します!

また株式会社スタメンでは、オンラインサロン用プラットホームFANTSというサービスも開発・運営しております。 ご関心がある方はぜひ下記のリンクをご覧ください。

FANTSの開発技術・開発組織を紹介します!