Rails/Deviseを利用した認証を Amazon Cognito 認証に委譲する

スタメン エンジニアの松谷(@uuushiro)です。Railsアプリケーションにおいて認証機能にDeviseが利用されるケースは多いと思いますが、サービスの特性次第で メールアドレスをIDとした認証だけでなく、携帯電話番号をIDとした SMS認証、外部ソーシャルID連携やSAML認証、MFA設定など多様な認証機能に対応する必要があります。その際にDeviseにモジュールを追加したりカスタマイズするなど、アプリケーション側で対応することも1つの手ですが、クリティカルな作業で工数がかかる割にはサービスの本質的な競争力の向上には繋がることは少ないです。できれば自前で実装するのは避けたいなと考えていたときに、Amazon Cognito というアプリケーションで必要とする一連の認証機能を提供してくれるサービスを思い出しました。Deviseを利用した認証を Amazon Cognitoに委譲することで、実装コストを抑えることができそうだと思い調査してみました。

TL;DR (概要)

Deviseを利用したユーザー認証を、Amazon Cognito に委譲しました。主にRailsアプリケーション側で対応した内容と、Amazon Cognito側で対応した内容の一部を共有します。

この記事では、 既にDeviseが導入されているアプリケーションの認証機能をAmazon Cognitoに委譲する想定とします。認証以外の機能(ヘルパー・ルーティング・コントローラー・セッション管理...etc)は引き続きDeviseを利用します。

認証フローは、RFC 6749: 4.1. Authorization Code Grantで定義されている認可コードフローを採用しています。ここではOpen ID Connectに関する仕様の詳細は説明しません。必要な認証情報をRailsアプリケーションに設定をしている前提で説明をします。 認証の流れとしては、クライアントが Amazon Cognito に対して認証をし、その認証結果(認可コード)をRailsアプリケーションで受け取り、認可コードをIDトークンと交換して、IDトークンの検証に問題が無ければWebサーバーがクライアントに対してCookie(SessionID)を発行し、そのSession情報をサーバーに保存し、ログインします。

Railsアプリケーション側の対応

今回、Amazon Cognito に委譲する Deviseの機能は以下3点です。

  • サインアップ・サインイン機能(database_authenticatable, :omniauthable, :registerable のモジュールが提供)
  • ユーザー確認機能(:confirmableモジュールが提供)
  • パスワードリセット機能(:recoverableモジュールが提供)

Deviseには、独自の認証ストラテジーを追加できる機構があるので、それを利用してこれらの機能をAmazon Cognitoに移譲します。上記以外の、ルーティングやコントローラー、そしてセッション管理まわりの機能は引き続き、Deviseを利用します。

以下、UserモデルをDeviseで扱う場合のコード例です。

認証ストラテジークラスの作成

lib/utils/devise/strategies/cognito_authenticatable.rbファイルを作成し、Amazon Cognito の認証結果を受け取りアプリケーション側で検証・認証するストラテジーを作成します。ストラテジークラスでは、authenticate! メソッドに認証ロジックを書き、valid?メソッドに認証をする条件を書きます。

valid?メソッドには、パラメータに認可コード(params['code'])が存在する場合 かつ 後述するstate値がセッションに記録されている値と一致すること を必須としています。 authenticate!メソッドには、認可コードを元に、認証情報が正当であるかどうかをチェックして、問題がなければWebサーバーがクライアントに対してCookie(SessionID)を発行し、そのSession情報をサーバーに保存するという、Deviseによる認証後のフローと同じセッション管理をしています。

# lib/utils/devise/strategies/cognito_authenticatable.rb

require 'devise/strategies/authenticatable'

module Devise
  module Strategies
    class CognitoAuthenticatable < Authenticatable
      def valid?
        # stateが一致(CSRF攻撃対策) && code が存在する の場合のみ認証処理に入る
        params['code'].present? && params['state'] == stored_state
      end

      def authenticate!
        # OpenIdConnect::UserAuthorizationは 認証情報を管理するクラス
        # OpenIdConnect::UserAuthorization.new.verify! の部分で適宜IDトークンの検証をする
        id_token = OpenIdConnect::UserAuthorization.new.verify!(params['code'], stored_nonce)
        sub = id_token.raw_attributes[:sub]
        user = User.active.find_by(sub: sub)
        
        success!(user)
      rescue StandardError => e
        Rails.logger.debug(e.message)
        fail!
      end
    end
  end
end

モジュールの作成

Strategyに対応するDeviseのモジュールを作成し、対応するコントローラー・ルーティングを設定し、Deviseのモジュールに追加します。ここでは、Deviseがデフォルトで指定する、sessions_controller をAmazon Cognitoによる認証機能に対応させています。

# frozen_string_literal: true

require 'utils/devise/strategies/cognito_authenticatable'

module Devise
  module Models
    module CognitoAuthenticatable
    end
  end
end

routes = [nil, :new, :destroy]
Devise.add_module :cognito_authenticatable, controller: :sessions, route: { session: routes }

コントローラーの修正

モデルの作成で、対応付けをした sessions_controllerを修正します。SessionsControllerのnewアクションがログイン画面に相当するため、Amazon Cognitoがホストしている認証画面にリダイレクトするようにします。 また、CSRF攻撃を防ぐためのstate値とリプレイ攻撃を防ぐためのnonce値をセッションに保存して、後に認証結果が渡ってきたときの検証に利用できるようにしています。

