0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Deviseの認証をCognitoに委譲し、カスタムログイン画面でログイン機能を実装する

Last updated at Posted at 2023-10-06

はじめに

今回はdeviseの認証をcognitoに委譲し、アプリケーション側で独自のログイン画面を作成して、ユーザーがログインできるように実装していきたいと思います。

対象読者

  • deviseを使用したことがある人
  • deviseの認証をcognitoに委譲したい人
  • deviseの認証をcognitoに委譲したが、cognitoのホストされたUIでは物足りない人

動作環境

今回はdeviseログイン画面ありのアプリケーションのひな形を作ってくれている方がいたので、そちらのプロジェクトを使用したいと思います。

実践

流れとしては下記に沿って進めていきます。
Cognitoを既に構築されている方は、3. 実装へ飛んでいただければと思います。

  1. Cognitoの構築
  2. サンプルユーザーの作成
  3. 実装
  4. 動作確認

1. Cognitoの構築

サインインエクスペリエンスを設定

今回はメールアドレスを使用するため、「Eメール」のみを有効にします。
cognito1.PNG

セキュリティ要件を設定

MFAについては設定する必要がないので「MFAなし」としました。
それ以外は何も変更せずデフォルトのまま設定してあります。
cognito2.PNG
cognito2-1.PNG

サインアップエクスペリエンスを設定

ここも特別な設定などはせず、すべてデフォルトのまま次に行きます。
cognito3.PNG
cognito3-1.PNG
cognito3-2.png

メッセージ配信を設定

今回はEメール送信の機能を使用しないので、どちらでも構わないのですが「CognitoでEメールを送信」を選択しておきます。
cognito4.png

アプリケーションを統合

ここの設定が肝になります。
「秘密クライアント」・「クライアントのシークレットを生成する」を選択します。
また、認証フローでは「ALLOW_ADMIN_USER_PASSWORD_AUTH」を選択します。
cognito5.png
cognito5-1.png
cognito5-2.png
以上でCognitoの構築完了です。

2. サンプルユーザーの作成

下記コマンドでcognitoにユーザーを作成しておきます。

cognitoユーザーの作成

aws cognito-idp admin-create-user \
--user-pool-id ${user_pool_id} \
--username ${email} \
--user-attributes Name=email,Value=${email} Name=email_verified,Value=true

ユーザーパスワードの再設定

ユーザー作成後には、パスワードを強制的に変更をしなければいけないため、下記コマンドでパスワードを変更しておきます。

aws cognito-idp admin-set-user-password \
--user-pool-id ${user_pool_id} \
--username ${email} \
--password ${password} \
--permanent

DBへのユーザー作成

次に、上記で設定したメールアドレスでDBにユーザーを作成します。

今回はDBに保存されているパスワードは使用しないため、バリデーションに通るような任意のパスワードを入力するようにしてください。

User.create!(email: "test@example.com", password: "password")

以上でユーザーの作成完了です。

3. 実装

Gemのインストール

Gemfile
gem 'dotenv-rails'
gem 'aws-sdk-cognitoidentityprovider'
gem 'faraday'
gem 'jwt'

環境変数の設定

.envファイルに下記を追記します。

.env
AWS_REGION=""
AWS_ACCESS_KEY=""
AWS_SECRET_ACCESS_KEY=""
COGNITO_CLIENT_ID=""
COGNITO_SECRET=""
COGNITO_USERPOOL_ID=""
COGNITO_JWK_ENDPOINT="https://cognito-idp.${region}.amazonaws.com/${user-pool-id}/.well-known/jwks.json"
COGNITO_TOKEN_ISSUER="https://cognito-idp.${region}.amazonaws.com/${user-pool-id}"

COGNITO_USERPOOL_IDは、ユーザープール内の「ユーザープールの概要」に記載されています。
COGNITO_JWK_ENDPOINTCOGNITO_TOKEN_ISSUERは、リージョンとユーザープールIDの穴埋めをします。

COGNITO_CLIENT_IDCOGNITO_SECRETは、少しわかりづらいですがアプリケーションクライアント内の画像の場所に記載されています。
cognito_env-1.png

