はじめに
今回はdeviseの認証をcognitoに委譲し、アプリケーション側で独自のログイン画面を作成して、ユーザーがログインできるように実装していきたいと思います。
対象読者
- deviseを使用したことがある人
- deviseの認証をcognitoに委譲したい人
- deviseの認証をcognitoに委譲したが、cognitoのホストされたUIでは物足りない人
動作環境
今回はdeviseログイン画面ありのアプリケーションのひな形を作ってくれている方がいたので、そちらのプロジェクトを使用したいと思います。
実践
流れとしては下記に沿って進めていきます。
Cognitoを既に構築されている方は、3. 実装へ飛んでいただければと思います。
- Cognitoの構築
- サンプルユーザーの作成
- 実装
- 動作確認
1. Cognitoの構築
サインインエクスペリエンスを設定
今回はメールアドレスを使用するため、「Eメール」のみを有効にします。
セキュリティ要件を設定
MFAについては設定する必要がないので「MFAなし」としました。
それ以外は何も変更せずデフォルトのまま設定してあります。
サインアップエクスペリエンスを設定
ここも特別な設定などはせず、すべてデフォルトのまま次に行きます。
メッセージ配信を設定
今回はEメール送信の機能を使用しないので、どちらでも構わないのですが「CognitoでEメールを送信」を選択しておきます。
アプリケーションを統合
ここの設定が肝になります。
「秘密クライアント」・「クライアントのシークレットを生成する」を選択します。
また、認証フローでは「ALLOW_ADMIN_USER_PASSWORD_AUTH」を選択します。
以上で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のインストール
gem 'dotenv-rails'
gem 'aws-sdk-cognitoidentityprovider'
gem 'faraday'
gem 'jwt'
環境変数の設定
.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_ENDPOINT
、COGNITO_TOKEN_ISSUER
は、リージョンとユーザープールIDの穴埋めをします。
COGNITO_CLIENT_ID
・COGNITO_SECRET
は、少しわかりづらいですがアプリケーションクライアント内の画像の場所に記載されています。
Deviseまわりの設定
今回はサンプルアプリを使用しているため、Deviseのセットアップは省きます。
ルーティングの修正
Deviseの初期設定では、/users/sign_in
などのパスは、devise/sessions#new
などのDeviseから提供されるControllerにマッピングされています。
そこで、カスタムされたController(今回はUsers::SessionsController
)を使用できるように、routes.rb
を修正します。
devise_for :users, controllers: {
sessions: 'users/sessions'
}
カスタムストラテジの実装
それでは本題のカスタムストラテジの実装に入っていきます。
はじめに、認証をするときに呼び出すための authenticate!
メソッドを定義します。
authenticate!
メソッドの大まかな流れは下記になります。
- cognito sdkを使用して、認証をする
- 取得したIDトークンをデコード・検証する
- 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)
下記が最終的に出来上がったファイルになります。
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)
require Rails.root.join('lib/devise/strategies/user_cognito_authenticatable')
module Devise
module Models
module UserCognitoAuthenticatable
end
end
end
カスタムストラテジの追加
ここで先ほど定義したカスタムストラテジを使用できるように追加します。
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!
メソッドを呼び出します。
def create
request.env['warden'].authenticate!(:user_cognito_authenticatable, scope: :user)
redirect_to home_index_path
end
以上で実装は完了です。
4. 動作確認
作成したユーザーでログインすると、画面がリダイレクトされます。
左下に小さく「You are already signed in.」と表示されているので、成功しているのがわかります。
参考