class Users::SessionsController < Devise::SessionsController

  def new
    redirect_to authorization_uri
  end

  private

  def authorization_uri
    # OpenIdConnect::UserAuthorizationは 認証情報を管理するクラス
    OpenIdConnect::UserAuthorization.new.authorization_uri(set_state, set_nonce)
  end

  # CSRF 攻撃対策
  def set_state
    session[:state] = SecureRandom.hex(16)
  end

  # リプレイ攻撃対策
  def set_nonce
    session[:nonce] = SecureRandom.hex(16)
  end
end

state値とnonce値の説明については、以下の記事がわかりやすかったので必要があればご参照ください。 https://tech-lab.sios.jp/archives/8492

Deviseの設定

上の「認証ストラテジーの作成」で作成したStrategyをwardenに追加します。 また、デフォルトで使用する認証ストラテジーをuserスコープに限定して宣言をします。

# config/initializers/devise.rb
require 'utils/devise/strategies/cognito_authenticatable'
config.warden do |manager|
  manager.strategies.add(:cognito_authenticatable, Devise::Strategies::CognitoAuthenticatable)
  manager.default_strategies(scope: :user).unshift :cognito_authenticatable
end

Userモデルも、追加したモジュールを利用するように変更します。 変更前は、Deviseのサインアップ・サインイン機能(database_authenticatable)、ユーザー確認機能(confirmable)、パスワードリセット機能(recoverable)も設定してありましたが、今後はAmazon Cognitoに移譲するためコメントアウト(削除)し、cognito_authenticatableのみにしています。

class User < ApplicationRecord
  # 以下のモジュールの機能は Amazon Cognitoが提供するので不要
  # devise :database_authenticatable, :recoverable,  :confirmable
  devise :cognito_authenticatable
end

Amazon Cognito から渡ってくる認証結果の受け口をつくる

認可コードフローの場合、認証結果として認可コードをAmazon Cognitoからアプリケーションが受け取り、検証をする必要があるので、それに対応するルーティング及びコントローラーを作成します。

# config/routes.rb
namespace :auth do
  namespace :cognito do
    get '/callback', action: :callback
  end
end

以下のように authenticate!メソッド が呼ばれると、上で定義したストラテジー内の、Devise::Strategies::CognitoAuthenticatable#valid?メソッドが呼ばれ、その結果がtrueの場合のみ、Devise::Strategies::CognitoAuthenticatable#authenticate!メソッドが呼ばれます。

module Auth
  class CognitoController < ApplicationController
    skip_before_action :authenticate_user!

    def callback
      resource = request.env['warden'].authenticate!(:cognito_authenticatable, scope: :user)
      redirect_to after_sign_in_path_for(resource)
    end
  end
end

ここまで簡単な説明でしたがアプリケーション側の説明は以上です。今後、認証方式を拡張する必要があったとしてもAmazon Cognitoで対応可能な認証方式なら、アプリケーション側の認証インターフェースは変わらず対応可能です。

Amazon Cognito側の対応

スクリーンショットなど設定画面の詳細は説明しませんが、アプリケーションの要件に合わせて Cognito User Poolの設定をします。アプリケーションとの連携時に必要なコールバックURLを、Railsアプリケーション側の対応で、実装したAuth::CognitoControllerのcallbackアクションに対応するようにします。

これらの設定がうまく出来ていれば、認証が必要なページにアクセスすると、以下のような Amazon Cognitoが用意したログイン画面が表示されるので、この画面で Amazon Cognito User Pool に対して認証を行えば、その認証結果をRailsアプリケーション側が受け取り、ログインすることができます。

まとめ

Deviseを利用した認証を Amazon Cognitoに委譲しました。そうすることで、Railsアプリケーション側で実装していた、サインアップ・サインイン機能、ユーザー確認機能、パスワードリセット機能のコードが不要になりメンテナンスするコードを減らすことができました。

いままでDeviseを利用していて、以下のような問題を感じていました。 1点目は、ログインIDがメールアドレス前提の実装になっている箇所が多いことです。ユーザー確認URL・パスワード再発行などの通知はメール前提となっているので、電話番号をIDとしたSMS認証や、メールアドレス or 電話番号のどちらかをIDとするような、現在では一般的な認証IDに対応するために、Deviseのコードを読み解いてカスタマイズする必要がありました。 2点目は、外部ソーシャルID連携です。Deviseにはomniauthという機構が備わっており、外部ソーシャルID連携が認証プロバイダを介したユーザ認証方法によって標準化されています。omniauth系のgemを利用すれば外部ソーシャルID連携に対応することができるのですが、多少コードを書く必要があり、すべてのgemが活発に更新されているわけではないこともあって気軽には導入でません。

しかし、Amazon Cognitoを利用すれば、上で言及したIDのパターンに対応しており、IDの種類に応じたメールや、SMSなどの通知機能にも対応しています。また、いくつかの外部ソーシャルIDプロバイダとの連携にも対応しており、選択するだけで簡単に利用することができます。その他もSAML2.0に対応していたり、MFAの強制ができたり、一般的に想定される認証のケースにほとんど対応することができます。今後、よほど複雑でない認証に関しては、自前で実装する前にAmazon Cognitoをまず検討してみると良いと思いました。

おわりに

今回 Amazon Cognitoを試してみて、とても簡単に認証機能を実現することができました。RailsアプリケーションでDeviseを利用した認証を置き換える場合に参考になれば幸いです。

最後に、株式会社スタメンでは一緒に働くエンジニアを募集しています。ご興味のある方はぜひエンジニア採用サイトをご覧ください。

wantedly