Deviseまわりの設定

今回はサンプルアプリを使用しているため、Deviseのセットアップは省きます。

ルーティングの修正

Deviseの初期設定では、/users/sign_inなどのパスは、devise/sessions#newなどのDeviseから提供されるControllerにマッピングされています。

そこで、カスタムされたController(今回はUsers::SessionsController)を使用できるように、routes.rbを修正します。

config/routes.rb
devise_for :users, controllers: {
      sessions: 'users/sessions'
    }

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

それでは本題のカスタムストラテジの実装に入っていきます。
はじめに、認証をするときに呼び出すための authenticate! メソッドを定義します。
authenticate! メソッドの大まかな流れは下記になります。

  1. cognito sdkを使用して、認証をする
  2. 取得したIDトークンをデコード・検証する
  3. IDトークンに含まれているメールアドレスをもつユーザーがDBに存在するか確認する
def authenticate!
  # 1. cognito sdkを使用して、認証をする
  resp = cognito_sign_in(params['user']['email'], params['user']['password'])
  return fail!(:invalid) unless resp

  # 2. 取得したIDトークンをデコード・検証する
  decoded_token_sub, decoded_token_kid  = decode_id_token(resp.authentication_result.id_token)

  if decoded_token_sub['iss'] != ENV.fetch('COGNITO_TOKEN_ISSUER')
    raise JWT::VerificationError, 'Invalid issuer'
  end

  if decoded_token_sub['aud'] != ENV.fetch('COGNITO_CLIENT_ID')
    raise JWT::VerificationError, 'Invalid audience'
  end

  # 3. IDトークンに含まれているメールアドレスをもつユーザーがDBに存在するか確認する
  user = User.find_by(email: decoded_token_sub['email'])
  user ? success!(user) : fail!(:invalid)
end

それでは、詳細な流れを追っていきます。

1. cognito sdkを使用して、認証をする

cognito_sign_in メソッドの内部では、cognito sdkを呼び出します。
今回は admin_initiate_auth を使用し、管理者として認証をしますが、ほかにもさまざまな方法が用意されています。


また、今回は下記に該当するため secret_hash を設定する必要があります。

ユーザープールのアプリケーションクライアントがユーザープールのクライアントシークレットで設定されている場合、API のクエリ引数に SecretHash 値が必要です。

secret_hash メソッドは、AWSからハッシュ値の計算方法が提供されているため、それに従って実装したものになります。

def cognito_sign_in(username, password)
  cognito_client = Aws::CognitoIdentityProvider::Client.new(
    region: ENV.fetch('AWS_REGION'),
    access_key_id: ENV.fetch('AWS_ACCESS_KEY'),
    secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY')
  )
  cognito_client.admin_initiate_auth(
    user_pool_id: ENV.fetch('COGNITO_USERPOOL_ID'),
    client_id: ENV.fetch('COGNITO_CLIENT_ID'),
    auth_flow: 'ADMIN_USER_PASSWORD_AUTH',
    auth_parameters: {
      USERNAME: username,
      PASSWORD: password,
      SECRET_HASH: secret_hash(username)
    }
  )
rescue
  false
end

def secret_hash(username)
  data = username + ENV.fetch('COGNITO_CLIENT_ID')
  Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', ENV.fetch('COGNITO_SECRET'), data))
end

2. 取得したIDトークンをデコード・検証する

今回作成したユーザープール用のJWKを取得して、
「1. cognito sdkを使用して、認証をする」で渡されたIDトークンをデコードします。

今回はJWK取得にfaraday、デコードにjwtのGemを使用しています。

def decode_id_token(jwt)
  jwks = JSON.parse(Faraday.get(ENV.fetch('COGNITO_JWK_ENDPOINT')).body)
  JWT.decode(jwt, nil, true, {jwks: jwks, algorithms: 'RS256' })
end

IDトークンデコード後は、IDトークンの要素で検証が行えます。
今回は、2つの要素で検証を行っています。

  • トークン発行者を識別するissuer
  • 発行要求をしてきた相手を識別するaudience
decoded_token_sub, decoded_token_kid  = decode_id_token(resp.authentication_result.id_token)

if decoded_token_sub['iss'] != ENV.fetch('COGNITO_TOKEN_ISSUER')
  raise JWT::VerificationError, 'Invalid issuer'
end

if decoded_token_sub['aud'] != ENV.fetch('COGNITO_CLIENT_ID')
  raise JWT::VerificationError, 'Invalid audience'
end


3. IDトークンに含まれているメールアドレスをもつユーザーがDBに存在するか確認する

IDトークンからメールアドレスを取り出し、ユーザーを検索します。
ユーザーが存在したら、success!にUserオブジェクトを渡し、認証を成功させます。

user = User.find_by(email: decoded_token_sub['email'])
user ? success!(user) : fail!(:invalid)

下記が最終的に出来上がったファイルになります。

lib/devise/strategies/user_cognito_authenticatable.rb
require 'devise/strategies/authenticatable'

module Devise
  module Strategies
    class UserCognitoAuthenticatable < Authenticatable
      def authenticate!
        resp = cognito_sign_in(params['user']['email'], params['user']['password'])
        return fail!(:invalid) unless resp

        decoded_token_sub, decoded_token_kid  = decode_id_token(resp.authentication_result.id_token)

        if decoded_token_sub['iss'] != ENV.fetch('COGNITO_TOKEN_ISSUER')
          raise JWT::VerificationError, 'Invalid issuer'
        end

        if decoded_token_sub['aud'] != ENV.fetch('COGNITO_CLIENT_ID')
          raise JWT::VerificationError, 'Invalid audience'
        end

        user = User.find_by(email: decoded_token_sub['email'])
        user ? success!(user) : fail!(:invalid)
      end

      private

      def cognito_sign_in(username, password)
        cognito_client = Aws::CognitoIdentityProvider::Client.new(
          region: ENV.fetch('AWS_REGION'),
          access_key_id: ENV.fetch('AWS_ACCESS_KEY'),
          secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY')
        )
        cognito_client.admin_initiate_auth(
          user_pool_id: ENV.fetch('COGNITO_USERPOOL_ID'),
          client_id: ENV.fetch('COGNITO_CLIENT_ID'),
          auth_flow: 'ADMIN_USER_PASSWORD_AUTH',
          auth_parameters: {
            USERNAME: username,
            PASSWORD: password,
            SECRET_HASH: secret_hash(username)
          }
        )
      rescue
        false
      end

      def secret_hash(username)
        data = username + ENV.fetch('COGNITO_CLIENT_ID')
        Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', ENV.fetch('COGNITO_SECRET'), data))
      end

      def decode_id_token(jwt)
        jwks = JSON.parse(Faraday.get(ENV.fetch('COGNITO_JWK_ENDPOINT')).body)
        JWT.decode(jwt, nil, true, {jwks: jwks, algorithms: 'RS256' })
      end
    end
  end
end

Warden::Strategies.add(:user_cognito_authenticatable, Devise::Strategies::UserCognitoAuthenticatable)
lib/devise/models/user_cognito_authenticatable.rb
require Rails.root.join('lib/devise/strategies/user_cognito_authenticatable')
module Devise
  module Models
    module UserCognitoAuthenticatable
    end
  end
end

カスタムストラテジの追加

ここで先ほど定義したカスタムストラテジを使用できるように追加します。

app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable, 
+        :user_cognito_authenticatable
end

コントローラーの修正

app/controllers/users/sessions_controller.rb のコメントアウトを外して、上で定義したカスタムストラテジのauthenticate!メソッドを呼び出します。

app/controllers/users/sessions_controller.rb
def create
    request.env['warden'].authenticate!(:user_cognito_authenticatable, scope: :user)
    redirect_to home_index_path
end

以上で実装は完了です。

4. 動作確認

success_signin.png

作成したユーザーでログインすると、画面がリダイレクトされます。
左下に小さく「You are already signed in.」と表示されているので、成功しているのがわかります。

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?